@fabricorg/cli 0.5.0 → 0.5.1

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/dist/fabric.js CHANGED
@@ -6,7 +6,7 @@ import { Command as Command21 } from "commander";
6
6
  // package.json
7
7
  var package_default = {
8
8
  name: "@fabricorg/cli",
9
- version: "0.5.0",
9
+ version: "0.5.1",
10
10
  description: "CLI for the Fabric AI platform",
11
11
  keywords: [
12
12
  "fabric",
@@ -235,10 +235,10 @@ var FabricHttpClient = class _FabricHttpClient {
235
235
  * Inject default `org` / `personal` query params if the path doesn't already
236
236
  * carry one. Resources that explicitly pass `?org=other` are untouched.
237
237
  */
238
- buildUrl(path28) {
239
- const url = `${this.baseUrl}/api/v1${path28}`;
240
- const hasOrg = /[?&]org=/.test(path28);
241
- const hasPersonal = /[?&]personal=/.test(path28);
238
+ buildUrl(path29) {
239
+ const url = `${this.baseUrl}/api/v1${path29}`;
240
+ const hasOrg = /[?&]org=/.test(path29);
241
+ const hasPersonal = /[?&]personal=/.test(path29);
242
242
  if (hasOrg || hasPersonal) {
243
243
  return url;
244
244
  }
@@ -251,11 +251,11 @@ var FabricHttpClient = class _FabricHttpClient {
251
251
  if (additions.length === 0) {
252
252
  return url;
253
253
  }
254
- const sep = path28.includes("?") ? "&" : "?";
254
+ const sep = path29.includes("?") ? "&" : "?";
255
255
  return `${url}${sep}${additions.join("&")}`;
256
256
  }
257
- async request(method, path28, options = {}) {
258
- const url = this.buildUrl(path28);
257
+ async request(method, path29, options = {}) {
258
+ const url = this.buildUrl(path29);
259
259
  const isMutating = MUTATING_METHODS.has(method);
260
260
  const idempotencyKey = options.idempotencyKey ?? (isMutating ? randomId() : void 0);
261
261
  const maxAttempts = (options.retry ?? this.retry).maxRetries + 1;
@@ -265,7 +265,7 @@ var FabricHttpClient = class _FabricHttpClient {
265
265
  throw options.signal.reason;
266
266
  }
267
267
  const start = Date.now();
268
- this.emit({ kind: "request", method, path: path28, attempt: attempt2 });
268
+ this.emit({ kind: "request", method, path: path29, attempt: attempt2 });
269
269
  try {
270
270
  const result = await this.executeOnce(
271
271
  method,
@@ -276,7 +276,7 @@ var FabricHttpClient = class _FabricHttpClient {
276
276
  this.emit({
277
277
  kind: "response",
278
278
  method,
279
- path: path28,
279
+ path: path29,
280
280
  attempt: attempt2,
281
281
  status: 200,
282
282
  durationMs: Date.now() - start
@@ -289,7 +289,7 @@ var FabricHttpClient = class _FabricHttpClient {
289
289
  this.emit({
290
290
  kind: retriable ? "retry" : "error",
291
291
  method,
292
- path: path28,
292
+ path: path29,
293
293
  attempt: attempt2,
294
294
  status,
295
295
  durationMs: Date.now() - start,
@@ -433,28 +433,28 @@ var FabricHttpClient = class _FabricHttpClient {
433
433
  } catch {
434
434
  }
435
435
  }
436
- get(path28, options = {}) {
436
+ get(path29, options = {}) {
437
437
  return this.request(
438
438
  "GET",
439
- path28,
439
+ path29,
440
440
  options.signal ? { signal: options.signal } : {}
441
441
  );
442
442
  }
443
- post(path28, body, options = {}) {
444
- return this.request("POST", path28, {
443
+ post(path29, body, options = {}) {
444
+ return this.request("POST", path29, {
445
445
  body,
446
446
  idempotencyKey: options.idempotencyKey,
447
447
  retry: options.retry
448
448
  });
449
449
  }
450
- patch(path28, body, options = {}) {
451
- return this.request("PATCH", path28, {
450
+ patch(path29, body, options = {}) {
451
+ return this.request("PATCH", path29, {
452
452
  body,
453
453
  idempotencyKey: options.idempotencyKey
454
454
  });
455
455
  }
456
- put(path28, body, options = {}) {
457
- return this.request("PUT", path28, {
456
+ put(path29, body, options = {}) {
457
+ return this.request("PUT", path29, {
458
458
  body,
459
459
  idempotencyKey: options.idempotencyKey,
460
460
  retry: options.retry
@@ -465,8 +465,8 @@ var FabricHttpClient = class _FabricHttpClient {
465
465
  * delete names the path and the version in its body). Without `body`,
466
466
  * nothing is sent, as before.
467
467
  */
468
- delete(path28, body, options = {}) {
469
- return this.request("DELETE", path28, {
468
+ delete(path29, body, options = {}) {
469
+ return this.request("DELETE", path29, {
470
470
  body,
471
471
  idempotencyKey: options.idempotencyKey
472
472
  });
@@ -1058,14 +1058,14 @@ function buildQuery3(opts) {
1058
1058
 
1059
1059
  // ../sdk/src/resources/integrations.ts
1060
1060
  var RESOURCE_BUILTINS = /* @__PURE__ */ new Set(["list", "constructor", "then"]);
1061
- function makeDotPath(http, slug, path28) {
1062
- const fn = (args) => http.post(`/integrations/${slug}/${path28.join(".")}`, args ?? {});
1061
+ function makeDotPath(http, slug, path29) {
1062
+ const fn = (args) => http.post(`/integrations/${slug}/${path29.join(".")}`, args ?? {});
1063
1063
  return new Proxy(fn, {
1064
1064
  get(target, prop) {
1065
1065
  if (prop in target || typeof prop !== "string") {
1066
1066
  return Reflect.get(target, prop);
1067
1067
  }
1068
- return makeDotPath(http, slug, [...path28, prop]);
1068
+ return makeDotPath(http, slug, [...path29, prop]);
1069
1069
  }
1070
1070
  });
1071
1071
  }
@@ -1743,10 +1743,21 @@ import path from "path";
1743
1743
  import Conf from "conf";
1744
1744
 
1745
1745
  // src/lib/exclusive-lock.ts
1746
- import { closeSync, openSync, statSync, unlinkSync, writeSync } from "fs";
1746
+ import { randomUUID } from "crypto";
1747
+ import {
1748
+ closeSync,
1749
+ openSync,
1750
+ readFileSync,
1751
+ statSync,
1752
+ unlinkSync,
1753
+ writeSync
1754
+ } from "fs";
1747
1755
  var ExclusiveLockBusyError = class extends Error {
1748
- constructor() {
1749
- super("another process holds this lock");
1756
+ constructor(abandoned = false) {
1757
+ super(
1758
+ abandoned ? "an abandoned lock must be removed manually" : "another process holds this lock"
1759
+ );
1760
+ this.abandoned = abandoned;
1750
1761
  this.name = "ExclusiveLockBusyError";
1751
1762
  }
1752
1763
  };
@@ -1757,11 +1768,15 @@ function sleep2(ms) {
1757
1768
  function isExistsError(error) {
1758
1769
  return typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
1759
1770
  }
1771
+ function isNotFoundError(error) {
1772
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
1773
+ }
1760
1774
  function takeOnce(lockPath2, staleMs) {
1761
1775
  try {
1762
1776
  const descriptor = openSync(lockPath2, "wx", 384);
1763
- writeSync(descriptor, String(process.pid));
1764
- return descriptor;
1777
+ const owner = `${process.pid}:${randomUUID()}`;
1778
+ writeSync(descriptor, owner);
1779
+ return { descriptor, owner };
1765
1780
  } catch (error) {
1766
1781
  if (!isExistsError(error)) {
1767
1782
  throw error;
@@ -1769,41 +1784,71 @@ function takeOnce(lockPath2, staleMs) {
1769
1784
  }
1770
1785
  try {
1771
1786
  if (Date.now() - statSync(lockPath2).mtimeMs > staleMs) {
1772
- unlinkSync(lockPath2);
1787
+ if (ownerIsAlive(readFileSync(lockPath2, "utf8"))) {
1788
+ return "busy";
1789
+ }
1790
+ return "abandoned";
1791
+ }
1792
+ } catch (error) {
1793
+ if (isNotFoundError(error)) {
1773
1794
  return "retry";
1774
1795
  }
1775
- } catch {
1776
- return "retry";
1796
+ throw error;
1777
1797
  }
1778
1798
  return "busy";
1779
1799
  }
1780
- function release(descriptor, lockPath2) {
1781
- closeSync(descriptor);
1800
+ function ownerIsAlive(owner) {
1801
+ const match = /^(\d+)(?::[0-9a-f-]+)?$/.exec(owner);
1802
+ if (!match) {
1803
+ return false;
1804
+ }
1805
+ const pid = Number(match[1]);
1806
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
1807
+ return false;
1808
+ }
1782
1809
  try {
1783
- unlinkSync(lockPath2);
1810
+ process.kill(pid, 0);
1811
+ return true;
1812
+ } catch (error) {
1813
+ const code = error.code;
1814
+ return code === "EPERM" || code !== "ESRCH" && code !== "ENOENT";
1815
+ }
1816
+ }
1817
+ function release(held, lockPath2) {
1818
+ closeSync(held.descriptor);
1819
+ try {
1820
+ if (readFileSync(lockPath2, "utf8") === held.owner) {
1821
+ unlinkSync(lockPath2);
1822
+ }
1784
1823
  } catch {
1785
1824
  }
1786
1825
  }
1787
1826
  async function withExclusiveLock(work, options) {
1788
1827
  const { lockPath: lockPath2, staleMs } = options;
1789
1828
  const deadline = Date.now() + options.waitMs;
1790
- let descriptor;
1791
- while (descriptor === void 0) {
1829
+ let held;
1830
+ while (held === void 0) {
1792
1831
  options.signal?.throwIfAborted();
1793
1832
  const taken = takeOnce(lockPath2, staleMs);
1794
- if (typeof taken === "number") {
1795
- descriptor = taken;
1833
+ if (typeof taken === "object") {
1834
+ held = taken;
1835
+ } else if (taken === "abandoned") {
1836
+ throw new ExclusiveLockBusyError(true);
1796
1837
  } else if (taken === "busy") {
1797
1838
  if (Date.now() >= deadline) {
1798
1839
  throw new ExclusiveLockBusyError();
1799
1840
  }
1800
1841
  await sleep2(options.pollMs ?? DEFAULT_POLL_MS);
1842
+ } else if (Date.now() >= deadline) {
1843
+ throw new ExclusiveLockBusyError();
1844
+ } else {
1845
+ await sleep2(options.pollMs ?? DEFAULT_POLL_MS);
1801
1846
  }
1802
1847
  }
1803
1848
  try {
1804
1849
  return await work();
1805
1850
  } finally {
1806
- release(descriptor, lockPath2);
1851
+ release(held, lockPath2);
1807
1852
  }
1808
1853
  }
1809
1854
  function sleepSync(ms) {
@@ -1812,22 +1857,28 @@ function sleepSync(ms) {
1812
1857
  function withExclusiveLockSync(work, options) {
1813
1858
  const { lockPath: lockPath2, staleMs } = options;
1814
1859
  const deadline = Date.now() + options.waitMs;
1815
- let descriptor;
1816
- while (descriptor === void 0) {
1860
+ let held;
1861
+ while (held === void 0) {
1817
1862
  const taken = takeOnce(lockPath2, staleMs);
1818
- if (typeof taken === "number") {
1819
- descriptor = taken;
1863
+ if (typeof taken === "object") {
1864
+ held = taken;
1865
+ } else if (taken === "abandoned") {
1866
+ throw new ExclusiveLockBusyError(true);
1820
1867
  } else if (taken === "busy") {
1821
1868
  if (Date.now() >= deadline) {
1822
1869
  throw new ExclusiveLockBusyError();
1823
1870
  }
1824
1871
  sleepSync(options.pollMs ?? DEFAULT_POLL_MS);
1872
+ } else if (Date.now() >= deadline) {
1873
+ throw new ExclusiveLockBusyError();
1874
+ } else {
1875
+ sleepSync(options.pollMs ?? DEFAULT_POLL_MS);
1825
1876
  }
1826
1877
  }
1827
1878
  try {
1828
1879
  return work();
1829
1880
  } finally {
1830
- release(descriptor, lockPath2);
1881
+ release(held, lockPath2);
1831
1882
  }
1832
1883
  }
1833
1884
 
@@ -1935,7 +1986,7 @@ function update(change) {
1935
1986
  );
1936
1987
  } catch (error) {
1937
1988
  throw error instanceof ExclusiveLockBusyError ? new Error(
1938
- "Another fabric process is updating the configuration and did not finish. Try again."
1989
+ error.abandoned ? `A previous Fabric process left its configuration lock at ${JSON.stringify(lockPath2)}. Confirm it is no longer running, remove that lock, then try again.` : "Another fabric process is updating the configuration and did not finish. Try again."
1939
1990
  ) : error;
1940
1991
  }
1941
1992
  }
@@ -2257,8 +2308,8 @@ function sameOriginEndpoint(value, field2, issuer) {
2257
2308
  }
2258
2309
  function authorizationServerMetadataUrl(issuer) {
2259
2310
  const url = new URL(issuer);
2260
- const path28 = url.pathname.replace(/\/+$/, "");
2261
- return `${url.origin}/.well-known/oauth-authorization-server${path28}`;
2311
+ const path29 = url.pathname.replace(/\/+$/, "");
2312
+ return `${url.origin}/.well-known/oauth-authorization-server${path29}`;
2262
2313
  }
2263
2314
  async function discoverAuthorizationServer(baseUrl, options = {}) {
2264
2315
  const fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
@@ -2704,24 +2755,25 @@ var OAuthIssuerMismatchError = class extends Error {
2704
2755
  }
2705
2756
  };
2706
2757
  var OAuthRefreshBusyError = class extends Error {
2707
- constructor() {
2758
+ constructor(lockPath2) {
2708
2759
  super(
2709
- "Another fabric process is renewing the sign-in and did not finish. Try again."
2760
+ lockPath2 ? `A previous Fabric process left its sign-in lock at ${JSON.stringify(lockPath2)}. Confirm it is no longer running, remove that lock, then try again.` : "Another fabric process is renewing the sign-in and did not finish. Try again."
2710
2761
  );
2711
2762
  this.name = "OAuthRefreshBusyError";
2712
2763
  }
2713
2764
  };
2714
2765
  async function withRefreshLock(work, options = {}) {
2766
+ const lockPath2 = options.lockPath ?? `${getConfigPath()}.refresh.lock`;
2715
2767
  try {
2716
2768
  return await withExclusiveLock(work, {
2717
- lockPath: options.lockPath ?? `${getConfigPath()}.refresh.lock`,
2769
+ lockPath: lockPath2,
2718
2770
  staleMs: LOCK_STALE_MS,
2719
2771
  waitMs: options.waitMs ?? LOCK_WAIT_MS,
2720
2772
  pollMs: LOCK_POLL_MS,
2721
2773
  signal: options.signal
2722
2774
  });
2723
2775
  } catch (error) {
2724
- throw error instanceof ExclusiveLockBusyError ? new OAuthRefreshBusyError() : error;
2776
+ throw error instanceof ExclusiveLockBusyError ? new OAuthRefreshBusyError(error.abandoned ? lockPath2 : void 0) : error;
2725
2777
  }
2726
2778
  }
2727
2779
  function defaults(deps) {
@@ -3530,8 +3582,9 @@ function decideCheckoutVerdict(input) {
3530
3582
  }
3531
3583
 
3532
3584
  // src/lib/user-agent.ts
3585
+ var INSTRUCTIONS_STREAMING_CAPABILITY = "instructions-stream-v1";
3533
3586
  function cliUserAgent(version = package_default.version, nodeVersion = process.versions.node, platform = process.platform) {
3534
- return `fabric-cli/${version} (node/${nodeVersion}; ${platform})`;
3587
+ return `fabric-cli/${version} (node/${nodeVersion}; ${platform}; ${INSTRUCTIONS_STREAMING_CAPABILITY})`;
3535
3588
  }
3536
3589
  var MAX_NOTICE_CHARS = 300;
3537
3590
  var notice = null;
@@ -4815,7 +4868,7 @@ async function assertWritableTarget(root, relativePath) {
4815
4868
  }
4816
4869
  return check;
4817
4870
  }
4818
- async function readFileSafely(root, relativePath, options = {}) {
4871
+ async function openFileSafely(root, relativePath) {
4819
4872
  const check = await assertWritableTarget(root, relativePath);
4820
4873
  if (!check.ok) {
4821
4874
  throw new Error(`Refusing to sync: ${describeRejection(check)}.`);
@@ -4851,15 +4904,31 @@ async function readFileSafely(root, relativePath, options = {}) {
4851
4904
  `Refusing to sync: ${relativePath} changed while it was being opened.`
4852
4905
  );
4853
4906
  }
4907
+ return { handle, stats: opened };
4908
+ } catch (error) {
4909
+ await handle.close().catch(() => {
4910
+ });
4911
+ throw error;
4912
+ }
4913
+ }
4914
+ async function readFileSafely(root, relativePath, options = {}) {
4915
+ const opened = await openFileSafely(root, relativePath);
4916
+ if (opened === null) {
4917
+ return null;
4918
+ }
4919
+ try {
4854
4920
  if (options.maxBytes === void 0) {
4855
- return { bytes: await handle.readFile(), mode: opened.mode };
4921
+ return {
4922
+ bytes: await opened.handle.readFile(),
4923
+ mode: opened.stats.mode
4924
+ };
4856
4925
  }
4857
4926
  if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0) {
4858
4927
  throw new Error(
4859
4928
  "A safe file-read limit must be a non-negative integer."
4860
4929
  );
4861
4930
  }
4862
- if (opened.size > options.maxBytes) {
4931
+ if (opened.stats.size > options.maxBytes) {
4863
4932
  throw new Error(
4864
4933
  `${relativePath} is too large to read safely (maximum ${options.maxBytes} bytes).`
4865
4934
  );
@@ -4867,7 +4936,7 @@ async function readFileSafely(root, relativePath, options = {}) {
4867
4936
  const buffer = Buffer.allocUnsafe(options.maxBytes + 1);
4868
4937
  let total = 0;
4869
4938
  while (total <= options.maxBytes) {
4870
- const { bytesRead } = await handle.read(
4939
+ const { bytesRead } = await opened.handle.read(
4871
4940
  buffer,
4872
4941
  total,
4873
4942
  options.maxBytes + 1 - total,
@@ -4883,9 +4952,44 @@ async function readFileSafely(root, relativePath, options = {}) {
4883
4952
  `${relativePath} is too large to read safely (maximum ${options.maxBytes} bytes).`
4884
4953
  );
4885
4954
  }
4886
- return { bytes: buffer.subarray(0, total), mode: opened.mode };
4955
+ return { bytes: buffer.subarray(0, total), mode: opened.stats.mode };
4887
4956
  } finally {
4888
- await handle.close();
4957
+ await opened.handle.close();
4958
+ }
4959
+ }
4960
+ var FINGERPRINT_BUFFER_BYTES = 64 * 1024;
4961
+ async function fingerprintFileSafely(root, relativePath) {
4962
+ const opened = await openFileSafely(root, relativePath);
4963
+ if (opened === null) {
4964
+ return null;
4965
+ }
4966
+ try {
4967
+ const hash = createHash2("sha256");
4968
+ const buffer = Buffer.allocUnsafe(FINGERPRINT_BUFFER_BYTES);
4969
+ let size = 0;
4970
+ while (size <= opened.stats.size) {
4971
+ const remaining = opened.stats.size + 1 - size;
4972
+ const { bytesRead } = await opened.handle.read(
4973
+ buffer,
4974
+ 0,
4975
+ Math.min(buffer.length, remaining),
4976
+ null
4977
+ );
4978
+ if (bytesRead === 0) {
4979
+ break;
4980
+ }
4981
+ hash.update(buffer.subarray(0, bytesRead));
4982
+ size += bytesRead;
4983
+ }
4984
+ const afterRead = await opened.handle.stat();
4985
+ if (size !== opened.stats.size || afterRead.size !== opened.stats.size || afterRead.dev !== opened.stats.dev || afterRead.ino !== opened.stats.ino) {
4986
+ throw new Error(
4987
+ `Refusing to sync: ${relativePath} changed while it was being read.`
4988
+ );
4989
+ }
4990
+ return { sha256: hash.digest("hex"), size, mode: opened.stats.mode };
4991
+ } finally {
4992
+ await opened.handle.close();
4889
4993
  }
4890
4994
  }
4891
4995
  async function resolveExistingRoot(destination) {
@@ -5070,23 +5174,23 @@ function normalizeContextSourcePath(input) {
5070
5174
  if (slashed.startsWith("/") || DRIVE_LETTER_PREFIX.test(slashed)) {
5071
5175
  return { ok: false, reason: "absolute" };
5072
5176
  }
5073
- let path28 = slashed.replace(/\/{2,}/g, "/");
5074
- while (path28.startsWith("./")) {
5075
- path28 = path28.slice(2);
5177
+ let path29 = slashed.replace(/\/{2,}/g, "/");
5178
+ while (path29.startsWith("./")) {
5179
+ path29 = path29.slice(2);
5076
5180
  }
5077
- if (path28.length === 0) {
5181
+ if (path29.length === 0) {
5078
5182
  return { ok: false, reason: "empty" };
5079
5183
  }
5080
- if (path28.endsWith("/")) {
5184
+ if (path29.endsWith("/")) {
5081
5185
  return { ok: false, reason: "trailing-slash" };
5082
5186
  }
5083
- if (path28.split("/").some((segment) => segment === "." || segment === "..")) {
5187
+ if (path29.split("/").some((segment) => segment === "." || segment === "..")) {
5084
5188
  return { ok: false, reason: "dot-segment" };
5085
5189
  }
5086
- if (path28.length > MAX_CONTEXT_SOURCE_PATH_LENGTH) {
5190
+ if (path29.length > MAX_CONTEXT_SOURCE_PATH_LENGTH) {
5087
5191
  return { ok: false, reason: "too-long" };
5088
5192
  }
5089
- return { ok: true, sourcePath: path28 };
5193
+ return { ok: true, sourcePath: path29 };
5090
5194
  }
5091
5195
 
5092
5196
  // src/lib/context-sync/lock.ts
@@ -5974,9 +6078,9 @@ async function pushContextPlan(input) {
5974
6078
  }
5975
6079
  };
5976
6080
  const pruneTargetsSoFar = () => {
5977
- const keptByMoves = results.map(moveKeptOldPath).filter((path28) => path28 !== null);
5978
- return [.../* @__PURE__ */ new Set([...input.plan.removed, ...keptByMoves])].filter((path28) => {
5979
- const locked = input.lock?.files[path28];
6081
+ const keptByMoves = results.map(moveKeptOldPath).filter((path29) => path29 !== null);
6082
+ return [.../* @__PURE__ */ new Set([...input.plan.removed, ...keptByMoves])].filter((path29) => {
6083
+ const locked = input.lock?.files[path29];
5980
6084
  return locked !== void 0 && locked.state !== "duplicate";
5981
6085
  }).sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
5982
6086
  };
@@ -6614,7 +6718,7 @@ function pruneSummary(results, targets) {
6614
6718
  (r) => r.status === "deleted" || r.status === "already-gone"
6615
6719
  ).map((r) => r.sourcePath)
6616
6720
  );
6617
- const notDeleted = targets.filter((path28) => !settled.has(path28)).length;
6721
+ const notDeleted = targets.filter((path29) => !settled.has(path29)).length;
6618
6722
  let text3 = `--prune: ${count("deleted")} deleted, ${count("already-gone")} already gone, ${notDeleted} not deleted`;
6619
6723
  const first = results.find((r) => r.status === "delete-failed");
6620
6724
  if (first && first.status === "delete-failed") {
@@ -6997,8 +7101,8 @@ function buildFramesCommand() {
6997
7101
  }
6998
7102
 
6999
7103
  // src/commands/instructions/index.ts
7000
- import { readdir as readdir4 } from "fs/promises";
7001
- import path27 from "path";
7104
+ import { readdir as readdir3 } from "fs/promises";
7105
+ import path28 from "path";
7002
7106
  import { Command as Command12 } from "commander";
7003
7107
 
7004
7108
  // src/lib/environment.ts
@@ -7122,6 +7226,11 @@ function runGit(cwd, args, deadline, options = {}) {
7122
7226
  child = spawn2(
7123
7227
  "git",
7124
7228
  [
7229
+ // Windows Git can report modified long-path files from its file
7230
+ // system cache even when Git's diff is empty. This command-local
7231
+ // setting keeps inspection truthful without changing the user's
7232
+ // checkout configuration.
7233
+ ...process.platform === "win32" ? ["-c", "core.fscache=false"] : [],
7125
7234
  "-c",
7126
7235
  "core.fsmonitor=false",
7127
7236
  ...options.write ? ["-c", "credential.interactive=never"] : [],
@@ -7541,6 +7650,77 @@ async function isClean(root, deadline) {
7541
7650
  deadline
7542
7651
  );
7543
7652
  }
7653
+ async function trackedPaths(root, relativePaths, deadline) {
7654
+ if (relativePaths.length === 0) {
7655
+ return { kind: "ok", value: [] };
7656
+ }
7657
+ if (relativePaths.some(
7658
+ (relativePath) => relativePath === "" || relativePath.startsWith("-") || path7.isAbsolute(relativePath) || path7.win32.isAbsolute(relativePath) || relativePath.split(/[\\/]/).some((part) => part === "..")
7659
+ )) {
7660
+ return {
7661
+ kind: "unavailable",
7662
+ reason: "not paths git can be asked about"
7663
+ };
7664
+ }
7665
+ const result = await simple(
7666
+ root,
7667
+ ["ls-files", "-z", "--", ...relativePaths],
7668
+ deadline
7669
+ );
7670
+ if (result.kind !== "ok") {
7671
+ return result;
7672
+ }
7673
+ if (result.value.code !== 0) {
7674
+ return {
7675
+ kind: "unavailable",
7676
+ reason: `git exited with status ${result.value.code}`
7677
+ };
7678
+ }
7679
+ return {
7680
+ kind: "ok",
7681
+ value: result.value.stdout.split("\0").filter((entry) => entry !== "")
7682
+ };
7683
+ }
7684
+ async function checkoutAppearsIncomplete(root, deadline) {
7685
+ const index = await simple(root, ["ls-files", "--stage", "-z"], deadline);
7686
+ if (index.kind !== "ok") {
7687
+ return index;
7688
+ }
7689
+ if (index.value.code !== 0) {
7690
+ return {
7691
+ kind: "unavailable",
7692
+ reason: `git exited with status ${index.value.code}`
7693
+ };
7694
+ }
7695
+ if (index.value.stdout !== "") {
7696
+ return { kind: "ok", value: false };
7697
+ }
7698
+ const [headTree, staged, untracked] = await Promise.all([
7699
+ simple(root, ["ls-tree", "-r", "-z", "--name-only", "HEAD"], deadline),
7700
+ simple(root, ["diff", "--cached", "--quiet", "--exit-code"], deadline),
7701
+ simple(
7702
+ root,
7703
+ ["ls-files", "--others", "--exclude-standard", "-z"],
7704
+ deadline
7705
+ )
7706
+ ]);
7707
+ if (headTree.kind !== "ok") {
7708
+ return headTree;
7709
+ }
7710
+ if (staged.kind !== "ok") {
7711
+ return staged;
7712
+ }
7713
+ if (untracked.kind !== "ok") {
7714
+ return untracked;
7715
+ }
7716
+ if (headTree.value.code !== 0 || staged.value.code !== 0 && staged.value.code !== 1 || untracked.value.code !== 0) {
7717
+ return { kind: "unavailable", reason: "git answered unexpectedly" };
7718
+ }
7719
+ return {
7720
+ kind: "ok",
7721
+ value: headTree.value.stdout !== "" && staged.value.code === 1 && untracked.value.stdout !== ""
7722
+ };
7723
+ }
7544
7724
  async function porcelainIsEmpty(root, args, deadline) {
7545
7725
  const result = await simple(
7546
7726
  root,
@@ -7767,6 +7947,11 @@ function cloneFailureOf(stderr) {
7767
7947
  if (/Remote branch .* not found/i.test(stderr)) {
7768
7948
  return "missing-ref";
7769
7949
  }
7950
+ if (/Filename too long|unable to create file|unable to checkout working tree/i.test(
7951
+ stderr
7952
+ )) {
7953
+ return "checkout";
7954
+ }
7770
7955
  if (/could not read (Username|Password)|terminal prompts disabled|Authentication failed|Permission denied|HTTP (401|403)|error: (401|403)|returned error: (401|403)|Repository not found|access denied/i.test(
7771
7956
  stderr
7772
7957
  )) {
@@ -7788,7 +7973,22 @@ async function cloneInto(dir, url, ref, deadline) {
7788
7973
  }
7789
7974
  const result = await runGit(
7790
7975
  dir,
7791
- ["clone", "--quiet", "--branch", ref, "--", url, "."],
7976
+ [
7977
+ ...process.platform === "win32" ? ["-c", "core.longpaths=true", "-c", "core.fscache=false"] : [],
7978
+ "clone",
7979
+ "--quiet",
7980
+ ...process.platform === "win32" ? [
7981
+ "--config",
7982
+ "core.longpaths=true",
7983
+ "--config",
7984
+ "core.fscache=false"
7985
+ ] : [],
7986
+ "--branch",
7987
+ ref,
7988
+ "--",
7989
+ url,
7990
+ "."
7991
+ ],
7792
7992
  deadline,
7793
7993
  { write: true }
7794
7994
  );
@@ -7879,16 +8079,19 @@ var LINES = {
7879
8079
  "clone-needs-project": () => "--clone <folder> needs --project <id>: there is no checkout yet to find the project from.",
7880
8080
  "clone-needs-repository": () => "--clone needs a project whose instructions come from a git repository; this project's are uploaded, so there is nothing to clone.",
7881
8081
  "clone-folder-in-use": () => "That folder already exists and is not empty, so nothing was cloned into it. Pick a folder that does not exist yet, or run init inside it when it already is the clone.",
8082
+ "clone-checkout-needs-repair": () => "This folder appears to be an incomplete Git checkout. Fabric left it untouched. Inspect its staged and untracked files with git status, then repair or remove the checkout yourself before running init again.",
7882
8083
  "clone-failed": ({ repo, ref, host, url, reason, login }) => {
7883
8084
  switch (reason) {
7884
8085
  case "auth":
7885
- return `Could not clone ${repo}: git has no credentials for ${shown(host)}. Run: ${login}`;
8086
+ return `Could not clone ${repo}: git has no credentials for ${shown(host)}. The Fabric MCP server was not registered. Run: ${login}`;
7886
8087
  case "network":
7887
- return `Could not clone ${repo}: ${shown(host)} did not answer. Check your network and try again.`;
8088
+ return `Could not clone ${repo}: ${shown(host)} did not answer. The Fabric MCP server was not registered. Check your network and try again.`;
7888
8089
  case "missing-ref":
7889
- return `Could not clone ${repo}: it has no branch ${shown(ref)}.`;
8090
+ return `Could not clone ${repo}: it has no branch ${shown(ref)}. The Fabric MCP server was not registered.`;
8091
+ case "checkout":
8092
+ return `Could not finish checking out ${repo}. Fabric did not change the partial checkout or register its MCP server. Inspect it with git status, then repair or remove it yourself before trying again.`;
7890
8093
  case "other":
7891
- return `Could not clone ${repo}. Run: git clone -- ${url} to see why.`;
8094
+ return `Could not clone ${repo}. The Fabric MCP server was not registered. Run: git clone -- ${url} to see why.`;
7892
8095
  default:
7893
8096
  return reason;
7894
8097
  }
@@ -7964,6 +8167,7 @@ var LINES = {
7964
8167
  }
7965
8168
  },
7966
8169
  "ff-locked": () => `${HOOK_PREFIX}: another fabric process is updating this checkout; nothing was changed.`,
8170
+ "ff-abandoned-lock": ({ lockPath: lockPath2 }) => `${HOOK_PREFIX}: a previous Fabric process left its lock at ${JSON.stringify(lockPath2)}; nothing was changed. Confirm it is no longer running, remove that lock, then run the hook again.`,
7967
8171
  "ff-fabric-lags": ({ reason, ref, sha7 }) => `${HOOK_PREFIX}: ${ref} is at ${sha7}; Fabric's copy is behind (${LAG_TEXT[reason]}).`,
7968
8172
  "class-foreign": ({ repo }) => `${HOOK_PREFIX}: no remote of this checkout fetches from ${repo}; nothing was checked.`,
7969
8173
  "class-ambiguous": ({ repo, remotes: remotes2 }) => `${HOOK_PREFIX}: remotes ${remotes2.join(", ")} all fetch from ${repo}; nothing was checked. Run: ${fabricCommand(`instructions init --remote ${remotes2[0] ?? "origin"}`)}`,
@@ -8017,6 +8221,7 @@ var EXIT_CODES = {
8017
8221
  "clone-needs-project": 2,
8018
8222
  "clone-needs-repository": 7,
8019
8223
  "clone-folder-in-use": 7,
8224
+ "clone-checkout-needs-repair": 7,
8020
8225
  "clone-failed": ({ reason }) => reason === "auth" ? 3 : reason === "missing-ref" ? 7 : 1,
8021
8226
  "no-clone-url": 7,
8022
8227
  "lock-removed": 0,
@@ -8030,6 +8235,7 @@ var EXIT_CODES = {
8030
8235
  "ff-fetch-failed": 0,
8031
8236
  "ff-merge-failed": 0,
8032
8237
  "ff-locked": 0,
8238
+ "ff-abandoned-lock": 0,
8033
8239
  "ff-fabric-lags": 0,
8034
8240
  "class-foreign": 7,
8035
8241
  "class-ambiguous": 7,
@@ -8899,6 +9105,9 @@ async function readRegistration(input) {
8899
9105
  }
8900
9106
 
8901
9107
  // src/lib/instructions/hook.ts
9108
+ import { createHash as createHash4 } from "crypto";
9109
+ import { mkdir as mkdir2 } from "fs/promises";
9110
+ import { tmpdir } from "os";
8902
9111
  import path11 from "path";
8903
9112
 
8904
9113
  // src/lib/instructions/identifiers.ts
@@ -8917,6 +9126,8 @@ var CLAUDE_SETTINGS_POSIX_PATH = ".claude/settings.local.json";
8917
9126
  var CODEX_HOOKS_POSIX_PATH = ".codex/hooks.json";
8918
9127
  var HOOK_TIMEOUT_SECONDS = 15;
8919
9128
  var MAX_HOOK_CONFIG_BYTES = 1024 * 1024;
9129
+ var HOOK_LOCK_STALE_MS = 3e4;
9130
+ var HOOK_LOCK_WAIT_MS = 5e3;
8920
9131
  var HOOK_SUBCOMMANDS = /* @__PURE__ */ new Set(["check", "sync", "lesson-prompt"]);
8921
9132
  var SESSION_START_SUBCOMMANDS = /* @__PURE__ */ new Set([
8922
9133
  "check",
@@ -8926,6 +9137,26 @@ var STOP_SUBCOMMANDS = /* @__PURE__ */ new Set(["lesson-prompt"]);
8926
9137
  function subcommandsForEvent(event) {
8927
9138
  return event === "Stop" ? STOP_SUBCOMMANDS : SESSION_START_SUBCOMMANDS;
8928
9139
  }
9140
+ async function withHookConfigLock(root, posixPath, work) {
9141
+ const directory = path11.join(tmpdir(), "fabric-instructions-hook-locks");
9142
+ await mkdir2(directory, { recursive: true });
9143
+ const identity = createHash4("sha256").update(path11.resolve(root)).update("\0").update(posixPath).digest("hex");
9144
+ const lockPath2 = path11.join(directory, `${identity}.lock`);
9145
+ try {
9146
+ return await withExclusiveLock(work, {
9147
+ lockPath: lockPath2,
9148
+ staleMs: HOOK_LOCK_STALE_MS,
9149
+ waitMs: HOOK_LOCK_WAIT_MS
9150
+ });
9151
+ } catch (error) {
9152
+ if (error instanceof ExclusiveLockBusyError && error.abandoned) {
9153
+ throw new Error(
9154
+ `A previous Fabric process left the hook configuration lock at ${JSON.stringify(lockPath2)}. Confirm it is no longer running, remove that lock, then run init again.`
9155
+ );
9156
+ }
9157
+ throw error;
9158
+ }
9159
+ }
8929
9160
  function assertCommandIdentifiers(projectId, org) {
8930
9161
  if (!isIdentifier(projectId) || org !== void 0 && !isIdentifier(org)) {
8931
9162
  throw new Error(
@@ -9043,85 +9274,89 @@ function pruneMatching(groups, projectId, subcommands) {
9043
9274
  }
9044
9275
  async function mergeCommandHook(input) {
9045
9276
  const hookTarget = hookTargetFor(input.tool ?? "claude-code");
9046
- const settingsPath = path11.join(input.root, hookTarget.relativePath);
9047
- const { settings, existingRaw } = await readHookConfig(
9048
- input.root,
9049
- hookTarget
9050
- );
9051
- const { hooks, groups } = readEventGroups(
9052
- settingsPath,
9053
- settings,
9054
- input.event
9055
- );
9056
- const replacement = {
9057
- type: "command",
9058
- command: input.command,
9059
- timeout: HOOK_TIMEOUT_SECONDS
9060
- };
9061
- const { pruned, removedCount } = pruneMatching(
9062
- groups,
9063
- input.projectId,
9064
- subcommandsForEvent(input.event)
9065
- );
9066
- pruned.push({ hooks: [replacement] });
9067
- const next = {
9068
- ...settings,
9069
- hooks: { ...hooks, [input.event]: pruned }
9070
- };
9071
- await writeFileSafely({
9072
- root: input.root,
9073
- relativePath: hookTarget.posixPath,
9074
- bytes: new TextEncoder().encode(
9075
- `${JSON.stringify(next, null, 2).replace(/\r\n/g, "\n")}
9277
+ return withHookConfigLock(input.root, hookTarget.posixPath, async () => {
9278
+ const settingsPath = path11.join(input.root, hookTarget.relativePath);
9279
+ const { settings, existingRaw } = await readHookConfig(
9280
+ input.root,
9281
+ hookTarget
9282
+ );
9283
+ const { hooks, groups } = readEventGroups(
9284
+ settingsPath,
9285
+ settings,
9286
+ input.event
9287
+ );
9288
+ const replacement = {
9289
+ type: "command",
9290
+ command: input.command,
9291
+ timeout: HOOK_TIMEOUT_SECONDS
9292
+ };
9293
+ const { pruned, removedCount } = pruneMatching(
9294
+ groups,
9295
+ input.projectId,
9296
+ subcommandsForEvent(input.event)
9297
+ );
9298
+ pruned.push({ hooks: [replacement] });
9299
+ const next = {
9300
+ ...settings,
9301
+ hooks: { ...hooks, [input.event]: pruned }
9302
+ };
9303
+ await writeFileSafely({
9304
+ root: input.root,
9305
+ relativePath: hookTarget.posixPath,
9306
+ bytes: new TextEncoder().encode(
9307
+ `${JSON.stringify(next, null, 2).replace(/\r\n/g, "\n")}
9076
9308
  `
9077
- )
9309
+ )
9310
+ });
9311
+ return {
9312
+ settingsPath,
9313
+ command: input.command,
9314
+ createdFile: existingRaw === null,
9315
+ replacedCount: removedCount
9316
+ };
9078
9317
  });
9079
- return {
9080
- settingsPath,
9081
- command: input.command,
9082
- createdFile: existingRaw === null,
9083
- replacedCount: removedCount
9084
- };
9085
9318
  }
9086
9319
  async function mergeSessionStartHook(input) {
9087
9320
  return mergeCommandHook({ ...input, event: "SessionStart" });
9088
9321
  }
9089
9322
  async function removeCommandHook(input) {
9090
9323
  const hookTarget = hookTargetFor(input.tool ?? "claude-code");
9091
- const settingsPath = path11.join(input.root, hookTarget.relativePath);
9092
- const { settings, existingRaw } = await readHookConfig(
9093
- input.root,
9094
- hookTarget
9095
- );
9096
- if (existingRaw === null) {
9097
- return { settingsPath, changed: false };
9098
- }
9099
- const { hooks, groups } = readEventGroups(
9100
- settingsPath,
9101
- settings,
9102
- input.event
9103
- );
9104
- const { pruned, removedCount } = pruneMatching(
9105
- groups,
9106
- input.projectId,
9107
- /* @__PURE__ */ new Set([input.subcommand])
9108
- );
9109
- if (removedCount === 0) {
9110
- return { settingsPath, changed: false };
9111
- }
9112
- const next = {
9113
- ...settings,
9114
- hooks: { ...hooks, [input.event]: pruned }
9115
- };
9116
- await writeFileSafely({
9117
- root: input.root,
9118
- relativePath: hookTarget.posixPath,
9119
- bytes: new TextEncoder().encode(
9120
- `${JSON.stringify(next, null, 2).replace(/\r\n/g, "\n")}
9324
+ return withHookConfigLock(input.root, hookTarget.posixPath, async () => {
9325
+ const settingsPath = path11.join(input.root, hookTarget.relativePath);
9326
+ const { settings, existingRaw } = await readHookConfig(
9327
+ input.root,
9328
+ hookTarget
9329
+ );
9330
+ if (existingRaw === null) {
9331
+ return { settingsPath, changed: false };
9332
+ }
9333
+ const { hooks, groups } = readEventGroups(
9334
+ settingsPath,
9335
+ settings,
9336
+ input.event
9337
+ );
9338
+ const { pruned, removedCount } = pruneMatching(
9339
+ groups,
9340
+ input.projectId,
9341
+ /* @__PURE__ */ new Set([input.subcommand])
9342
+ );
9343
+ if (removedCount === 0) {
9344
+ return { settingsPath, changed: false };
9345
+ }
9346
+ const next = {
9347
+ ...settings,
9348
+ hooks: { ...hooks, [input.event]: pruned }
9349
+ };
9350
+ await writeFileSafely({
9351
+ root: input.root,
9352
+ relativePath: hookTarget.posixPath,
9353
+ bytes: new TextEncoder().encode(
9354
+ `${JSON.stringify(next, null, 2).replace(/\r\n/g, "\n")}
9121
9355
  `
9122
- )
9356
+ )
9357
+ });
9358
+ return { settingsPath, changed: true };
9123
9359
  });
9124
- return { settingsPath, changed: true };
9125
9360
  }
9126
9361
  async function findSessionStartHooks(input) {
9127
9362
  const hookTarget = hookTargetFor(input.tool);
@@ -9292,6 +9527,16 @@ var TOOL_COMMAND = {
9292
9527
  "claude-code": "claude",
9293
9528
  codex: "codex"
9294
9529
  };
9530
+ function mcpRegistrationComplete(results) {
9531
+ return results.every(
9532
+ (result) => result.outcome.kind === "registered" || result.outcome.kind === "replaced" || result.outcome.kind === "already"
9533
+ );
9534
+ }
9535
+ function mcpAuthenticationPending(results) {
9536
+ return results.some(
9537
+ (result) => result.login === "printed" || result.login === "not-finished"
9538
+ );
9539
+ }
9295
9540
  var SERVER_ID_SUFFIX_LENGTH = 6;
9296
9541
  function codexServerName(projectId) {
9297
9542
  const suffix = projectId.toLowerCase().replace(/[^a-z0-9]+/g, "").slice(-SERVER_ID_SUFFIX_LENGTH);
@@ -9795,9 +10040,6 @@ function createAgentRunner(input) {
9795
10040
  import { createHash as createHash5 } from "crypto";
9796
10041
  import { chmod } from "fs/promises";
9797
10042
 
9798
- // src/lib/instructions/plan.ts
9799
- import { createHash as createHash4 } from "crypto";
9800
-
9801
10043
  // src/lib/instructions/lock.ts
9802
10044
  import { readFile as readFile3 } from "fs/promises";
9803
10045
  import path14 from "path";
@@ -9970,8 +10212,7 @@ function sourceProblem(value) {
9970
10212
 
9971
10213
  // src/lib/instructions/plan.ts
9972
10214
  async function hashLocalFile(root, relativePath) {
9973
- const read = await readFileSafely(root, relativePath);
9974
- return read === null ? null : createHash4("sha256").update(read.bytes).digest("hex");
10215
+ return (await fingerprintFileSafely(root, relativePath))?.sha256 ?? null;
9975
10216
  }
9976
10217
  function lockPaths(lock) {
9977
10218
  return lock === null ? [] : Object.keys(lock.files);
@@ -10012,9 +10253,9 @@ async function findLedgerDrift(input) {
10012
10253
  assertSafeLockPaths(lockPaths(input.lock));
10013
10254
  const drifted = [];
10014
10255
  for (const [lockedPath, locked] of Object.entries(input.lock.files)) {
10015
- let read;
10256
+ let fingerprint;
10016
10257
  try {
10017
- read = await readFileSafely(input.root, lockedPath);
10258
+ fingerprint = await fingerprintFileSafely(input.root, lockedPath);
10018
10259
  } catch (error) {
10019
10260
  drifted.push({
10020
10261
  path: lockedPath,
@@ -10024,7 +10265,7 @@ async function findLedgerDrift(input) {
10024
10265
  });
10025
10266
  continue;
10026
10267
  }
10027
- if (read === null) {
10268
+ if (fingerprint === null) {
10028
10269
  drifted.push({
10029
10270
  path: lockedPath,
10030
10271
  reason: "missing",
@@ -10033,7 +10274,7 @@ async function findLedgerDrift(input) {
10033
10274
  });
10034
10275
  continue;
10035
10276
  }
10036
- const actual = createHash4("sha256").update(read.bytes).digest("hex");
10277
+ const actual = fingerprint.sha256;
10037
10278
  if (actual !== locked.sha256) {
10038
10279
  drifted.push({
10039
10280
  path: lockedPath,
@@ -10043,11 +10284,11 @@ async function findLedgerDrift(input) {
10043
10284
  });
10044
10285
  continue;
10045
10286
  }
10046
- if (process.platform !== "win32" && locked.mode !== null && (read.mode & 4095) !== (locked.mode & 4095)) {
10287
+ if (process.platform !== "win32" && locked.mode !== null && (fingerprint.mode & 4095) !== (locked.mode & 4095)) {
10047
10288
  drifted.push({
10048
10289
  path: lockedPath,
10049
10290
  reason: "mode",
10050
- detail: `mode ${(read.mode & 4095).toString(8)}, published as ${(locked.mode & 4095).toString(8)}`,
10291
+ detail: `mode ${(fingerprint.mode & 4095).toString(8)}, published as ${(locked.mode & 4095).toString(8)}`,
10051
10292
  kept: locked.kept === true
10052
10293
  });
10053
10294
  }
@@ -10207,7 +10448,7 @@ async function applyPlan(input) {
10207
10448
  `Refusing to sync: ${entry.path} asks for file mode ${(entry.mode ?? 0).toString(8)}, and only 644 and 755 are accepted. Nothing was written.`
10208
10449
  );
10209
10450
  }
10210
- const bytes = contents.get(entry.path);
10451
+ const bytes = await contents.get(entry.path);
10211
10452
  if (!bytes) {
10212
10453
  throw new Error(
10213
10454
  `The downloaded bundle is missing ${entry.path}. Nothing was written.`
@@ -10233,7 +10474,10 @@ async function applyPlan(input) {
10233
10474
  continue;
10234
10475
  }
10235
10476
  }
10236
- const bytes = contents.get(entry.path);
10477
+ const bytes = await contents.get(entry.path);
10478
+ if (!bytes) {
10479
+ throw new Error(`The downloaded bundle is missing ${entry.path}.`);
10480
+ }
10237
10481
  await writeFileSafely({
10238
10482
  root,
10239
10483
  relativePath: entry.path,
@@ -10414,7 +10658,7 @@ function extractBundle(archive, wanted) {
10414
10658
  }
10415
10659
 
10416
10660
  // src/lib/instructions/clone-target.ts
10417
- import { lstat as lstat5, readdir as readdir2 } from "fs/promises";
10661
+ import { lstat as lstat5 } from "fs/promises";
10418
10662
  import path15 from "path";
10419
10663
  async function resolveCloneFolder(base, folder) {
10420
10664
  const target = path15.resolve(base, folder);
@@ -10427,23 +10671,117 @@ async function resolveCloneFolder(base, folder) {
10427
10671
  if (stats === null) {
10428
10672
  return target;
10429
10673
  }
10430
- if (!stats.isDirectory() || (await readdir2(target)).length > 0) {
10674
+ if (!stats.isDirectory()) {
10431
10675
  throw outcomeFailure("clone-folder-in-use", {});
10432
10676
  }
10433
10677
  return target;
10434
10678
  }
10435
10679
 
10436
10680
  // src/lib/instructions/doctor.ts
10437
- import { createHash as createHash7 } from "crypto";
10681
+ import { createHash as createHash9 } from "crypto";
10438
10682
  import { stat as stat4 } from "fs/promises";
10439
- import path16 from "path";
10683
+ import path17 from "path";
10440
10684
 
10441
- // src/lib/instructions/manifest.ts
10685
+ // src/lib/instructions/file-downloads.ts
10442
10686
  import { createHash as createHash6 } from "crypto";
10687
+ var PER_FILE_MAX_WRITES = 100;
10688
+ var URL_REQUEST_CHUNK = 200;
10689
+ var DOWNLOAD_CONCURRENCY = 8;
10690
+ var PublishedChangedError = class extends Error {
10691
+ constructor() {
10692
+ super("The published version changed while this command ran.");
10693
+ this.name = "PublishedChangedError";
10694
+ }
10695
+ };
10696
+ async function fetchFilesByUrl(options) {
10697
+ const contents = /* @__PURE__ */ new Map();
10698
+ await downloadFilesByUrl(options, async (file, bytes) => {
10699
+ contents.set(file.path, bytes);
10700
+ });
10701
+ return contents;
10702
+ }
10703
+ async function downloadFilesByUrl(options, consume) {
10704
+ for (let i = 0; i < options.files.length; i += URL_REQUEST_CHUNK) {
10705
+ const files = options.files.slice(i, i + URL_REQUEST_CHUNK);
10706
+ const wanted = new Set(files.map((file) => file.path));
10707
+ const urls = /* @__PURE__ */ new Map();
10708
+ const paths = files.map((file) => file.path);
10709
+ let response;
10710
+ try {
10711
+ response = await options.client.instructions.createFileDownloadUrls(
10712
+ options.projectId,
10713
+ { digest: options.digest, paths },
10714
+ { org: options.org }
10715
+ );
10716
+ } catch (error) {
10717
+ if (error instanceof FabricError && error.status === 409 && error.code === "PUBLISHED_CHANGED") {
10718
+ throw new PublishedChangedError();
10719
+ }
10720
+ throw error;
10721
+ }
10722
+ if (response.digest !== options.digest) {
10723
+ throw new PublishedChangedError();
10724
+ }
10725
+ for (const file of response.files) {
10726
+ if (!wanted.has(file.path) || urls.has(file.path)) {
10727
+ throw new Error(
10728
+ "The server returned a file that was not asked for. Nothing was written."
10729
+ );
10730
+ }
10731
+ urls.set(file.path, file.url);
10732
+ }
10733
+ if (urls.size !== wanted.size) {
10734
+ throw new Error(
10735
+ `The server returned ${urls.size} of ${wanted.size} requested files. Nothing was written.`
10736
+ );
10737
+ }
10738
+ const queue = files.values();
10739
+ const failures = [];
10740
+ const worker = async () => {
10741
+ while (failures.length === 0) {
10742
+ const next = queue.next();
10743
+ if (next.done) {
10744
+ return;
10745
+ }
10746
+ const file = next.value;
10747
+ try {
10748
+ const bytes = await fetchBundle(urls.get(file.path) ?? "", {
10749
+ timeoutMs: options.timeoutMs,
10750
+ // The manifest's size, not the response's: the read is
10751
+ // abandoned the moment it passes what the plan expects.
10752
+ maxBytes: file.size,
10753
+ signal: options.signal,
10754
+ fetchImpl: options.fetchImpl
10755
+ });
10756
+ if (bytes.length !== file.size || createHash6("sha256").update(bytes).digest("hex") !== file.sha256) {
10757
+ throw new Error(
10758
+ `A downloaded file does not match the manifest (${file.path}). Nothing was written.`
10759
+ );
10760
+ }
10761
+ await consume(file, bytes);
10762
+ } catch (error) {
10763
+ failures.push(error);
10764
+ }
10765
+ }
10766
+ };
10767
+ await Promise.all(
10768
+ Array.from(
10769
+ { length: Math.min(DOWNLOAD_CONCURRENCY, files.length) },
10770
+ () => worker()
10771
+ )
10772
+ );
10773
+ if (failures.length > 0) {
10774
+ throw failures[0];
10775
+ }
10776
+ }
10777
+ }
10778
+
10779
+ // src/lib/instructions/manifest.ts
10780
+ import { createHash as createHash7 } from "crypto";
10443
10781
  var SHA256_HEX3 = /^[0-9a-f]{64}$/;
10444
10782
  var MANIFEST_LIMITS = {
10445
10783
  maxFiles: 5e3,
10446
- maxTotalBytes: 52428800,
10784
+ maxTotalBytes: 2147483647,
10447
10785
  maxFileBytes: 5242880,
10448
10786
  maxPathBytes: 1024,
10449
10787
  maxDepth: 64
@@ -10468,7 +10806,7 @@ function computeSnapshotDigest(entries) {
10468
10806
  ` : `${entry.path}\0${entry.sha256}\0${mode.toString(8)}
10469
10807
  `;
10470
10808
  }).join("");
10471
- return createHash6("sha256").update(Buffer.from(lines2, "utf8")).digest("hex");
10809
+ return createHash7("sha256").update(Buffer.from(lines2, "utf8")).digest("hex");
10472
10810
  }
10473
10811
  function computeLegacySnapshotDigest(entries) {
10474
10812
  return computeSnapshotDigest(
@@ -10685,6 +11023,53 @@ function isTooLarge(error) {
10685
11023
  return error instanceof Error && error.message.includes("too large to read safely");
10686
11024
  }
10687
11025
 
11026
+ // src/lib/instructions/staged-files.ts
11027
+ import { createHash as createHash8 } from "crypto";
11028
+ import { mkdtemp, realpath as realpath3, rm } from "fs/promises";
11029
+ import { tmpdir as tmpdir2 } from "os";
11030
+ import path16 from "path";
11031
+ var IN_MEMORY_SNAPSHOT_BYTES = 50 * 1024 * 1024;
11032
+ async function stageFilesByUrl(options) {
11033
+ const root = await realpath3(
11034
+ await mkdtemp(path16.join(tmpdir2(), "fabric-instructions-"))
11035
+ );
11036
+ const files = /* @__PURE__ */ new Map();
11037
+ const dispose = () => rm(root, { recursive: true, force: true });
11038
+ try {
11039
+ await downloadFilesByUrl(options, async (file, bytes) => {
11040
+ const key = String(files.size);
11041
+ files.set(file.path, { ...file, key });
11042
+ await writeFileSafely({
11043
+ root,
11044
+ relativePath: key,
11045
+ bytes,
11046
+ mode: null
11047
+ });
11048
+ });
11049
+ } catch (error) {
11050
+ await dispose();
11051
+ throw error;
11052
+ }
11053
+ return {
11054
+ dispose,
11055
+ async get(filePath) {
11056
+ const file = files.get(filePath);
11057
+ if (!file) {
11058
+ return void 0;
11059
+ }
11060
+ const loaded = await readFileSafely(root, file.key, {
11061
+ maxBytes: file.size
11062
+ });
11063
+ if (!loaded || loaded.bytes.length !== file.size || createHash8("sha256").update(loaded.bytes).digest("hex") !== file.sha256) {
11064
+ throw new Error(
11065
+ `A staged file does not match the manifest (${filePath}).`
11066
+ );
11067
+ }
11068
+ return loaded.bytes;
11069
+ }
11070
+ };
11071
+ }
11072
+
10688
11073
  // src/lib/instructions/doctor.ts
10689
11074
  var MAX_ITEMS = 50;
10690
11075
  var MAX_NAMES_IN_FIX = 20;
@@ -10812,7 +11197,7 @@ function buildFabricCommand(args, platform = process.platform) {
10812
11197
  return launcher === null ? void 0 : [...launcher, ...words].join(" ");
10813
11198
  }
10814
11199
  function sha256Hex(bytes) {
10815
- return createHash7("sha256").update(bytes).digest("hex");
11200
+ return createHash9("sha256").update(bytes).digest("hex");
10816
11201
  }
10817
11202
  function listNames(names) {
10818
11203
  const shown2 = names.slice(0, MAX_NAMES_IN_FIX);
@@ -12002,26 +12387,55 @@ async function downloadDeclaration(input, commands, client, initial, initialEntr
12002
12387
  let manifest = initial.manifest;
12003
12388
  let entry = initialEntry;
12004
12389
  for (let attempt2 = 0; attempt2 < 2; attempt2++) {
12005
- let download2;
12006
12390
  try {
12007
- download2 = await input.createDownloadUrl(input.projectId, {
12391
+ if (manifest.reduce((total, file) => total + file.size, 0) > IN_MEMORY_SNAPSHOT_BYTES) {
12392
+ const instructions = client().instructions;
12393
+ const sign = instructions.createFileDownloadUrls?.bind(instructions);
12394
+ if (!sign) {
12395
+ return {
12396
+ kind: "unreadable",
12397
+ status: "fail",
12398
+ detail: "this CLI cannot read a declaration from a large snapshot",
12399
+ fix: fixOf(
12400
+ "run the latest CLI from the project's Connect dialog"
12401
+ )
12402
+ };
12403
+ }
12404
+ const files = await fetchFilesByUrl({
12405
+ client: { instructions: { createFileDownloadUrls: sign } },
12406
+ projectId: input.projectId,
12407
+ org: input.org,
12408
+ digest: snapshot.digest,
12409
+ files: [entry],
12410
+ timeoutMs: 3e4,
12411
+ fetchImpl: input.fetchImpl
12412
+ });
12413
+ const bytes = files.get(entry.path);
12414
+ if (!bytes) {
12415
+ throw new Error("Published declaration missing");
12416
+ }
12417
+ return parseDeclarationBytes(bytes, "published");
12418
+ }
12419
+ const download2 = await input.createDownloadUrl(input.projectId, {
12008
12420
  org: input.org
12009
12421
  });
12422
+ if (download2.snapshotId === snapshot.id && download2.digest === snapshot.digest) {
12423
+ return fetchVerifiedDeclaration(
12424
+ input,
12425
+ download2.url,
12426
+ manifest,
12427
+ entry
12428
+ );
12429
+ }
12010
12430
  } catch (error) {
12011
- return {
12012
- kind: "unreadable",
12013
- status: "fail",
12014
- detail: `could not download the published declaration (${requestFailureClass(error)})`,
12015
- fix: fixOf(NETWORK_FIX)
12016
- };
12017
- }
12018
- if (download2.snapshotId === snapshot.id && download2.digest === snapshot.digest) {
12019
- return fetchVerifiedDeclaration(
12020
- input,
12021
- download2.url,
12022
- manifest,
12023
- entry
12024
- );
12431
+ if (!(error instanceof PublishedChangedError)) {
12432
+ return {
12433
+ kind: "unreadable",
12434
+ status: "fail",
12435
+ detail: `could not download the published declaration (${requestFailureClass(error)})`,
12436
+ fix: fixOf(NETWORK_FIX)
12437
+ };
12438
+ }
12025
12439
  }
12026
12440
  if (attempt2 > 0) {
12027
12441
  break;
@@ -12449,13 +12863,13 @@ async function commandResolves(input, lookup, command) {
12449
12863
  if (TOOL_NAME.test(command)) {
12450
12864
  return await isOnPath(command, lookup) ? "found" : "missing";
12451
12865
  }
12452
- const api = input.platform === "win32" ? path16.win32 : path16.posix;
12866
+ const api = input.platform === "win32" ? path17.win32 : path17.posix;
12453
12867
  if (api.isAbsolute(command)) {
12454
12868
  return await isExecutableFile(command, input.platform) ? "found" : "missing";
12455
12869
  }
12456
12870
  if (command.includes("/") || command.includes("\\")) {
12457
12871
  return await isExecutableFile(
12458
- path16.resolve(input.root, command),
12872
+ path17.resolve(input.root, command),
12459
12873
  input.platform
12460
12874
  ) ? "found" : "missing";
12461
12875
  }
@@ -12630,9 +13044,9 @@ function formatDoctorText(report, destination) {
12630
13044
  }
12631
13045
 
12632
13046
  // src/lib/instructions/drop-lock.ts
12633
- import { createHash as createHash8 } from "crypto";
13047
+ import { createHash as createHash10 } from "crypto";
12634
13048
  import { rmdir } from "fs/promises";
12635
- import path17 from "path";
13049
+ import path18 from "path";
12636
13050
  var LOCK_RELATIVE = `${LOCK_DIRECTORY}/${LOCK_FILENAME}`;
12637
13051
  var MAX_LOCK_BYTES2 = 16 * 1024 * 1024;
12638
13052
  async function dropOwnLock(root, projectId) {
@@ -12647,12 +13061,12 @@ async function dropOwnLock(root, projectId) {
12647
13061
  if (lock.projectId !== projectId) {
12648
13062
  return false;
12649
13063
  }
12650
- const sha256 = createHash8("sha256").update(read.bytes).digest("hex");
13064
+ const sha256 = createHash10("sha256").update(read.bytes).digest("hex");
12651
13065
  const outcome = await deleteFileSafely(root, LOCK_RELATIVE, sha256);
12652
13066
  if (outcome !== "deleted") {
12653
13067
  return false;
12654
13068
  }
12655
- await rmdir(path17.join(root, LOCK_DIRECTORY)).catch(() => void 0);
13069
+ await rmdir(path18.join(root, LOCK_DIRECTORY)).catch(() => void 0);
12656
13070
  return true;
12657
13071
  } catch {
12658
13072
  return false;
@@ -12744,8 +13158,8 @@ function replacePath(text3, target, replacement) {
12744
13158
  }
12745
13159
 
12746
13160
  // src/lib/instructions/fast-forward-run.ts
12747
- import { mkdir as mkdir4, realpath as realpath3 } from "fs/promises";
12748
- import path20 from "path";
13161
+ import { mkdir as mkdir5, realpath as realpath4 } from "fs/promises";
13162
+ import path21 from "path";
12749
13163
 
12750
13164
  // src/lib/instructions/fast-forward.ts
12751
13165
  function fastForwardEligibility(facts) {
@@ -12916,6 +13330,14 @@ function fastForwardLines(outcome, context) {
12916
13330
  stdout.push(outcomeLine("ff-locked", {}));
12917
13331
  otherLine();
12918
13332
  break;
13333
+ case "abandoned-lock":
13334
+ stdout.push(
13335
+ outcomeLine("ff-abandoned-lock", {
13336
+ lockPath: outcome.lockPath
13337
+ })
13338
+ );
13339
+ otherLine();
13340
+ break;
12919
13341
  case "deadline":
12920
13342
  stderr.push(
12921
13343
  skipped(outcomeLine("gave-up", { after: context.gaveUpAfter }))
@@ -12929,10 +13351,10 @@ function fastForwardLines(outcome, context) {
12929
13351
  }
12930
13352
 
12931
13353
  // src/lib/instructions/fast-forward-memory.ts
12932
- import { mkdir as mkdir2, readFile as readFile4, rename as rename2, rm, writeFile } from "fs/promises";
12933
- import path18 from "path";
13354
+ import { mkdir as mkdir3, readFile as readFile4, rename as rename2, rm as rm2, writeFile } from "fs/promises";
13355
+ import path19 from "path";
12934
13356
  function noticeFile(commonDir2) {
12935
- return path18.join(commonDir2, "fabric", "ff-notice.json");
13357
+ return path19.join(commonDir2, "fabric", "ff-notice.json");
12936
13358
  }
12937
13359
  function isRecord4(value) {
12938
13360
  if (typeof value !== "object" || value === null) {
@@ -12951,7 +13373,7 @@ async function isNewNotice(file, key) {
12951
13373
  }
12952
13374
  async function rememberNotice(file, key) {
12953
13375
  try {
12954
- await mkdir2(path18.dirname(file), { recursive: true });
13376
+ await mkdir3(path19.dirname(file), { recursive: true });
12955
13377
  const temporary = `${file}.${process.pid}.tmp`;
12956
13378
  const record = { v: 1, ...key };
12957
13379
  await writeFile(temporary, `${JSON.stringify(record)}
@@ -12963,7 +13385,7 @@ async function rememberNotice(file, key) {
12963
13385
  }
12964
13386
  }
12965
13387
  async function forgetNotice(file) {
12966
- await rm(file, { force: true }).catch(() => void 0);
13388
+ await rm2(file, { force: true }).catch(() => void 0);
12967
13389
  }
12968
13390
 
12969
13391
  // src/lib/instructions/hook-timing.ts
@@ -12975,11 +13397,11 @@ var hookTiming = {
12975
13397
  };
12976
13398
 
12977
13399
  // src/lib/instructions/hook-trace.ts
12978
- import { mkdir as mkdir3, readFile as readFile5, rename as rename3, writeFile as writeFile2 } from "fs/promises";
12979
- import path19 from "path";
13400
+ import { mkdir as mkdir4, readFile as readFile5, rename as rename3, writeFile as writeFile2 } from "fs/promises";
13401
+ import path20 from "path";
12980
13402
  var TRACE_LIMIT = 50;
12981
13403
  function traceFile(configDir) {
12982
- return path19.join(configDir, "traces", "instructions-hook.jsonl");
13404
+ return path20.join(configDir, "traces", "instructions-hook.jsonl");
12983
13405
  }
12984
13406
  async function appendTrace(file, entry) {
12985
13407
  try {
@@ -12992,7 +13414,7 @@ async function appendTrace(file, entry) {
12992
13414
  }
12993
13415
  }).slice(-(TRACE_LIMIT - 1));
12994
13416
  kept.push(JSON.stringify(entry));
12995
- await mkdir3(path19.dirname(file), { recursive: true, mode: 448 });
13417
+ await mkdir4(path20.dirname(file), { recursive: true, mode: 448 });
12996
13418
  const temporary = `${file}.${process.pid}.tmp`;
12997
13419
  await writeFile2(temporary, `${kept.join("\n")}
12998
13420
  `, { mode: 384 });
@@ -13006,7 +13428,7 @@ var MERGE_RESERVE_MS = 1500;
13006
13428
  var FF_LOCK_STALE_MS = 3e4;
13007
13429
  var show2 = (value, max = 200) => sanitizeDisplayText(value, max);
13008
13430
  async function samePath(a, b) {
13009
- const resolve = async (value) => realpath3(value).catch(() => path20.resolve(value));
13431
+ const resolve = async (value) => realpath4(value).catch(() => path21.resolve(value));
13010
13432
  const [left, right] = await Promise.all([resolve(a), resolve(b)]);
13011
13433
  return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
13012
13434
  }
@@ -13110,6 +13532,10 @@ async function underLock(run3, first) {
13110
13532
  return fetched;
13111
13533
  }
13112
13534
  const tip = fetched.tip;
13535
+ const afterFetch = await factsStillMatch(run3, facts, deadline);
13536
+ if (afterFetch !== null) {
13537
+ return afterFetch;
13538
+ }
13113
13539
  if (tip === head) {
13114
13540
  return { outcome: { kind: "already-current" }, head };
13115
13541
  }
@@ -13124,6 +13550,13 @@ async function underLock(run3, first) {
13124
13550
  if (deadline - Date.now() < MERGE_RESERVE_MS) {
13125
13551
  return { outcome: { kind: "deadline" }, head };
13126
13552
  }
13553
+ const beforeMerge = await factsStillMatch(run3, facts, deadline);
13554
+ if (beforeMerge !== null) {
13555
+ return beforeMerge;
13556
+ }
13557
+ if (deadline - Date.now() < MERGE_RESERVE_MS) {
13558
+ return { outcome: { kind: "deadline" }, head };
13559
+ }
13127
13560
  const merged = await fastForwardTo(root, tip, deadline);
13128
13561
  switch (merged.kind) {
13129
13562
  case "merged":
@@ -13157,6 +13590,26 @@ async function underLock(run3, first) {
13157
13590
  return merged;
13158
13591
  }
13159
13592
  }
13593
+ async function factsStillMatch(run3, expected, deadline) {
13594
+ const facts = await readFacts(run3, deadline);
13595
+ if (facts === null) {
13596
+ return unanswered(deadline, expected.state.head);
13597
+ }
13598
+ const gate = fastForwardEligibility(facts);
13599
+ if (!gate.eligible) {
13600
+ return {
13601
+ outcome: { kind: "not-safe", reason: gate.reason },
13602
+ head: facts.state.head
13603
+ };
13604
+ }
13605
+ if (facts.state.branch !== expected.state.branch || facts.state.head !== expected.state.head) {
13606
+ return {
13607
+ outcome: { kind: "not-safe", reason: "git-busy" },
13608
+ head: facts.state.head
13609
+ };
13610
+ }
13611
+ return null;
13612
+ }
13160
13613
  async function decide(run3) {
13161
13614
  const { classification, deadline, report } = run3;
13162
13615
  const root = classification.toplevel;
@@ -13179,11 +13632,11 @@ async function decide(run3) {
13179
13632
  if (common.kind !== "ok") {
13180
13633
  return unanswered(deadline, head);
13181
13634
  }
13182
- const directory = path20.join(common.value, "fabric");
13635
+ const directory = path21.join(common.value, "fabric");
13183
13636
  try {
13184
- await mkdir4(directory, { recursive: true });
13637
+ await mkdir5(directory, { recursive: true });
13185
13638
  return await withExclusiveLock(() => underLock(run3, facts), {
13186
- lockPath: path20.join(directory, "ff.lock"),
13639
+ lockPath: path21.join(directory, "ff.lock"),
13187
13640
  staleMs: FF_LOCK_STALE_MS,
13188
13641
  waitMs: 0
13189
13642
  });
@@ -13191,7 +13644,10 @@ async function decide(run3) {
13191
13644
  if (error instanceof ExclusiveLockBusyError) {
13192
13645
  const now = await headSha(root, deadline);
13193
13646
  return {
13194
- outcome: { kind: "locked" },
13647
+ outcome: error.abandoned ? {
13648
+ kind: "abandoned-lock",
13649
+ lockPath: path21.join(directory, "ff.lock")
13650
+ } : { kind: "locked" },
13195
13651
  head: now.kind === "ok" ? now.value : head
13196
13652
  };
13197
13653
  }
@@ -13309,99 +13765,11 @@ async function runFastForward(run3) {
13309
13765
  return { outcome, stdout, stderr };
13310
13766
  }
13311
13767
 
13312
- // src/lib/instructions/file-downloads.ts
13313
- import { createHash as createHash9 } from "crypto";
13314
- var PER_FILE_MAX_WRITES = 100;
13315
- var URL_REQUEST_CHUNK = 200;
13316
- var DOWNLOAD_CONCURRENCY = 8;
13317
- var PublishedChangedError = class extends Error {
13318
- constructor() {
13319
- super("The published version changed while this command ran.");
13320
- this.name = "PublishedChangedError";
13321
- }
13322
- };
13323
- async function fetchFilesByUrl(options) {
13324
- const wanted = new Map(options.files.map((file) => [file.path, file]));
13325
- const urls = /* @__PURE__ */ new Map();
13326
- for (let i = 0; i < options.files.length; i += URL_REQUEST_CHUNK) {
13327
- const paths = options.files.slice(i, i + URL_REQUEST_CHUNK).map((file) => file.path);
13328
- let response;
13329
- try {
13330
- response = await options.client.instructions.createFileDownloadUrls(
13331
- options.projectId,
13332
- { digest: options.digest, paths },
13333
- { org: options.org }
13334
- );
13335
- } catch (error) {
13336
- if (error instanceof FabricError && error.status === 409 && error.code === "PUBLISHED_CHANGED") {
13337
- throw new PublishedChangedError();
13338
- }
13339
- throw error;
13340
- }
13341
- if (response.digest !== options.digest) {
13342
- throw new PublishedChangedError();
13343
- }
13344
- for (const file of response.files) {
13345
- if (!wanted.has(file.path) || urls.has(file.path)) {
13346
- throw new Error(
13347
- "The server returned a file that was not asked for. Nothing was written."
13348
- );
13349
- }
13350
- urls.set(file.path, file.url);
13351
- }
13352
- }
13353
- if (urls.size !== wanted.size) {
13354
- throw new Error(
13355
- `The server returned ${urls.size} of ${wanted.size} requested files. Nothing was written.`
13356
- );
13357
- }
13358
- const contents = /* @__PURE__ */ new Map();
13359
- const queue = options.files.values();
13360
- const failures = [];
13361
- const worker = async () => {
13362
- while (failures.length === 0) {
13363
- const next = queue.next();
13364
- if (next.done) {
13365
- return;
13366
- }
13367
- const file = next.value;
13368
- try {
13369
- const bytes = await fetchBundle(urls.get(file.path) ?? "", {
13370
- timeoutMs: options.timeoutMs,
13371
- // The manifest's size, not the response's: the read is
13372
- // abandoned the moment it passes what the plan expects.
13373
- maxBytes: file.size,
13374
- signal: options.signal,
13375
- fetchImpl: options.fetchImpl
13376
- });
13377
- if (bytes.length !== file.size || createHash9("sha256").update(bytes).digest("hex") !== file.sha256) {
13378
- throw new Error(
13379
- `A downloaded file does not match the manifest (${file.path}). Nothing was written.`
13380
- );
13381
- }
13382
- contents.set(file.path, bytes);
13383
- } catch (error) {
13384
- failures.push(error);
13385
- }
13386
- }
13387
- };
13388
- await Promise.all(
13389
- Array.from(
13390
- { length: Math.min(DOWNLOAD_CONCURRENCY, options.files.length) },
13391
- () => worker()
13392
- )
13393
- );
13394
- if (failures.length > 0) {
13395
- throw failures[0];
13396
- }
13397
- return contents;
13398
- }
13399
-
13400
13768
  // src/lib/instructions/git-exclude.ts
13401
- import { appendFile, lstat as lstat6, mkdir as mkdir5, readFile as readFile6 } from "fs/promises";
13402
- import path21 from "path";
13769
+ import { appendFile, lstat as lstat6, mkdir as mkdir6, readFile as readFile6 } from "fs/promises";
13770
+ import path22 from "path";
13403
13771
  function relativeToTopLevel(toplevel, directory, file) {
13404
- return path21.relative(toplevel, path21.join(directory, file)).split(path21.sep).join("/");
13772
+ return path22.relative(toplevel, path22.join(directory, file)).split(path22.sep).join("/");
13405
13773
  }
13406
13774
  function isLiteralEntry(entry) {
13407
13775
  return !/[\n\r*?[\]\\!]/.test(entry) && !entry.startsWith("#");
@@ -13435,7 +13803,7 @@ async function excludeLocalFiles(input) {
13435
13803
  if (file.kind !== "ok") {
13436
13804
  return { kind: "failed", entries: missing };
13437
13805
  }
13438
- await mkdir5(path21.dirname(file.value), { recursive: true });
13806
+ await mkdir6(path22.dirname(file.value), { recursive: true });
13439
13807
  const existing = await lstat6(file.value).catch((error) => {
13440
13808
  if (error.code === "ENOENT") {
13441
13809
  return null;
@@ -13461,8 +13829,8 @@ async function excludeLocalFiles(input) {
13461
13829
  }
13462
13830
 
13463
13831
  // src/lib/instructions/hook-launcher.ts
13464
- import { mkdir as mkdir6, readFile as readFile7, rename as rename4, rm as rm2, writeFile as writeFile3 } from "fs/promises";
13465
- import path22 from "path";
13832
+ import { mkdir as mkdir7, readFile as readFile7, rename as rename4, rm as rm3, writeFile as writeFile3 } from "fs/promises";
13833
+ import path23 from "path";
13466
13834
  var NOT_ON_PATH_WARNING = "`fabric` is not on this machine's PATH, so this session hook will not run. Run the setup line from the project's Connect dialog instead: it keeps a copy of this deployment's CLI where the hook finds it.";
13467
13835
  var COPY_IN_USE_WARNING = "The CLI copy the session hook runs is in use and could not be replaced, so the earlier copy was kept. Run this again when no coding session is starting.";
13468
13836
  var UNQUOTABLE_PATH = "The CLI's config folder has characters in its path that cannot be written safely into a hook command. Set XDG_CONFIG_HOME (APPDATA on Windows) to a plain path and run this again.";
@@ -13470,7 +13838,7 @@ function deploymentFolder(origin) {
13470
13838
  return origin.replace(/^(https?):\/\//, "$1-").replace(/[^A-Za-z0-9.-]+/g, "-");
13471
13839
  }
13472
13840
  function bundleCopyPath(configDirectory, origin) {
13473
- return path22.join(
13841
+ return path23.join(
13474
13842
  configDirectory,
13475
13843
  "cli",
13476
13844
  deploymentFolder(origin),
@@ -13478,18 +13846,18 @@ function bundleCopyPath(configDirectory, origin) {
13478
13846
  );
13479
13847
  }
13480
13848
  function samePath2(a, b) {
13481
- const [left, right] = [path22.resolve(a), path22.resolve(b)];
13849
+ const [left, right] = [path23.resolve(a), path23.resolve(b)];
13482
13850
  return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
13483
13851
  }
13484
13852
  async function writeCopy(bytes, destination) {
13485
- await mkdir6(path22.dirname(destination), { recursive: true });
13853
+ await mkdir7(path23.dirname(destination), { recursive: true });
13486
13854
  const temporary = `${destination}.${process.pid}.tmp`;
13487
13855
  await writeFile3(temporary, bytes, { mode: 420 });
13488
13856
  try {
13489
13857
  await rename4(temporary, destination);
13490
13858
  return true;
13491
13859
  } catch (error) {
13492
- await rm2(temporary, { force: true });
13860
+ await rm3(temporary, { force: true });
13493
13861
  const existing = await readFile7(destination).catch(() => null);
13494
13862
  if (existing === null) {
13495
13863
  throw error;
@@ -13564,10 +13932,10 @@ async function installHooks(input) {
13564
13932
  }
13565
13933
 
13566
13934
  // src/lib/instructions/lesson-prompt.ts
13567
- import { createHash as createHash10 } from "crypto";
13935
+ import { createHash as createHash11 } from "crypto";
13568
13936
  import { createReadStream } from "fs";
13569
- import { mkdir as mkdir7, readdir as readdir3, stat as stat5, unlink as unlink2, writeFile as writeFile4 } from "fs/promises";
13570
- import path23 from "path";
13937
+ import { mkdir as mkdir8, readdir as readdir2, stat as stat5, unlink as unlink2, writeFile as writeFile4 } from "fs/promises";
13938
+ import path24 from "path";
13571
13939
  import { createInterface } from "readline";
13572
13940
  function parseStopHookInput(text3) {
13573
13941
  let parsed;
@@ -13649,7 +14017,7 @@ function countEditingToolUsesInLine(line2) {
13649
14017
  return count;
13650
14018
  }
13651
14019
  function markerKey(sessionId, projectId) {
13652
- return createHash10("sha256").update(`${sessionId}:${projectId}`).digest("hex");
14020
+ return createHash11("sha256").update(`${sessionId}:${projectId}`).digest("hex");
13653
14021
  }
13654
14022
  function lessonPromptReason(projectId) {
13655
14023
  return `Fabric lesson check for project ${projectId}: before you finish, ask the developer once, in one short question: was there a mistake in this session the team should not repeat? If they name one, draft it as a lesson (a title, what happened, why it was a mistake, what to do instead, and the instruction files it relates to) and show them the draft. Only after they confirm, call the MCP tool fabric_add_instruction_lesson with projectId "${projectId}", title, body and relatedPaths; it opens a proposal a person approves, so report it as a suggestion awaiting review. If they say no, or the tool is not available, say so in one line and stop. Never call the tool without an explicit yes.`;
@@ -13658,14 +14026,14 @@ var MARKER_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
13658
14026
  async function pruneOldMarkers(markerDir, now) {
13659
14027
  let entries;
13660
14028
  try {
13661
- entries = await readdir3(markerDir);
14029
+ entries = await readdir2(markerDir);
13662
14030
  } catch {
13663
14031
  return;
13664
14032
  }
13665
14033
  const cutoff = now.getTime() - MARKER_MAX_AGE_MS;
13666
14034
  await Promise.all(
13667
14035
  entries.map(async (name) => {
13668
- const entryPath = path23.join(markerDir, name);
14036
+ const entryPath = path24.join(markerDir, name);
13669
14037
  try {
13670
14038
  const stats = await stat5(entryPath);
13671
14039
  if (stats.mtimeMs < cutoff) {
@@ -13716,7 +14084,7 @@ async function runLessonPromptInner(input) {
13716
14084
  return { output: null, why: "stop-hook-active" };
13717
14085
  }
13718
14086
  try {
13719
- await mkdir7(input.markerDir, { recursive: true });
14087
+ await mkdir8(input.markerDir, { recursive: true });
13720
14088
  } catch (error) {
13721
14089
  debugLog(`marker directory unavailable: ${describeError(error)}`);
13722
14090
  return { output: null, why: "marker-unavailable" };
@@ -13725,7 +14093,7 @@ async function runLessonPromptInner(input) {
13725
14093
  debugLog(`marker prune failed: ${describeError(error)}`);
13726
14094
  });
13727
14095
  const key = markerKey(parsedInput.sessionId, projectId);
13728
- const markerPath = path23.join(input.markerDir, key);
14096
+ const markerPath = path24.join(input.markerDir, key);
13729
14097
  if (await markerExists(markerPath)) {
13730
14098
  return { output: null, why: "already-asked" };
13731
14099
  }
@@ -13994,8 +14362,9 @@ function splitMessage(message) {
13994
14362
  }
13995
14363
 
13996
14364
  // src/lib/instructions/push.ts
13997
- import { createHash as createHash11 } from "crypto";
14365
+ import { createHash as createHash12 } from "crypto";
13998
14366
  var MAX_PUSH_CHANGES = 50;
14367
+ var MAX_INLINE_PUSH_BYTES = 2 * 1024 * 1024;
13999
14368
  function assertPushablePath(candidate, source) {
14000
14369
  const check = checkRelativePath(candidate);
14001
14370
  if (!check.ok) {
@@ -14025,7 +14394,7 @@ function encodeContent(bytes) {
14025
14394
  };
14026
14395
  }
14027
14396
  function sha256Of(bytes) {
14028
- return createHash11("sha256").update(bytes).digest("hex");
14397
+ return createHash12("sha256").update(bytes).digest("hex");
14029
14398
  }
14030
14399
  function refuseStaleLedger(detail) {
14031
14400
  throw new Error(
@@ -14036,16 +14405,16 @@ function assertLockMatchesManifest(lock, manifest) {
14036
14405
  const published = new Map(
14037
14406
  manifest.map((entry) => [entry.path, entry.sha256])
14038
14407
  );
14039
- for (const [path28, entry] of Object.entries(lock.files)) {
14040
- const expected = published.get(path28);
14408
+ for (const [path29, entry] of Object.entries(lock.files)) {
14409
+ const expected = published.get(path29);
14041
14410
  if (expected === void 0) {
14042
14411
  refuseStaleLedger(
14043
- `it lists ${path28}, which version ${lock.snapshotVersion} does not contain`
14412
+ `it lists ${path29}, which version ${lock.snapshotVersion} does not contain`
14044
14413
  );
14045
14414
  }
14046
14415
  if (expected !== entry.sha256) {
14047
14416
  refuseStaleLedger(
14048
- `its record of ${path28} is not the published file`
14417
+ `its record of ${path29} is not the published file`
14049
14418
  );
14050
14419
  }
14051
14420
  }
@@ -14100,46 +14469,80 @@ async function computePushPlan(input) {
14100
14469
  );
14101
14470
  }
14102
14471
  const entries = [];
14103
- const changes = [];
14104
14472
  const unchanged = [];
14105
14473
  for (const lockedPath of lockedPaths.sort()) {
14106
- const read = await readFileSafely(root, lockedPath);
14107
- if (read === null) {
14474
+ const fingerprint = await fingerprintFileSafely(root, lockedPath);
14475
+ if (fingerprint === null) {
14108
14476
  entries.push({ path: lockedPath, action: "delete" });
14109
- changes.push({ op: "delete", path: lockedPath });
14110
14477
  continue;
14111
14478
  }
14112
- const actual = sha256Of(read.bytes);
14113
- if (actual === lock.files[lockedPath]?.sha256) {
14479
+ if (fingerprint.sha256 === lock.files[lockedPath]?.sha256) {
14114
14480
  unchanged.push(lockedPath);
14115
14481
  continue;
14116
14482
  }
14117
- const encoded = encodeContent(read.bytes);
14118
14483
  entries.push({
14119
14484
  path: lockedPath,
14120
14485
  action: "put",
14121
- size: read.bytes.length,
14122
- sha256: actual
14486
+ size: fingerprint.size,
14487
+ sha256: fingerprint.sha256
14123
14488
  });
14124
- changes.push({ op: "put", path: lockedPath, ...encoded });
14125
14489
  }
14126
14490
  for (const newPath of added.sort()) {
14127
- const read = await readFileSafely(root, newPath);
14128
- if (read === null) {
14491
+ const fingerprint = await fingerprintFileSafely(root, newPath);
14492
+ if (fingerprint === null) {
14129
14493
  throw new Error(
14130
14494
  `Refusing to push: there is no file at ${newPath}.`
14131
14495
  );
14132
14496
  }
14133
- const encoded = encodeContent(read.bytes);
14134
14497
  entries.push({
14135
14498
  path: newPath,
14136
14499
  action: "put",
14137
- size: read.bytes.length,
14138
- sha256: sha256Of(read.bytes)
14500
+ size: fingerprint.size,
14501
+ sha256: fingerprint.sha256
14502
+ });
14503
+ }
14504
+ return { entries, unchanged };
14505
+ }
14506
+ function pushContentBytes(entries) {
14507
+ return entries.reduce(
14508
+ (total, entry) => total + (entry.action === "put" ? entry.size : 0),
14509
+ 0
14510
+ );
14511
+ }
14512
+ async function materializePushChanges(input) {
14513
+ if (input.entries.length > MAX_PUSH_CHANGES) {
14514
+ throw new Error(`Too many changes to push (${input.entries.length}).`);
14515
+ }
14516
+ if (pushContentBytes(input.entries) > MAX_INLINE_PUSH_BYTES) {
14517
+ throw new Error("The selected changes are too large to send inline.");
14518
+ }
14519
+ const changes = [];
14520
+ for (const entry of input.entries) {
14521
+ if (entry.action === "delete") {
14522
+ changes.push({ op: "delete", path: entry.path });
14523
+ continue;
14524
+ }
14525
+ const read = await readFileSafely(input.root, entry.path, {
14526
+ maxBytes: MAX_INLINE_PUSH_BYTES
14527
+ });
14528
+ if (read === null) {
14529
+ throw new Error(
14530
+ `Refusing to push: ${entry.path} disappeared after planning. Run \`${fabricCommand("instructions push")}\` again.`
14531
+ );
14532
+ }
14533
+ const sha256 = sha256Of(read.bytes);
14534
+ if (read.bytes.length !== entry.size || sha256 !== entry.sha256) {
14535
+ throw new Error(
14536
+ `Refusing to push: ${entry.path} changed after planning. Run \`${fabricCommand("instructions push")}\` again.`
14537
+ );
14538
+ }
14539
+ changes.push({
14540
+ op: "put",
14541
+ path: entry.path,
14542
+ ...encodeContent(read.bytes)
14139
14543
  });
14140
- changes.push({ op: "put", path: newPath, ...encoded });
14141
14544
  }
14142
- return { entries, changes, unchanged };
14545
+ return changes;
14143
14546
  }
14144
14547
  var LIVE_PULL_REQUEST_STATES = /* @__PURE__ */ new Set([
14145
14548
  "QUEUED",
@@ -14183,9 +14586,6 @@ function setAsideProposed(plan, proposals, baseSnapshotId) {
14183
14586
  return {
14184
14587
  plan: {
14185
14588
  entries: plan.entries.filter((entry) => !setAside.has(entry.path)),
14186
- changes: plan.changes.filter(
14187
- (change) => !setAside.has(change.path)
14188
- ),
14189
14589
  unchanged: plan.unchanged
14190
14590
  },
14191
14591
  alreadyProposed
@@ -14193,7 +14593,7 @@ function setAsideProposed(plan, proposals, baseSnapshotId) {
14193
14593
  }
14194
14594
 
14195
14595
  // src/lib/instructions/resolve-project.ts
14196
- import path24 from "path";
14596
+ import path25 from "path";
14197
14597
  var MAX_CANDIDATES = 8;
14198
14598
  var MAX_CANDIDATE_CHARS = 512;
14199
14599
  var MAX_LABEL_CHARS = 80;
@@ -14262,7 +14662,7 @@ async function resolveProjectFromCheckout(input) {
14262
14662
  for (const match of matches2) {
14263
14663
  if (await sameDirectory(
14264
14664
  destination,
14265
- path24.join(toplevel, match.rootPath)
14665
+ path25.join(toplevel, match.rootPath)
14266
14666
  )) {
14267
14667
  here.push(match);
14268
14668
  }
@@ -14285,9 +14685,9 @@ async function resolveProjectFromCheckout(input) {
14285
14685
  }
14286
14686
 
14287
14687
  // src/lib/instructions/self-update.ts
14288
- import { createHash as createHash12 } from "crypto";
14289
- import { mkdir as mkdir8, readFile as readFile8, realpath as realpath4, writeFile as writeFile5 } from "fs/promises";
14290
- import path25 from "path";
14688
+ import { createHash as createHash13 } from "crypto";
14689
+ import { mkdir as mkdir9, readFile as readFile8, realpath as realpath5, writeFile as writeFile5 } from "fs/promises";
14690
+ import path26 from "path";
14291
14691
 
14292
14692
  // src/lib/instructions/npm-tarball.ts
14293
14693
  import { gunzipSync } from "zlib";
@@ -14421,11 +14821,11 @@ function reasonToStayQuiet(input, now) {
14421
14821
  return null;
14422
14822
  }
14423
14823
  async function isKeptCopy(script, copy) {
14424
- const actual = await realpath4(copy).catch(() => null);
14824
+ const actual = await realpath5(copy).catch(() => null);
14425
14825
  if (actual === null) {
14426
14826
  return false;
14427
14827
  }
14428
- const running = await realpath4(script).catch(() => path25.resolve(script));
14828
+ const running = await realpath5(script).catch(() => path26.resolve(script));
14429
14829
  return samePath2(running, actual);
14430
14830
  }
14431
14831
  async function readState2(file) {
@@ -14438,7 +14838,7 @@ async function readState2(file) {
14438
14838
  }
14439
14839
  async function writeState(file, state) {
14440
14840
  try {
14441
- await mkdir8(path25.dirname(file), { recursive: true });
14841
+ await mkdir9(path26.dirname(file), { recursive: true });
14442
14842
  await writeFile5(file, `${JSON.stringify(state)}
14443
14843
  `);
14444
14844
  return true;
@@ -14557,7 +14957,7 @@ async function attempt({
14557
14957
  signal,
14558
14958
  fetchImpl
14559
14959
  );
14560
- const digest = `sha512-${createHash12("sha512").update(tarball).digest("base64")}`;
14960
+ const digest = `sha512-${createHash13("sha512").update(tarball).digest("base64")}`;
14561
14961
  if (digest !== served.integrity) {
14562
14962
  throw new SelfUpdateFailure(
14563
14963
  "the download does not match the integrity hash the deployment published"
@@ -14588,7 +14988,7 @@ async function refreshKeptCopy(input, deps = {}) {
14588
14988
  if (!await isKeptCopy(input.script, copy)) {
14589
14989
  return skipped2("not-a-kept-copy");
14590
14990
  }
14591
- const stateFile = path25.join(path25.dirname(copy), STATE_FILE);
14991
+ const stateFile = path26.join(path26.dirname(copy), STATE_FILE);
14592
14992
  const previous = await readState2(stateFile);
14593
14993
  if (previous !== null && askedRecently(previous, now())) {
14594
14994
  return skipped2("checked-recently");
@@ -14646,7 +15046,7 @@ function selfUpdateLine(outcome) {
14646
15046
 
14647
15047
  // src/lib/instructions/tools.ts
14648
15048
  import { stat as stat6 } from "fs/promises";
14649
- import path26 from "path";
15049
+ import path27 from "path";
14650
15050
  var TOOLS = [
14651
15051
  { tool: "claude-code", directory: ".claude", command: "claude" },
14652
15052
  { tool: "codex", directory: ".codex", command: "codex" }
@@ -14657,7 +15057,7 @@ async function isDirectory(target) {
14657
15057
  async function detectTools(input) {
14658
15058
  const present = [];
14659
15059
  for (const facts of TOOLS) {
14660
- const found = await isDirectory(path26.join(input.root, facts.directory)) || input.home !== null && await isDirectory(path26.join(input.home, facts.directory)) || await isOnPath(facts.command, input.lookup);
15060
+ const found = await isDirectory(path27.join(input.root, facts.directory)) || input.home !== null && await isDirectory(path27.join(input.home, facts.directory)) || await isOnPath(facts.command, input.lookup);
14661
15061
  if (found) {
14662
15062
  present.push(facts.tool);
14663
15063
  }
@@ -14875,7 +15275,7 @@ async function refreshKeptCopyAfterHook(opts, deadlineAt) {
14875
15275
  const outcome = await refreshKeptCopy({
14876
15276
  origin,
14877
15277
  script: bundleScriptPath(),
14878
- configDirectory: path27.dirname(getConfigPath()),
15278
+ configDirectory: path28.dirname(getConfigPath()),
14879
15279
  runningTarball: bundleTarballPath(),
14880
15280
  env: process.env,
14881
15281
  deadlineAt,
@@ -14935,7 +15335,7 @@ function gitDeadline() {
14935
15335
  return activeDeadlineAt === void 0 ? Date.now() + GIT_BUDGET_MS2 : activeDeadlineAt - hookTiming.gitMarginMs;
14936
15336
  }
14937
15337
  function destinationOf(opts) {
14938
- return path27.resolve(opts.dest ?? process.cwd());
15338
+ return path28.resolve(opts.dest ?? process.cwd());
14939
15339
  }
14940
15340
  function instructionsClient(opts, timeoutMs, { neverRetry = false } = {}) {
14941
15341
  if (!getApiKey(deploymentOrigin(opts), opts.project)) {
@@ -15485,7 +15885,7 @@ async function syncOnce(opts) {
15485
15885
  projectId: opts.project,
15486
15886
  deadline: gitDeadline(),
15487
15887
  optedOut: opts.fastForward === false,
15488
- traceFile: traceFile(path27.dirname(getConfigPath()))
15888
+ traceFile: traceFile(path28.dirname(getConfigPath()))
15489
15889
  }) : null;
15490
15890
  return {
15491
15891
  kind: "reported",
@@ -15557,42 +15957,67 @@ async function syncOnce(opts) {
15557
15957
  return { kind: "synced", outcome };
15558
15958
  }
15559
15959
  let contents = /* @__PURE__ */ new Map();
15560
- if (plan.writes.length > 0) {
15561
- let perFile = plan.writes.length <= PER_FILE_MAX_WRITES;
15562
- if (perFile) {
15563
- const byPath3 = new Map(
15564
- manifest.map((entry) => [entry.path, entry])
15565
- );
15960
+ let staged = null;
15961
+ let applied;
15962
+ try {
15963
+ for (let attempt2 = 0; plan.writes.length > 0 && attempt2 < 2; attempt2++) {
15566
15964
  try {
15567
- contents = await fetchFilesByUrl({
15568
- client,
15569
- projectId: opts.project,
15570
- org: orgSlugFor(opts),
15571
- digest: published.snapshot.digest,
15572
- files: plan.writes.map((entry) => ({
15573
- path: entry.path,
15574
- size: byPath3.get(entry.path)?.size ?? -1,
15575
- sha256: byPath3.get(entry.path)?.sha256 ?? ""
15576
- })),
15577
- timeoutMs: opts.hook ? hookDeadlineMs() : BUNDLE_TIMEOUT_MS,
15578
- signal: activeDeadline
15579
- });
15965
+ const byPath3 = new Map(
15966
+ manifest.map((entry) => [entry.path, entry])
15967
+ );
15968
+ const large = manifest.reduce((total, entry) => total + entry.size, 0) > IN_MEMORY_SNAPSHOT_BYTES;
15969
+ if (large || attempt2 === 0 && plan.writes.length <= PER_FILE_MAX_WRITES) {
15970
+ const options = {
15971
+ client,
15972
+ projectId: opts.project,
15973
+ org: orgSlugFor(opts),
15974
+ digest: published.snapshot.digest,
15975
+ files: plan.writes.map((entry) => ({
15976
+ path: entry.path,
15977
+ size: byPath3.get(entry.path)?.size ?? -1,
15978
+ sha256: entry.sha256 ?? ""
15979
+ })),
15980
+ timeoutMs: opts.hook ? hookDeadlineMs() : BUNDLE_TIMEOUT_MS,
15981
+ signal: activeDeadline
15982
+ };
15983
+ if (large) {
15984
+ staged = await stageFilesByUrl(options);
15985
+ contents = staged;
15986
+ } else contents = await fetchFilesByUrl(options);
15987
+ } else {
15988
+ const download2 = await downloadUrlClient(
15989
+ opts
15990
+ ).instructions.createDownloadUrl(opts.project, {
15991
+ org: orgSlugFor(opts)
15992
+ });
15993
+ const archive = await fetchBundle(download2.url, {
15994
+ timeoutMs: opts.hook ? hookDeadlineMs() : BUNDLE_TIMEOUT_MS,
15995
+ maxBytes: maxArchiveBytes(manifest),
15996
+ signal: activeDeadline
15997
+ });
15998
+ contents = extractBundle(
15999
+ archive,
16000
+ plan.writes.map((entry) => ({
16001
+ path: entry.path,
16002
+ size: byPath3.get(entry.path)?.size ?? -1
16003
+ }))
16004
+ );
16005
+ }
16006
+ break;
15580
16007
  } catch (error) {
15581
- if (!(error instanceof PublishedChangedError)) {
16008
+ if (!(error instanceof PublishedChangedError) || attempt2 > 0)
15582
16009
  throw fixedFailure(error);
15583
- }
15584
16010
  published = await fetchPublished(
15585
16011
  client,
15586
16012
  opts,
15587
16013
  void 0,
15588
16014
  guard
15589
16015
  );
15590
- if (!published.published || !published.snapshot) {
16016
+ if (!published.published || !published.snapshot)
15591
16017
  throw new CliFailure(
15592
16018
  "This project has no published coding instructions yet.",
15593
16019
  4
15594
16020
  );
15595
- }
15596
16021
  outcome.version = published.snapshot.version;
15597
16022
  outcome.digest = published.snapshot.digest;
15598
16023
  manifest = assertValidManifest({
@@ -15604,43 +16029,12 @@ async function syncOnce(opts) {
15604
16029
  { keepLocalEdits }
15605
16030
  );
15606
16031
  fillPlanPaths(outcome, plan);
15607
- perFile = false;
15608
16032
  }
15609
16033
  }
15610
- if (!perFile && plan.writes.length > 0) {
15611
- const org = orgSlugFor(opts);
15612
- let download2;
15613
- try {
15614
- download2 = await downloadUrlClient(
15615
- opts
15616
- ).instructions.createDownloadUrl(opts.project, { org });
15617
- } catch (error) {
15618
- throw fixedFailure(error);
15619
- }
15620
- const sizes = new Map(
15621
- manifest.map((entry) => [entry.path, entry.size])
15622
- );
15623
- const archive = await fetchBundle(download2.url, {
15624
- timeoutMs: opts.hook ? hookDeadlineMs() : BUNDLE_TIMEOUT_MS,
15625
- // Bounded by what this manifest says it holds, not by what the
15626
- // response claims. The manifest has already been checked against
15627
- // the published snapshot limits, so this is a number the client
15628
- // decided.
15629
- maxBytes: maxArchiveBytes(manifest),
15630
- // In hook mode the same clock that bounds the command bounds the
15631
- // download, rather than a second independent budget after it.
15632
- signal: activeDeadline
15633
- });
15634
- contents = extractBundle(
15635
- archive,
15636
- plan.writes.map((entry) => ({
15637
- path: entry.path,
15638
- size: sizes.get(entry.path) ?? -1
15639
- }))
15640
- );
15641
- }
16034
+ applied = await applyPlan({ root, plan, contents, keepLocalEdits });
16035
+ } finally {
16036
+ await staged?.dispose();
15642
16037
  }
15643
- const applied = await applyPlan({ root, plan, contents, keepLocalEdits });
15644
16038
  outcome.removed = applied.deleted;
15645
16039
  if (applied.keptEdited.length > 0) {
15646
16040
  const late = new Set(applied.keptEdited);
@@ -15774,7 +16168,7 @@ async function runPush2(opts, format) {
15774
16168
  manifest: published.manifest ?? [],
15775
16169
  added: opts.add
15776
16170
  });
15777
- if (computed.changes.length === 0) {
16171
+ if (computed.entries.length === 0) {
15778
16172
  throw new CliFailure(
15779
16173
  `Nothing to push: every file the last sync wrote still matches version ${lock.snapshotVersion}. Add a new file with --add <path> if you meant to suggest one.`,
15780
16174
  7
@@ -15805,9 +16199,15 @@ async function runPush2(opts, format) {
15805
16199
  );
15806
16200
  }
15807
16201
  }
15808
- if (plan.changes.length > MAX_PUSH_CHANGES) {
16202
+ if (plan.entries.length > MAX_PUSH_CHANGES) {
15809
16203
  throw new CliFailure(
15810
- `Too many changes to push (${plan.changes.length} > ${MAX_PUSH_CHANGES}). A change set this large is a replacement rather than an edit \u2014 upload the folder from the project's Coding Instructions tab, which is also the only path that re-reads the project's exclusion rules.`,
16204
+ `Too many changes to push (${plan.entries.length} > ${MAX_PUSH_CHANGES}). A change set this large is a replacement rather than an edit \u2014 upload the folder from the project's Coding Instructions tab, which is also the only path that re-reads the project's exclusion rules.`,
16205
+ 7
16206
+ );
16207
+ }
16208
+ if (pushContentBytes(plan.entries) > MAX_INLINE_PUSH_BYTES) {
16209
+ throw new CliFailure(
16210
+ `The selected changes are too large to send inline (over ${MAX_INLINE_PUSH_BYTES} bytes of file content). Upload the folder from the project's Coding Instructions tab instead.`,
15811
16211
  7
15812
16212
  );
15813
16213
  }
@@ -15837,7 +16237,7 @@ async function runPush2(opts, format) {
15837
16237
  published: null,
15838
16238
  pullRequest: null
15839
16239
  };
15840
- if (plan.changes.length === 0 && opts.publish) {
16240
+ if (plan.entries.length === 0 && opts.publish) {
15841
16241
  if (format === "json") {
15842
16242
  printOutput(outcome, { format: "json" });
15843
16243
  }
@@ -15852,17 +16252,26 @@ async function runPush2(opts, format) {
15852
16252
  sawBranchWithoutPullRequest = true;
15853
16253
  }
15854
16254
  };
15855
- if (!opts.dryRun && plan.changes.length > 0) {
16255
+ if (!opts.dryRun && plan.entries.length > 0) {
16256
+ let changes;
16257
+ try {
16258
+ changes = await materializePushChanges({
16259
+ root,
16260
+ entries: plan.entries
16261
+ });
16262
+ } catch (error) {
16263
+ throw new CliFailure(describeError(error), 7);
16264
+ }
15856
16265
  try {
15857
16266
  const submitted = opts.publish ? await client.instructions.publishChange(
15858
16267
  opts.project,
15859
16268
  lock.snapshotId,
15860
- plan.changes,
16269
+ changes,
15861
16270
  { org: orgSlugFor(opts) }
15862
16271
  ) : await client.instructions.submitChange(
15863
16272
  opts.project,
15864
16273
  lock.snapshotId,
15865
- plan.changes,
16274
+ changes,
15866
16275
  { org: orgSlugFor(opts), ...note ? { note } : {} }
15867
16276
  );
15868
16277
  outcome.snapshotId = submitted.snapshotId;
@@ -16214,7 +16623,7 @@ function reportAlreadyProposed(outcome) {
16214
16623
  var CLONE_TIMEOUT_MS = 5 * 6e4;
16215
16624
  async function isEmptyFolder(folder) {
16216
16625
  try {
16217
- return (await readdir4(folder)).length === 0;
16626
+ return (await readdir3(folder)).length === 0;
16218
16627
  } catch (error) {
16219
16628
  return error.code === "ENOENT";
16220
16629
  }
@@ -16278,12 +16687,14 @@ async function runInit(opts, format) {
16278
16687
  destination = await resolveCloneFolder(destination, cloneFolder);
16279
16688
  }
16280
16689
  let existing = await resolveExistingRoot(destination);
16690
+ let cloned = false;
16691
+ const freshCloneDestination = cloneFolder !== void 0 && await isEmptyFolder(destination);
16281
16692
  await assertKeyOutside(existing);
16282
16693
  const client = instructionsClient(opts, SYNC_TIMEOUT_MS);
16283
16694
  const guard = new SourceGuard(
16284
16695
  destination,
16285
16696
  opts.remote,
16286
- cloneFolder !== void 0
16697
+ freshCloneDestination
16287
16698
  );
16288
16699
  const published = await fetchPublished(client, opts, void 0, guard);
16289
16700
  const repository = published.repository ?? null;
@@ -16313,8 +16724,9 @@ async function runInit(opts, format) {
16313
16724
  destination,
16314
16725
  repository
16315
16726
  );
16727
+ cloned = true;
16316
16728
  if (cloneFolder !== void 0 && checkout.class === "unmapped") {
16317
- destination = path27.join(checkout.toplevel, checkout.rootPath);
16729
+ destination = path28.join(checkout.toplevel, checkout.rootPath);
16318
16730
  existing = await resolveExistingRoot(destination);
16319
16731
  checkout = await classifyCheckout({
16320
16732
  destination: existing,
@@ -16356,6 +16768,13 @@ async function runInit(opts, format) {
16356
16768
  }
16357
16769
  }
16358
16770
  const matching = checkout?.class === "matching" ? checkout : null;
16771
+ const interrupted = matching === null ? null : await checkoutAppearsIncomplete(
16772
+ matching.toplevel,
16773
+ gitDeadline()
16774
+ );
16775
+ if (interrupted?.kind === "ok" && interrupted.value) {
16776
+ throw outcomeFailure("clone-checkout-needs-repair", {});
16777
+ }
16359
16778
  if (matching === null) {
16360
16779
  await readLockForProject2(existing, opts.project);
16361
16780
  }
@@ -16374,6 +16793,32 @@ async function runInit(opts, format) {
16374
16793
  if (opts.lessons && !tools.tools.includes("claude-code")) {
16375
16794
  throw outcomeFailure("lessons-need-claude", {});
16376
16795
  }
16796
+ if (matching !== null) {
16797
+ const prefix = path28.relative(matching.toplevel, root);
16798
+ const relativeSetupPaths = [
16799
+ ...tools.tools.map(
16800
+ (tool) => path28.join(prefix, hookPathFor(tool)).replaceAll(path28.sep, "/")
16801
+ ),
16802
+ path28.join(prefix, LOCK_DIRECTORY, LOCK_FILENAME).replaceAll(path28.sep, "/")
16803
+ ];
16804
+ const tracked = await trackedPaths(
16805
+ matching.toplevel,
16806
+ relativeSetupPaths,
16807
+ gitDeadline()
16808
+ );
16809
+ if (tracked.kind !== "ok") {
16810
+ throw new CliFailure(
16811
+ "Fabric could not verify whether its local setup files are tracked by Git, so it did not modify them. Check git status and run init again.",
16812
+ 7
16813
+ );
16814
+ }
16815
+ if (tracked.value.length > 0) {
16816
+ throw new CliFailure(
16817
+ `Fabric left this checkout unchanged because Git tracks setup file${tracked.value.length === 1 ? "" : "s"}: ${tracked.value.join(", ")}. Keep ${tracked.value.length === 1 ? "it" : "them"} under the repository's normal setup process, or configure Fabric's hook and agent integration manually outside tracked paths.`,
16818
+ 7
16819
+ );
16820
+ }
16821
+ }
16377
16822
  let outcome = null;
16378
16823
  if (checkout === null && published.published) {
16379
16824
  const synced = await syncOnce({ ...opts, hook: false, guard });
@@ -16389,7 +16834,7 @@ async function runInit(opts, format) {
16389
16834
  const applies = opts.reportOnly ? false : matching !== null ? true : Boolean(opts.apply);
16390
16835
  const launcher = await resolveHookLauncher({
16391
16836
  origin: deploymentOrigin(opts),
16392
- configDirectory: path27.dirname(getConfigPath()),
16837
+ configDirectory: path28.dirname(getConfigPath()),
16393
16838
  lookup: { env: machine.env(), platform: machine.platform() },
16394
16839
  bundle: isServedBundle() ? { scriptPath: bundleScriptPath() } : null
16395
16840
  });
@@ -16427,7 +16872,8 @@ async function runInit(opts, format) {
16427
16872
  deadline: gitDeadline()
16428
16873
  });
16429
16874
  const lockRemoved = matching === null ? false : await dropOwnLock(root, opts.project);
16430
- const mcp = opts.mcp === false || !tools.detected && explicitTool === void 0 ? [] : await registerAgentMcp({
16875
+ const mcpRequested = opts.mcp !== false && (tools.detected || explicitTool !== void 0);
16876
+ const mcp = !mcpRequested ? [] : await registerAgentMcp({
16431
16877
  tools: tools.tools,
16432
16878
  projectId: opts.project,
16433
16879
  origin: deploymentOrigin(opts),
@@ -16443,6 +16889,8 @@ async function runInit(opts, format) {
16443
16889
  }),
16444
16890
  interactive: format !== "json" && canPrompt() && process.stdout.isTTY === true && !runningInCi()
16445
16891
  });
16892
+ const mcpComplete = !mcpRequested || mcpRegistrationComplete(mcp);
16893
+ const mcpAuthPending = mcpAuthenticationPending(mcp);
16446
16894
  const first = hooks[0];
16447
16895
  if (format === "json") {
16448
16896
  printOutput(
@@ -16461,6 +16909,9 @@ async function runInit(opts, format) {
16461
16909
  replacedHooks: hook.replacedCount
16462
16910
  })),
16463
16911
  lessonsHook: Boolean(opts.lessons),
16912
+ mcpRequested,
16913
+ mcpComplete,
16914
+ mcpAuthenticationPending: mcpAuthPending,
16464
16915
  mcp: mcp.map((result) => ({
16465
16916
  tool: result.tool,
16466
16917
  name: result.name,
@@ -16477,6 +16928,12 @@ async function runInit(opts, format) {
16477
16928
  },
16478
16929
  { format: "json" }
16479
16930
  );
16931
+ if (!mcpComplete) {
16932
+ throw new CliFailure(
16933
+ "Coding tool setup is incomplete: the Fabric MCP server was not registered for every selected tool. Complete the action shown above, then run init again.",
16934
+ 7
16935
+ );
16936
+ }
16480
16937
  return;
16481
16938
  }
16482
16939
  for (const hook of hooks) {
@@ -16512,12 +16969,17 @@ async function runInit(opts, format) {
16512
16969
  for (const mcpLine of agentMcpLines(mcp)) {
16513
16970
  line(mcpLine);
16514
16971
  }
16515
- if (cloneFolder !== void 0) {
16972
+ if (mcpAuthPending) {
16973
+ line(
16974
+ "The Fabric MCP server is registered, but its coding-tool sign-in is still pending."
16975
+ );
16976
+ }
16977
+ if (cloneFolder !== void 0 && cloned) {
16516
16978
  line(
16517
16979
  outcomeLine("cloned-into", {
16518
16980
  where: folderForCommand(
16519
16981
  opts.dest,
16520
- path27.relative(
16982
+ path28.relative(
16521
16983
  await resolveExistingRoot(destinationOf(opts)),
16522
16984
  root
16523
16985
  )
@@ -16525,6 +16987,15 @@ async function runInit(opts, format) {
16525
16987
  })
16526
16988
  );
16527
16989
  }
16990
+ if (!mcpComplete) {
16991
+ line(
16992
+ "The session hook was set up, but the Fabric MCP server was not registered for every selected tool."
16993
+ );
16994
+ throw new CliFailure(
16995
+ "Coding tool setup is incomplete: the Fabric MCP server was not registered for every selected tool. Complete the action shown above, then run init again.",
16996
+ 7
16997
+ );
16998
+ }
16528
16999
  line(
16529
17000
  outcomeLine("set-up", {
16530
17001
  repo: matching !== null && repository !== null ? repositoryName(repository) : null,
@@ -16554,8 +17025,8 @@ async function runLessonPromptCommand(opts) {
16554
17025
  stdinText = await readAllStdin(process.stdin);
16555
17026
  } catch {
16556
17027
  }
16557
- const markerDir = path27.join(
16558
- path27.dirname(getConfigPath()),
17028
+ const markerDir = path28.join(
17029
+ path28.dirname(getConfigPath()),
16559
17030
  "lesson-prompts"
16560
17031
  );
16561
17032
  const result = await runLessonPrompt({