comfyui-mcp 0.52.76 → 0.52.78

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.
@@ -2,14 +2,14 @@ import { execFileSync } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
4
4
  import { basename, isAbsolute, join, relative, resolve } from "node:path";
5
- import { config, getComfyUIBaseUrl, isRemoteMode } from "../config.js";
5
+ import { config, getComfyUIBaseUrl, getComfyuiTargetGeneration, isRemoteMode, } from "../config.js";
6
6
  import { comfyuiFetch } from "../comfyui/fetch.js";
7
7
  import { resetObjectInfoCache } from "../comfyui/client.js";
8
8
  import { progressEnabled, reportDownloadProgress } from "./download-progress.js";
9
9
  import { parsePyproject } from "./node-authoring.js";
10
10
  import { getNodePackDetails } from "./registry-client.js";
11
11
  import { cacheManagerApi, dialectRecheckSuppressed, getCachedManagerApi, managerApiCacheStamp, managerApiEpoch, resetManagerApiCache, setManagerApiCacheForTests, suppressDialectRecheck, } from "./manager-api-cache.js";
12
- import { detectLocalWriteTargetMismatch, resolveEffectiveComfyUIBase, resolveEffectiveComfyUICodeBase, resolveInstallInterpreter, } from "./workspace-env.js";
12
+ import { detectLocalWriteTargetMismatch, resolveEffectiveComfyUIBase, resolveEffectiveComfyUICodeBase, resolveCustomNodesScanBaseLiveStrict, resolveInstallInterpreter, } from "./workspace-env.js";
13
13
  import { assertComfyCliOk, getComfyCliVersion, isSupportedComfyCliVersion, resolveComfyCliExecutable, runComfyCliSync, } from "./comfy-cli.js";
14
14
  import { withPanelPinGuard } from "./panel-pin-guard.js";
15
15
  import { ambiguousBareNameRefusal, ambiguousBareNameWarning, bareNameAmbiguity, isFromSourceSpec, registryVersionAmbiguity, registryVersionRefusal, rekeyedRegistryVersionAmbiguity, rekeyedRegistryVersionRefusal, rekeyedRepoAmbiguity, rekeyedRepoRefusal, rekeyedRepoWarning, } from "./manager-bare-name-collisions.js";
@@ -156,7 +156,7 @@ export function explainManagerAuthenticationRequired(status) {
156
156
  credentialHint);
157
157
  }
158
158
  async function managerFetch(path, options = {}) {
159
- const { method = "GET", body, base = managerBaseUrl(), soft = false } = options;
159
+ const { method = "GET", body, base = managerBaseUrl(), soft = false, onSoftFailure, } = options;
160
160
  const url = `${base}${path}`;
161
161
  logger.debug("Manager API request", { url, method });
162
162
  const headers = {};
@@ -171,8 +171,10 @@ async function managerFetch(path, options = {}) {
171
171
  });
172
172
  }
173
173
  catch (err) {
174
- if (soft)
174
+ if (soft) {
175
+ onSoftFailure?.(undefined);
175
176
  return undefined;
177
+ }
176
178
  throw new NodeManagementError(`ComfyUI-Manager API unreachable at ${url}. Is ComfyUI running with ComfyUI-Manager installed? (${err instanceof Error ? err.message : String(err)})`);
177
179
  }
178
180
  if (!res.ok) {
@@ -181,6 +183,7 @@ async function managerFetch(path, options = {}) {
181
183
  // text, never a wrong conclusion. Verified there is no branch on this value.
182
184
  const text = await res.text().catch(() => "");
183
185
  if (soft) {
186
+ onSoftFailure?.(res.status);
184
187
  // A soft probe may ignore an absent route or a transient server error, but
185
188
  // authentication is actionable evidence. Returning undefined here made
186
189
  // detectManagerApi() fall through to its false "Manager is missing"
@@ -331,6 +334,8 @@ function looksLikeQueueStatus(s) {
331
334
  typeof q.pending_count === "number" ||
332
335
  typeof q.done_count === "number");
333
336
  }
337
+ /** Internal marker carried by the all-soft queue-generation probe. */
338
+ const MANAGER_QUEUE_DETECTION_FAILURE = "manager-queue-detection";
334
339
  /**
335
340
  * Probe both queue/status dialects while preserving their HTTP status. This is
336
341
  * intentionally separate from `managerFetch(..., { soft: true })`, whose
@@ -487,13 +492,26 @@ async function probeManagerApi(base) {
487
492
  // conclusion describes a server that may be gone and must not be pinned over
488
493
  // the newer state (#646).
489
494
  const stamp = managerApiCacheStamp();
490
- const v2 = await managerFetch("/v2/manager/queue/status", { base, soft: true });
495
+ const queueStatusCodes = [undefined, undefined];
496
+ const v2 = await managerFetch("/v2/manager/queue/status", {
497
+ base,
498
+ soft: true,
499
+ onSoftFailure: (status) => {
500
+ queueStatusCodes[0] = status;
501
+ },
502
+ });
491
503
  if (looksLikeQueueStatus(v2)) {
492
504
  const api = await resolveV2SubDialect(base);
493
505
  cacheManagerApi(base, api, stamp);
494
506
  return api;
495
507
  }
496
- const legacy = await managerFetch("/manager/queue/status", { base, soft: true });
508
+ const legacy = await managerFetch("/manager/queue/status", {
509
+ base,
510
+ soft: true,
511
+ onSoftFailure: (status) => {
512
+ queueStatusCodes[1] = status;
513
+ },
514
+ });
497
515
  if (looksLikeQueueStatus(legacy)) {
498
516
  // /manager/queue/status answering ALMOST always means the released 3.x
499
517
  // custom-node Manager — but do NOT brand it "legacy 3.x" (and speak 3.x
@@ -526,7 +544,11 @@ async function probeManagerApi(base) {
526
544
  throw new NodeManagementError("ComfyUI-Manager's queue API is not reachable (neither /v2/manager/queue/status " +
527
545
  "nor /manager/queue/status answered with a queue status). Is ComfyUI-Manager " +
528
546
  "installed and enabled on the connected ComfyUI? The pip comfyui_manager " +
529
- "package only activates when ComfyUI is started with --enable-manager.");
547
+ "package only activates when ComfyUI is started with --enable-manager.", {
548
+ kind: MANAGER_QUEUE_DETECTION_FAILURE,
549
+ base,
550
+ queueStatusCodes,
551
+ });
530
552
  }
531
553
  /** Queue route prefix for a dialect ("v2-batch" serves status/start under the
532
554
  * /v2 prefix too — only the mutation routes differ). */
@@ -1859,12 +1881,13 @@ export function findPackOnDisk(id, comfyuiBase) {
1859
1881
  }
1860
1882
  return { state: "not-found", scanned: customNodes };
1861
1883
  }
1862
- /** Capture the presence context for one operation, before its first await. */
1863
- export function capturePackPresenceContext(diskRoot) {
1884
+ export function capturePackPresenceContext(diskRoot, options = {}) {
1864
1885
  const remote = isRemoteMode();
1865
1886
  return {
1866
1887
  remote,
1867
- diskRoot: remote ? undefined : (diskRoot ?? resolveEffectiveComfyUIBase()),
1888
+ diskRoot: remote || (diskRoot === undefined && options.allowConfiguredFallback === false)
1889
+ ? undefined
1890
+ : (diskRoot ?? resolveEffectiveComfyUIBase()),
1868
1891
  };
1869
1892
  }
1870
1893
  /**
@@ -2154,16 +2177,21 @@ function discardFailedClone(nodeDir, alreadyPresent, settlingProcesses = false)
2154
2177
  `import it on every start and log an error — delete that directory by hand.`);
2155
2178
  }
2156
2179
  /**
2157
- * Did ComfyUI-Manager REFUSE the enqueue outright, before queueing anything? (#1129)
2180
+ * Did ComfyUI-Manager reject the route outright, before queueing anything?
2158
2181
  *
2159
- * A git-URL install is gated server-side by `security_level` and
2160
- * `allow_git_url_install`, and a legacy 3.x host that does not serve the route at
2161
- * all answers the same way. All three arrive as a status on the POST itself:
2182
+ * A legacy 3.x git-URL route can be absent, and a Manager-native install can be
2183
+ * refused before queueing by its `security_level` / `allow_git_url_install`
2184
+ * policy. Both arrive as a status on the POST itself:
2162
2185
  *
2163
- * 403 — "A security error has occurred" (the policy gate)
2164
- * 404 — the route is not registered on this build
2186
+ * 404 — the route is not registered on this build. Git installs use
2187
+ * `/manager/queue/install`, `/v2/manager/queue/task`, or the legacy-UI
2188
+ * `/v2/manager/queue/batch` envelope.
2165
2189
  *
2166
- * 405 is deliberately NOT here. On this API it is the DIALECT-MISMATCH signal
2190
+ * 403 is admitted only when Manager's authoritative JSON error shape names its
2191
+ * own policy gate, or when one of the exact legacy Manager policy responses is
2192
+ * present. A bare 403 may be an authentication/permission layer and stays
2193
+ * fail-closed. 405 is deliberately
2194
+ * NOT here. On this API it is the DIALECT-MISMATCH signal
2167
2195
  * (ComfyUI's frontend catchall answering a route registered under the other
2168
2196
  * generation), and the self-heal retry in #646 already owns it — diverting to a
2169
2197
  * clone would pre-empt a Manager install that is about to succeed on the right
@@ -2175,16 +2203,100 @@ function discardFailedClone(nodeDir, alreadyPresent, settlingProcesses = false)
2175
2203
  * That is the whole warrant for continuing — and it is why 5xx is excluded, where
2176
2204
  * a task may well have been accepted before the handler fell over.
2177
2205
  *
2178
- * Returns the status, or undefined when the failure is anything else (a transport
2179
- * error, a timeout, a 500) and must keep propagating.
2206
+ * Returns 404, or the explicitly Manager-owned policy 403, or undefined when the
2207
+ * failure is anything else (including 401, bare 403, 407, a transport error, a
2208
+ * timeout, or a 500) and must keep propagating.
2209
+ */
2210
+ const LEGACY_MANAGER_SECURITY_ERROR = "A security error has occurred. Please check the terminal logs";
2211
+ /**
2212
+ * Keep the old plain-text Manager response working without treating arbitrary
2213
+ * response text as proof of Manager ownership. JSON is the current authoritative
2214
+ * Manager shape; the two exact strings are retained for the legacy response and
2215
+ * the existing local compatibility fixture.
2180
2216
  */
2217
+ function isManagerOwnedGitPolicy403(body) {
2218
+ if (typeof body !== "string")
2219
+ return false;
2220
+ const text = body.trim();
2221
+ if (text === LEGACY_MANAGER_SECURITY_ERROR ||
2222
+ text === "gated by security_level") {
2223
+ return true;
2224
+ }
2225
+ try {
2226
+ const parsed = JSON.parse(text);
2227
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
2228
+ return false;
2229
+ const reason = parsed.error;
2230
+ return reason === "security_level" || reason === "allow_git_url_install";
2231
+ }
2232
+ catch {
2233
+ return false;
2234
+ }
2235
+ }
2181
2236
  function managerEnqueueRefusal(err) {
2182
2237
  if (!(err instanceof NodeManagementError))
2183
2238
  return undefined;
2184
- const status = err.details?.status;
2239
+ const details = err.details;
2240
+ const status = details?.status;
2241
+ const url = details?.url;
2185
2242
  if (typeof status !== "number")
2186
2243
  return undefined;
2187
- return status === 403 || status === 404 ? status : undefined;
2244
+ if (typeof url !== "string" ||
2245
+ !/\/manager\/queue\/(?:task|install|batch)(?:[?#]|$)/.test(url)) {
2246
+ return undefined;
2247
+ }
2248
+ if (status === 404)
2249
+ return status;
2250
+ if (status === 403 &&
2251
+ isManagerOwnedGitPolicy403(details?.body)) {
2252
+ return status;
2253
+ }
2254
+ return undefined;
2255
+ }
2256
+ function isManagerQueueDetectionFailure(err) {
2257
+ if (!(err instanceof NodeManagementError) || !err.details || typeof err.details !== "object") {
2258
+ return false;
2259
+ }
2260
+ return err.details.kind === MANAGER_QUEUE_DETECTION_FAILURE;
2261
+ }
2262
+ function initialManagerQueueAbsenceWasProven(err) {
2263
+ if (!(err instanceof NodeManagementError) || !err.details || typeof err.details !== "object") {
2264
+ return false;
2265
+ }
2266
+ const codes = err.details.queueStatusCodes;
2267
+ return (Array.isArray(codes) &&
2268
+ codes.length === 2 &&
2269
+ codes.every((status) => status === 404));
2270
+ }
2271
+ /**
2272
+ * A direct clone is safe after either a Manager-native policy refusal whose body
2273
+ * explicitly proves no task was queued, a direct enqueue route-level 404, or a
2274
+ * detection failure followed by a fresh probe proving BOTH queue dialects are
2275
+ * 404 on the same captured ComfyUI base.
2276
+ * A bare 401/403/407, timeout, 5xx, malformed response, or unrelated reachability
2277
+ * error remains fail-closed.
2278
+ */
2279
+ async function managerAbsenceAllowsGitFallback(err, base) {
2280
+ const status = errorStatus(err);
2281
+ const preQueueRefusal = managerEnqueueRefusal(err);
2282
+ // The enqueue route's own 404 is already a pre-queue refusal. Preserve the
2283
+ // legacy contract that authorized a direct clone on that route-level fact;
2284
+ // the fresh status probes are required only for the new detection-failure
2285
+ // path, where the enqueue error carries no route-level refusal.
2286
+ if (preQueueRefusal === 403 || preQueueRefusal === 404)
2287
+ return true;
2288
+ if (status !== undefined)
2289
+ return false;
2290
+ if (status === undefined &&
2291
+ (!isManagerQueueDetectionFailure(err) || !initialManagerQueueAbsenceWasProven(err))) {
2292
+ return false;
2293
+ }
2294
+ try {
2295
+ return (await probeManagerQueueAvailability(base)) === "absent";
2296
+ }
2297
+ catch {
2298
+ return false;
2299
+ }
2188
2300
  }
2189
2301
  /**
2190
2302
  * #1765 (recurrence on 0.52.35) — the write was aimed at the wrong ComfyUI.
@@ -2241,11 +2353,11 @@ async function cloneCustomNodeFallback(gitId, repoName, gitRef, managerStatus, b
2241
2353
  */
2242
2354
  opts) {
2243
2355
  const because = opts?.managerRefusalNote ?? `"${repoName}" is not in the ComfyUI-Manager registry`;
2244
- // basePath is the CALL-SCOPED local ComfyUI root (apply_manifest threads an
2245
- // adopted saved-default/live root here WITHOUT mutating global config, so a
2246
- // panel-connected local session with no COMFYUI_PATH can still clone an
2247
- // unregistered git pack); fall back to the shared data/base resolver.
2248
- const comfyuiBase = basePath ?? resolveEffectiveComfyUIBase();
2356
+ // basePath is the CALL-SCOPED local ComfyUI root. When the caller has already
2357
+ // established that no safe root exists, do not re-read the saved-default
2358
+ // resolver and recreate the wrong-machine clone hazard.
2359
+ const comfyuiBase = basePath ??
2360
+ (opts?.allowSharedWorkspaceFallback === false ? undefined : resolveEffectiveComfyUIBase());
2249
2361
  // Same hazard as the CLI paths (codex gate P0): the guard below catches "no
2250
2362
  // path", but the dangerous case is HAVING one while connected elsewhere. A
2251
2363
  // clone into a stale local tree would report a successful install of a pack the
@@ -2493,6 +2605,37 @@ function comfyCliUnavailableReason(workspace) {
2493
2605
  }
2494
2606
  return undefined;
2495
2607
  }
2608
+ async function resolveInstallLocalWorkspace(opts) {
2609
+ if (opts.comfyuiPath !== undefined)
2610
+ return opts.comfyuiPath;
2611
+ if (opts.localCloneFallback === "verified-only")
2612
+ return undefined;
2613
+ // An unset COMFYUI_PATH is not permission to use the saved default. The
2614
+ // connected local server must identify a usable custom_nodes scan root, or
2615
+ // this operation remains Manager-only. This resolver never throws for an
2616
+ // unavailable/ambiguous live root; the Manager attempt still proceeds.
2617
+ if (!process.env.COMFYUI_PATH && !isRemoteMode()) {
2618
+ try {
2619
+ return await resolveCustomNodesScanBaseLiveStrict({ requireLive: true });
2620
+ }
2621
+ catch {
2622
+ return undefined;
2623
+ }
2624
+ }
2625
+ return resolveEffectiveComfyUIBase();
2626
+ }
2627
+ function assertInstallTargetStable(expectedGeneration, managerBase) {
2628
+ const actualGeneration = getComfyuiTargetGeneration();
2629
+ if (actualGeneration === expectedGeneration)
2630
+ return;
2631
+ throw new ProcessControlError("ComfyUI's target changed while this custom-node install was being prepared. " +
2632
+ "The Manager request and any local clone were refused; retry after the target settles.", {
2633
+ kind: "target-generation-changed",
2634
+ managerBase,
2635
+ expectedGeneration,
2636
+ actualGeneration,
2637
+ });
2638
+ }
2496
2639
  async function installCustomNodeImpl(opts) {
2497
2640
  const { id, version, mode = "remote", channel = "default" } = opts;
2498
2641
  const parsedGit = parseGitUrl(id);
@@ -2523,31 +2666,32 @@ async function installCustomNodeImpl(opts) {
2523
2666
  if (source === "git")
2524
2667
  assertSafeGitUrl(gitId);
2525
2668
  // Keep the mutation, its post-queue verification, AND any local filesystem
2526
- // work on the target selected for this invocation — captured NOW, before the
2527
- // first await: a panel retarget in between must not split them across two
2528
- // installs. cliWorkspace is the pack/data root (COMFYUI_PATH, an adopted
2529
- // live `--base-directory`, or the saved default); the CLI --workspace, the
2530
- // ref checkout, and the clone fallback all take this ONE root, so a git
2531
- // install with a ref can never install into the workspace and then fail the
2532
- // checkout for want of a path (codex gate rounds 5-6).
2533
- const managerBase = managerBaseUrl();
2534
- const cliWorkspace = opts.comfyuiPath ?? resolveEffectiveComfyUIBase();
2535
- const presenceCtx = capturePackPresenceContext(cliWorkspace);
2669
+ // work on the target selected for this invocation. The Manager base is
2670
+ // captured before the live-root lookup so a panel retarget cannot move the
2671
+ // HTTP half of this operation while the filesystem target is being verified.
2672
+ // With COMFYUI_PATH unset, a live custom_nodes root is required; a saved
2673
+ // default is machine-local configuration, not proof of what this panel target
2674
+ // scans.
2675
+ const managerBase = opts.managerBase ?? managerBaseUrl();
2676
+ const targetGeneration = opts.targetGeneration ?? getComfyuiTargetGeneration();
2677
+ const cliWorkspace = source === "git" || opts.useCmCli
2678
+ ? await resolveInstallLocalWorkspace(opts)
2679
+ : opts.comfyuiPath ?? resolveEffectiveComfyUIBase();
2680
+ assertInstallTargetStable(targetGeneration, managerBase);
2681
+ const presenceCtx = capturePackPresenceContext(cliWorkspace, {
2682
+ allowConfiguredFallback: cliWorkspace !== undefined,
2683
+ });
2536
2684
  // #1765 — and is that root the install this session is actually CONNECTED to?
2537
2685
  // Pinned to the very cliWorkspace the write will use, so a panel retarget
2538
2686
  // mid-call cannot judge one install's root against another's server.
2539
2687
  //
2540
- // AFTER presenceCtx, deliberately: this is the first await in the function, and
2541
- // capturePackPresenceContext must be taken at operation ENTRY from the mode and
2542
- // config as they are NOW (codex gate round 11 — computing it after an await lets
2543
- // a mid-op retarget pair Manager A's answer with disk B).
2544
- //
2545
2688
  // Taken only when a LOCAL write is reachable for this call: a registry install
2546
2689
  // goes through ComfyUI-Manager, which targets the connected server by
2547
2690
  // construction and has no local destination to misdirect.
2548
2691
  const localWriteMismatch = source === "git" || opts.useCmCli === true
2549
2692
  ? await detectLocalWriteTargetMismatch(cliWorkspace)
2550
2693
  : undefined;
2694
+ assertInstallTargetStable(targetGeneration, managerBase);
2551
2695
  // THE PRE-STATE, OBSERVED BEFORE THE OPERATION (codex gate P0). The "already
2552
2696
  // installed" verdict below used to be inferred from POST-op disk state alone:
2553
2697
  // a pack that was absent before the call and installed as a registry ZIP —
@@ -2655,28 +2799,44 @@ async function installCustomNodeImpl(opts) {
2655
2799
  // A remote target keeps the original error: the clone would write to a
2656
2800
  // LOCAL tree that is not the server we are connected to, so "the Manager
2657
2801
  // refused" is the honest and complete answer there.
2658
- if (refusedBy === undefined || isRemoteMode())
2802
+ assertInstallTargetStable(targetGeneration, managerBase);
2803
+ const absenceAllows = await managerAbsenceAllowsGitFallback(err, managerBase);
2804
+ assertInstallTargetStable(targetGeneration, managerBase);
2805
+ if (isRemoteMode() || !absenceAllows) {
2659
2806
  throw err;
2807
+ }
2660
2808
  // BEFORE the log line, which says "cloning directly" and would otherwise be
2661
2809
  // recording an action that then does not happen.
2662
2810
  if (localWriteMismatch) {
2663
2811
  throw new ProcessControlError(wrongInstallRefusal(localWriteMismatch, 'install_custom_node (action:"install", git clone)', managerBase));
2664
2812
  }
2665
- logger.info("Manager refused the git install enqueue — cloning directly", {
2813
+ logger.info("Manager refused or was proven absent — cloning directly", {
2666
2814
  status: refusedBy,
2667
2815
  gitId,
2668
2816
  });
2669
- return withCliNote(await cloneCustomNodeFallback(gitId, repoName, gitRef, { manager_refused: refusedBy }, cliWorkspace, {
2817
+ const cloned = await cloneCustomNodeFallback(gitId, repoName, gitRef, refusedBy === 403 || refusedBy === 404
2818
+ ? { manager_refused: refusedBy }
2819
+ : { manager_absent: true }, cliWorkspace, {
2670
2820
  refFromVersion,
2671
- managerRefusalNote: `ComfyUI-Manager REFUSED the git-URL install (HTTP ${refusedBy}) — on a legacy 3.x ` +
2672
- `host that is its security_level / allow_git_url_install gate, or a build that does ` +
2673
- `not serve the route. Nothing was queued there.`,
2674
- }));
2821
+ allowSharedWorkspaceFallback: cliWorkspace !== undefined,
2822
+ managerRefusalNote: refusedBy === 403
2823
+ ? `ComfyUI-Manager REFUSED the git-URL install (HTTP 403) via its ` +
2824
+ `security_level / allow_git_url_install policy gate. Nothing was queued there.`
2825
+ : refusedBy === 404
2826
+ ? `ComfyUI-Manager's git-install route returned HTTP 404 before queueing. ` +
2827
+ `Nothing was queued there.`
2828
+ : `ComfyUI-Manager is confirmed absent: both queue/status dialects returned HTTP 404 ` +
2829
+ `on the connected ComfyUI. Nothing was queued there.`,
2830
+ });
2831
+ assertInstallTargetStable(targetGeneration, managerBase);
2832
+ return withCliNote(cloned);
2675
2833
  }
2834
+ assertInstallTargetStable(targetGeneration, managerBase);
2676
2835
  // VERIFY: /v2/customnode/installed reflects on-disk custom_nodes, so a
2677
2836
  // freshly-cloned pack shows up even before a reboot. If the Manager actually
2678
2837
  // installed it, we're done; otherwise it's unregistered → clone it directly.
2679
2838
  const installed = await listInstalledNodesAt(managerBase).catch(() => []);
2839
+ assertInstallTargetStable(targetGeneration, managerBase);
2680
2840
  if (nodeInstalledMatches(gitId, installed)) {
2681
2841
  return withCliNote({
2682
2842
  mechanism: "manager-http",
@@ -2687,7 +2847,12 @@ async function installCustomNodeImpl(opts) {
2687
2847
  if (localWriteMismatch) {
2688
2848
  throw new ProcessControlError(wrongInstallRefusal(localWriteMismatch, 'install_custom_node (action:"install", git clone)', managerBase));
2689
2849
  }
2690
- return withCliNote(await cloneCustomNodeFallback(gitId, repoName, gitRef, status, cliWorkspace, { refFromVersion }));
2850
+ const cloned = await cloneCustomNodeFallback(gitId, repoName, gitRef, status, cliWorkspace, {
2851
+ refFromVersion,
2852
+ allowSharedWorkspaceFallback: cliWorkspace !== undefined,
2853
+ });
2854
+ assertInstallTargetStable(targetGeneration, managerBase);
2855
+ return withCliNote(cloned);
2691
2856
  }
2692
2857
  // Registry (plain CNR id). Keep the prior defaults channel "default" /
2693
2858
  // mode "remote" (overridable via opts) — forcing "dev"/"cache" risks resolving
@@ -2702,10 +2867,12 @@ async function installCustomNodeImpl(opts) {
2702
2867
  channel,
2703
2868
  mode,
2704
2869
  }, managerBase);
2870
+ assertInstallTargetStable(targetGeneration, managerBase);
2705
2871
  // #797: verify across BOTH sources (Manager list + local disk) and keep
2706
2872
  // "could not determine" distinct from "determined absent" — an unreadable
2707
2873
  // Manager list collapsed to [] used to read as "not found in the registry".
2708
2874
  const presence = await resolvePackPresence(id, managerBase, presenceCtx);
2875
+ assertInstallTargetStable(targetGeneration, managerBase);
2709
2876
  switch (presence.state) {
2710
2877
  case "manager-listed":
2711
2878
  return withCliNote({
@@ -4254,8 +4421,8 @@ async function listInstalledNodesAt(base, opts = {}) {
4254
4421
  }
4255
4422
  return parseInstalled(raw);
4256
4423
  }
4257
- export async function listInstalledNodes(opts = {}) {
4258
- return listInstalledNodesAt(managerBaseUrl(), opts);
4424
+ export async function listInstalledNodes(opts = {}, base = managerBaseUrl()) {
4425
+ return listInstalledNodesAt(base, opts);
4259
4426
  }
4260
4427
  /**
4261
4428
  * Reconcile installed-node Python dependencies. comfy-cli exposes this as