dshmarket 1.66.20 → 1.66.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/profile.js CHANGED
@@ -8,7 +8,7 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
8
8
  import { isDeepStrictEqual } from 'node:util';
9
9
  import { JSON_SCHEMA, load } from 'js-yaml';
10
10
  import { resolveDshHome } from './home-paths.js';
11
- import { githubRemoteIdentities, githubRepoIdentities, isGitHostedSpec, repoOfTarget } from './sources.js';
11
+ import { githubRemoteIdentities, githubRepoIdentities, hostedRepoKey, isGitHostedSpec, repoOfTarget } from './sources.js';
12
12
  /**
13
13
  * Whether a profile name follows DSH's own directory-name contract.
14
14
  *
@@ -680,9 +680,20 @@ export function readLockCommits(profile, explicitDir) {
680
680
  * by pnpm's `path:` selector, so the spec's subpath has to match too — and
681
681
  * when the spec names no subpath while several entries of that remote do,
682
682
  * there is no answer rather than the first sibling's commit (#632).
683
+ *
684
+ * The key on both sides of the comparison is `hostedRepoKey`, the identity
685
+ * the rest of the market already compares specs by (#851). This function
686
+ * used to derive its own, and that second spelling lost every install pnpm
687
+ * writes the shorthand back for: it demanded an `https?://` prefix or a
688
+ * `.git` suffix, so `github:owner/repo` keyed to nothing at all, and it kept
689
+ * the `.git` suffix, so the spec side `https://github.com/o/r` could never
690
+ * equal the lockfile side pnpm writes as `git@github.com:o/r.git`. A floating
691
+ * shorthand therefore had no commit, and with no commit the update route
692
+ * refuses to roll back — the market reports "cannot confirm the commit before
693
+ * the update" for a lockfile that states it plainly.
683
694
  */
684
695
  export function readGitResolutionCommit(profile, spec, explicitDir) {
685
- const want = normalizeGitRepoKey(spec);
696
+ const want = hostedRepoKey(spec);
686
697
  if (want === null)
687
698
  return null;
688
699
  const wantPath = gitSubpathSelector(spec);
@@ -695,7 +706,7 @@ export function readGitResolutionCommit(profile, spec, explicitDir) {
695
706
  const repo = /\brepo:\s*([^\s,}]+)/.exec(body);
696
707
  if (commit === null || repo === null)
697
708
  continue;
698
- if (normalizeGitRepoKey(repo[1]) !== want)
709
+ if (hostedRepoKey(repo[1]) !== want)
699
710
  continue;
700
711
  const entryPath = /\bpath:\s*([^\s,}]+)/.exec(body)?.[1]?.replace(/^\/+|\/+$/g, '').toLowerCase() ?? null;
701
712
  if (wantPath !== null) {
@@ -722,23 +733,6 @@ function gitSubpathSelector(spec) {
722
733
  const trimmed = selector.replace(/^\/+|\/+$/g, '').toLowerCase();
723
734
  return trimmed === '' ? null : trimmed;
724
735
  }
725
- /** Lowercased transport-agnostic key for comparing two git remote spellings. */
726
- function normalizeGitRepoKey(spec) {
727
- let remote = spec.trim().replace(/^git\+/i, '');
728
- const scp = /^git@([^:]+):(.+)$/.exec(remote);
729
- if (scp !== null)
730
- remote = `https://${scp[1]}/${scp[2].replace(/^\/*/, '')}`;
731
- const hash = remote.indexOf('#');
732
- if (hash !== -1)
733
- remote = remote.slice(0, hash);
734
- const query = remote.indexOf('?');
735
- if (query !== -1)
736
- remote = remote.slice(0, query);
737
- remote = remote.replace(/\/+$/, '').toLowerCase();
738
- if (!/^https?:\/\//.test(remote) && !remote.includes('.git'))
739
- return null;
740
- return remote;
741
- }
742
736
  /** True when the installed package's manifest declares a dsh plugin surface. */
743
737
  export function hasDshManifest(dir) {
744
738
  try {
@@ -859,32 +853,52 @@ export function parsePatchRows(text) {
859
853
  // and the market uninstalled the newcomer over a clash the host does
860
854
  // not have.
861
855
  let payloadIndent = null;
862
- /** Index of the `- ` line that opens the row holding `from`. */
856
+ const isDash = (index) => /^-(?:\s|$)/u.test(rows[index].trimmed);
857
+ /**
858
+ * Column where a line's key text starts: its indent plus any `- ` marker.
859
+ * A row's keys share this column whether written on the marker line or
860
+ * below it, however many spaces follow the `-`.
861
+ */
862
+ const keyCol = (index) => {
863
+ // A bare `-` puts the row's keys on the lines below it.
864
+ if (rows[index].trimmed === '-') {
865
+ const next = rows[index + 1];
866
+ return next !== undefined && next.indent > rows[index].indent ? next.indent : rows[index].indent + 2;
867
+ }
868
+ const dash = /^-\s+/u.exec(rows[index].trimmed);
869
+ return rows[index].indent + (dash === null ? 0 : dash[0].length);
870
+ };
871
+ /** Index of the `- ` line that opens the row holding `from`, or -1. */
863
872
  const rowStart = (from) => {
864
- const isRowStart = (index) => /^-\s/u.test(rows[index].trimmed) || rows[index].trimmed === '-';
865
- if (isRowStart(from))
866
- return from;
867
- for (let j = from - 1; j >= 0; j--) {
868
- if (isRowStart(j))
873
+ const col = keyCol(from);
874
+ for (let j = from; j >= 0; j--) {
875
+ if (isDash(j) && keyCol(j) === col)
869
876
  return j;
877
+ // Shallower than the row's keys: left the row without finding its marker.
878
+ if (rows[j].indent < col)
879
+ return -1;
870
880
  }
871
- return 0;
881
+ return -1;
872
882
  };
873
883
  /**
874
884
  * Whether the insert row holding the `config:` at `from` declares
875
885
  * `group: true`. YAML gives a mapping's keys no order, so this reads the
876
- * whole row — its own `- ` marker plus the key lines at the marker's body
877
- * indentation — rather than only what happened to be written above.
886
+ * whole row — its own `- ` marker plus the key lines at the row's key
887
+ * column — rather than only what happened to be written above.
878
888
  */
879
889
  const rowDeclaresGroup = (from) => {
880
890
  const start = rowStart(from);
891
+ if (start === -1)
892
+ return false;
881
893
  const markerIndent = rows[start].indent;
894
+ const col = keyCol(start);
882
895
  for (let j = start; j < rows.length; j++) {
883
896
  const row = rows[j];
884
897
  if (j > start && row.indent <= markerIndent)
885
898
  break;
886
- // The row's own keys; a config body sits deeper than these.
887
- if (j > start && row.indent !== markerIndent + 2)
899
+ // Only the row's own keys: a config body, or a list under another
900
+ // key, is not at the key column or starts with its own `- `.
901
+ if (j > start && (row.indent !== col || isDash(j)))
888
902
  continue;
889
903
  if (/^\s*-?\s*group:\s*['"]?true['"]?\s*$/u.test(row.line))
890
904
  return true;
@@ -912,15 +926,22 @@ export function parsePatchRows(text) {
912
926
  payloadIndent = null;
913
927
  continue;
914
928
  }
915
- // Exactly one `config:` shape carries loader rows: a `group: true` row
916
- // whose config is a LIST, because its children are entries. That is
917
- // the rule userPatchPackageReferences has always applied to this same
918
- // shape — a plain plugin may use a list of `{ name: … }` as ordinary
919
- // options, and those must stay payload. Anything else here is payload
920
- // too, so the block is skipped rather than counted.
921
- if (insertIndent !== null && indent > insertIndent && /^\s*-?\s*config:\s*$/u.test(line)) {
922
- if (!(rowDeclaresGroup(i) && valueIsList(i, indent)))
923
- payloadIndent = indent;
929
+ // Exactly one block under a row carries loader rows: the `config:` of a
930
+ // `group: true` row whose config is a LIST, because its children are
931
+ // entries. That is the rule userPatchPackageReferences has always
932
+ // applied to this same shape — a plain plugin may use a list of
933
+ // `{ name: … }` as ordinary options, and those must stay payload. Any
934
+ // other block-valued key (`config:`, `tags:`, …) holds data too, so the
935
+ // block is skipped rather than counted.
936
+ // Inside a payload, a nested key is more payload: it must not move the
937
+ // payload's edge, or the payload ends early and its ids count.
938
+ const blockKey = /^\s*-?\s*([A-Za-z_][\w.-]*):\s*$/u.exec(line);
939
+ if (payloadIndent === null && insertIndent !== null && indent > insertIndent && blockKey !== null) {
940
+ // The key's own column, not the line's: when the key sits on a `- `
941
+ // line, the row's later keys share its column and are not payload.
942
+ const col = keyCol(i);
943
+ if (!(blockKey[1] === 'config' && rowDeclaresGroup(i) && valueIsList(i, col)))
944
+ payloadIndent = col;
924
945
  continue;
925
946
  }
926
947
  const name = /^\s*-?\s*name:\s*['"]?([^'"\s]+)/.exec(line);
package/lib/recovery.js CHANGED
@@ -938,6 +938,16 @@ export async function runRecovery(config, facts = { exitCode: null, bound: false
938
938
  const servePort = options.port ?? config.port;
939
939
  /** Write errors from the previous apply; they travel into the next payload. */
940
940
  let writeErrors = [];
941
+ // A port that ANSWERS is proof the host came up, and it needs no pid to say
942
+ // so (#854). The helper's pid is the process IT spawned, which on Windows is
943
+ // the `powershell.exe` wrapper rather than the node host that binds — so the
944
+ // guard below cannot be trusted on its own, and a helper that gave up late
945
+ // handed this surface a pid that was already gone while the host was still
946
+ // starting. Asking the socket settles it either way.
947
+ if (servePort !== null && servePort !== 0 && await portListening(servePort)) {
948
+ note(config, `port ${String(servePort)} is already answering — the host came up after all; leaving it alone`);
949
+ return 'booted';
950
+ }
941
951
  // The replacement is not necessarily dead just because it has not bound the
942
952
  // port: on a slow host it is still starting. It gets the same grace as a
943
953
  // released port before the surface takes the port out from under it (#719).
package/lib/regions.js CHANGED
@@ -160,6 +160,28 @@ export function rememberGithubRoute(service, route) {
160
160
  export function resetGithubRoutePreferences() {
161
161
  preferredGithubRoutes.clear();
162
162
  }
163
+ /**
164
+ * Put a user's own prefix in front, and keep the region's other routes (#850).
165
+ *
166
+ * Heading rather than replacing, because a public proxy dying is the whole
167
+ * reason the setting exists — the reporter's was one of these mirrors, and it
168
+ * took the rest of the chain with it: `gh-proxy.com` stopped serving on
169
+ * 2026-10-13, and every GitHub-sourced update then failed on a timeout rather
170
+ * than trying the next candidate. Replacing the list buys one attempt per
171
+ * mirror outage; heading it buys one, and then the chain that was already
172
+ * measured to work.
173
+ *
174
+ * The proxy is also dropped from the tail if it is already there, so a user
175
+ * who accepted the value the settings UI pre-suggests gets exactly the
176
+ * region's own table back rather than the same prefix twice.
177
+ *
178
+ * The direct escape is never dropped: `githubRoutes` promises every list ends
179
+ * in one, and dropping it would turn a dead mirror into an outage instead of
180
+ * a slow request.
181
+ */
182
+ function withCustomProxy(proxy, base) {
183
+ return [proxy, ...base.filter(route => route !== proxy)];
184
+ }
163
185
  /**
164
186
  * The routes for a region, with environment overrides applied.
165
187
  *
@@ -183,7 +205,11 @@ export function routesFor(region, env = process.env) {
183
205
  raw: [...base.githubRoutes.raw],
184
206
  avatar: [...base.githubRoutes.avatar],
185
207
  }
186
- : { git: [githubProxy, null], raw: [githubProxy, null], avatar: [githubProxy, null] };
208
+ : {
209
+ git: withCustomProxy(githubProxy, base.githubRoutes.git),
210
+ raw: withCustomProxy(githubProxy, base.githubRoutes.raw),
211
+ avatar: withCustomProxy(githubProxy, base.githubRoutes.avatar),
212
+ };
187
213
  return {
188
214
  npmRegistry: registry,
189
215
  githubProxy: githubProxy ?? base.githubProxy,
package/lib/restart.js CHANGED
@@ -481,19 +481,39 @@ export function restartHelperSource(spawned, launch, logs, port, recovery = null
481
481
  // seen from the other side. A replacement that EXITS still ends this
482
482
  // early (the `exited` break below), so a genuinely failed boot is not
483
483
  // made slower by the wider backstop.
484
+ ' const startedAt = Date.now()',
484
485
  ' const upBy = Date.now() + 45000 + SETTLE_MS',
486
+ // #854: the window is a budget for "has it come up", NOT a verdict on
487
+ // "is it dead". A cold start right after the market rewrote node_modules
488
+ // can outlast it (measured on Windows: bind at ~63s with a scanner on the
489
+ // fresh files), and handing the port over while the replacement is still
490
+ // RUNNING is what turns a slow boot into the deadlock #719 removed: the
491
+ // surface takes port 3080, the host reaches its own bind and dies on
492
+ // EADDRINUSE, and nothing can come back. So a replacement that has NOT
493
+ // exited keeps the port, up to a hard cap; one that has exited still ends
494
+ // the wait immediately, which is the failed-boot case.
495
+ ' const hardBy = Date.now() + 240000',
485
496
  ' let steadySince = null',
486
- ' while (Date.now() < upBy) {',
497
+ ' let announcedWait = false',
498
+ ' while (true) {',
487
499
  ' if (await listening()) {',
488
500
  ' if (steadySince === null) steadySince = Date.now()',
489
501
  ' else if (Date.now() - steadySince >= SETTLE_MS) return',
490
502
  ' } else {',
491
503
  ' steadySince = null',
492
504
  ' if (exited !== null) break',
505
+ ' const now = Date.now()',
506
+ ' if (now >= hardBy) break',
507
+ ' if (now >= upBy && !announcedWait) {',
508
+ ' announcedWait = true',
509
+ // The numbers are computed, not typed twice: a log line that drifts from
510
+ // the constants it describes is worse than no line at all.
511
+ ' note(`the replacement is still starting after ${String(Math.round((upBy - startedAt) / 1000))}s and has not exited — waiting up to ${String(Math.round((hardBy - startedAt) / 1000))}s for it rather than taking its port (it would die on EADDRINUSE)`)',
512
+ ' }',
493
513
  ' }',
494
514
  ' await sleep(500)',
495
515
  ' }',
496
- ' note(`the replacement never came up on port ${port}${exited === null ? "" : ` (it exited with code ${exited})`} — see the output log beside this one`)',
516
+ ' note(`the replacement never came up on port ${port}${exited === null ? " (it is still running, so the recovery surface waits for it to exit before binding)" : ` (it exited with code ${exited})`} — see the output log beside this one`)',
497
517
  // The host is not coming back on its own. Everything the user can still
498
518
  // do about it lives in the recovery surface, so start it while the
499
519
  // browser tab that asked for the restart is still open.
@@ -232,6 +232,17 @@ export declare function readLockCommits(profile: string, explicitDir?: string):
232
232
  * by pnpm's `path:` selector, so the spec's subpath has to match too — and
233
233
  * when the spec names no subpath while several entries of that remote do,
234
234
  * there is no answer rather than the first sibling's commit (#632).
235
+ *
236
+ * The key on both sides of the comparison is `hostedRepoKey`, the identity
237
+ * the rest of the market already compares specs by (#851). This function
238
+ * used to derive its own, and that second spelling lost every install pnpm
239
+ * writes the shorthand back for: it demanded an `https?://` prefix or a
240
+ * `.git` suffix, so `github:owner/repo` keyed to nothing at all, and it kept
241
+ * the `.git` suffix, so the spec side `https://github.com/o/r` could never
242
+ * equal the lockfile side pnpm writes as `git@github.com:o/r.git`. A floating
243
+ * shorthand therefore had no commit, and with no commit the update route
244
+ * refuses to roll back — the market reports "cannot confirm the commit before
245
+ * the update" for a lockfile that states it plainly.
235
246
  */
236
247
  export declare function readGitResolutionCommit(profile: string, spec: string, explicitDir?: string): string | null;
237
248
  /** True when the installed package's manifest declares a dsh plugin surface. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dshmarket",
3
3
  "description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
4
- "version": "1.66.20",
4
+ "version": "1.66.21",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
package/src/profile.ts CHANGED
@@ -9,7 +9,7 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
9
9
  import { isDeepStrictEqual } from 'node:util'
10
10
  import { JSON_SCHEMA, load } from 'js-yaml'
11
11
  import { resolveDshHome } from './home-paths.ts'
12
- import { githubRemoteIdentities, githubRepoIdentities, isGitHostedSpec, repoOfTarget } from './sources.ts'
12
+ import { githubRemoteIdentities, githubRepoIdentities, hostedRepoKey, isGitHostedSpec, repoOfTarget } from './sources.ts'
13
13
 
14
14
  /**
15
15
  * Whether a profile name follows DSH's own directory-name contract.
@@ -727,13 +727,24 @@ export function readLockCommits(profile: string, explicitDir?: string): Map<stri
727
727
  * by pnpm's `path:` selector, so the spec's subpath has to match too — and
728
728
  * when the spec names no subpath while several entries of that remote do,
729
729
  * there is no answer rather than the first sibling's commit (#632).
730
+ *
731
+ * The key on both sides of the comparison is `hostedRepoKey`, the identity
732
+ * the rest of the market already compares specs by (#851). This function
733
+ * used to derive its own, and that second spelling lost every install pnpm
734
+ * writes the shorthand back for: it demanded an `https?://` prefix or a
735
+ * `.git` suffix, so `github:owner/repo` keyed to nothing at all, and it kept
736
+ * the `.git` suffix, so the spec side `https://github.com/o/r` could never
737
+ * equal the lockfile side pnpm writes as `git@github.com:o/r.git`. A floating
738
+ * shorthand therefore had no commit, and with no commit the update route
739
+ * refuses to roll back — the market reports "cannot confirm the commit before
740
+ * the update" for a lockfile that states it plainly.
730
741
  */
731
742
  export function readGitResolutionCommit(
732
743
  profile: string,
733
744
  spec: string,
734
745
  explicitDir?: string,
735
746
  ): string | null {
736
- const want = normalizeGitRepoKey(spec)
747
+ const want = hostedRepoKey(spec)
737
748
  if (want === null) return null
738
749
  const wantPath = gitSubpathSelector(spec)
739
750
  const matches: string[] = []
@@ -746,7 +757,7 @@ export function readGitResolutionCommit(
746
757
  const commit = /\bcommit:\s*([0-9a-f]{40})\b/i.exec(body)
747
758
  const repo = /\brepo:\s*([^\s,}]+)/.exec(body)
748
759
  if (commit === null || repo === null) continue
749
- if (normalizeGitRepoKey(repo[1]!) !== want) continue
760
+ if (hostedRepoKey(repo[1]!) !== want) continue
750
761
  const entryPath = /\bpath:\s*([^\s,}]+)/.exec(body)?.[1]?.replace(/^\/+|\/+$/g, '').toLowerCase() ?? null
751
762
  if (wantPath !== null) {
752
763
  if (entryPath === wantPath) return commit[1]!.toLowerCase()
@@ -770,20 +781,6 @@ function gitSubpathSelector(spec: string): string | null {
770
781
  return trimmed === '' ? null : trimmed
771
782
  }
772
783
 
773
- /** Lowercased transport-agnostic key for comparing two git remote spellings. */
774
- function normalizeGitRepoKey(spec: string): string | null {
775
- let remote = spec.trim().replace(/^git\+/i, '')
776
- const scp = /^git@([^:]+):(.+)$/.exec(remote)
777
- if (scp !== null) remote = `https://${scp[1]}/${scp[2]!.replace(/^\/*/, '')}`
778
- const hash = remote.indexOf('#')
779
- if (hash !== -1) remote = remote.slice(0, hash)
780
- const query = remote.indexOf('?')
781
- if (query !== -1) remote = remote.slice(0, query)
782
- remote = remote.replace(/\/+$/, '').toLowerCase()
783
- if (!/^https?:\/\//.test(remote) && !remote.includes('.git')) return null
784
- return remote
785
- }
786
-
787
784
  /** True when the installed package's manifest declares a dsh plugin surface. */
788
785
  export function hasDshManifest(dir: string): boolean {
789
786
  try {
@@ -904,30 +901,48 @@ export function parsePatchRows(text: string): { names: string[]; ids: string[];
904
901
  // and the market uninstalled the newcomer over a clash the host does
905
902
  // not have.
906
903
  let payloadIndent: number | null = null
907
- /** Index of the `- ` line that opens the row holding `from`. */
904
+ const isDash = (index: number): boolean => /^-(?:\s|$)/u.test(rows[index].trimmed)
905
+ /**
906
+ * Column where a line's key text starts: its indent plus any `- ` marker.
907
+ * A row's keys share this column whether written on the marker line or
908
+ * below it, however many spaces follow the `-`.
909
+ */
910
+ const keyCol = (index: number): number => {
911
+ // A bare `-` puts the row's keys on the lines below it.
912
+ if (rows[index].trimmed === '-') {
913
+ const next = rows[index + 1]
914
+ return next !== undefined && next.indent > rows[index].indent ? next.indent : rows[index].indent + 2
915
+ }
916
+ const dash = /^-\s+/u.exec(rows[index].trimmed)
917
+ return rows[index].indent + (dash === null ? 0 : dash[0].length)
918
+ }
919
+ /** Index of the `- ` line that opens the row holding `from`, or -1. */
908
920
  const rowStart = (from: number): number => {
909
- const isRowStart = (index: number): boolean =>
910
- /^-\s/u.test(rows[index].trimmed) || rows[index].trimmed === '-'
911
- if (isRowStart(from)) return from
912
- for (let j = from - 1; j >= 0; j--) {
913
- if (isRowStart(j)) return j
921
+ const col = keyCol(from)
922
+ for (let j = from; j >= 0; j--) {
923
+ if (isDash(j) && keyCol(j) === col) return j
924
+ // Shallower than the row's keys: left the row without finding its marker.
925
+ if (rows[j].indent < col) return -1
914
926
  }
915
- return 0
927
+ return -1
916
928
  }
917
929
  /**
918
930
  * Whether the insert row holding the `config:` at `from` declares
919
931
  * `group: true`. YAML gives a mapping's keys no order, so this reads the
920
- * whole row — its own `- ` marker plus the key lines at the marker's body
921
- * indentation — rather than only what happened to be written above.
932
+ * whole row — its own `- ` marker plus the key lines at the row's key
933
+ * column — rather than only what happened to be written above.
922
934
  */
923
935
  const rowDeclaresGroup = (from: number): boolean => {
924
936
  const start = rowStart(from)
937
+ if (start === -1) return false
925
938
  const markerIndent = rows[start].indent
939
+ const col = keyCol(start)
926
940
  for (let j = start; j < rows.length; j++) {
927
941
  const row = rows[j]
928
942
  if (j > start && row.indent <= markerIndent) break
929
- // The row's own keys; a config body sits deeper than these.
930
- if (j > start && row.indent !== markerIndent + 2) continue
943
+ // Only the row's own keys: a config body, or a list under another
944
+ // key, is not at the key column or starts with its own `- `.
945
+ if (j > start && (row.indent !== col || isDash(j))) continue
931
946
  if (/^\s*-?\s*group:\s*['"]?true['"]?\s*$/u.test(row.line)) return true
932
947
  }
933
948
  return false
@@ -951,14 +966,21 @@ export function parsePatchRows(text: string): { names: string[]; ids: string[];
951
966
  payloadIndent = null
952
967
  continue
953
968
  }
954
- // Exactly one `config:` shape carries loader rows: a `group: true` row
955
- // whose config is a LIST, because its children are entries. That is
956
- // the rule userPatchPackageReferences has always applied to this same
957
- // shape — a plain plugin may use a list of `{ name: … }` as ordinary
958
- // options, and those must stay payload. Anything else here is payload
959
- // too, so the block is skipped rather than counted.
960
- if (insertIndent !== null && indent > insertIndent && /^\s*-?\s*config:\s*$/u.test(line)) {
961
- if (!(rowDeclaresGroup(i) && valueIsList(i, indent))) payloadIndent = indent
969
+ // Exactly one block under a row carries loader rows: the `config:` of a
970
+ // `group: true` row whose config is a LIST, because its children are
971
+ // entries. That is the rule userPatchPackageReferences has always
972
+ // applied to this same shape — a plain plugin may use a list of
973
+ // `{ name: … }` as ordinary options, and those must stay payload. Any
974
+ // other block-valued key (`config:`, `tags:`, …) holds data too, so the
975
+ // block is skipped rather than counted.
976
+ // Inside a payload, a nested key is more payload: it must not move the
977
+ // payload's edge, or the payload ends early and its ids count.
978
+ const blockKey = /^\s*-?\s*([A-Za-z_][\w.-]*):\s*$/u.exec(line)
979
+ if (payloadIndent === null && insertIndent !== null && indent > insertIndent && blockKey !== null) {
980
+ // The key's own column, not the line's: when the key sits on a `- `
981
+ // line, the row's later keys share its column and are not payload.
982
+ const col = keyCol(i)
983
+ if (!(blockKey[1] === 'config' && rowDeclaresGroup(i) && valueIsList(i, col))) payloadIndent = col
962
984
  continue
963
985
  }
964
986
  const name = /^\s*-?\s*name:\s*['"]?([^'"\s]+)/.exec(line)
package/src/recovery.ts CHANGED
@@ -1088,6 +1088,16 @@ export async function runRecovery(
1088
1088
  const servePort = options.port ?? config.port
1089
1089
  /** Write errors from the previous apply; they travel into the next payload. */
1090
1090
  let writeErrors: string[] = []
1091
+ // A port that ANSWERS is proof the host came up, and it needs no pid to say
1092
+ // so (#854). The helper's pid is the process IT spawned, which on Windows is
1093
+ // the `powershell.exe` wrapper rather than the node host that binds — so the
1094
+ // guard below cannot be trusted on its own, and a helper that gave up late
1095
+ // handed this surface a pid that was already gone while the host was still
1096
+ // starting. Asking the socket settles it either way.
1097
+ if (servePort !== null && servePort !== 0 && await portListening(servePort)) {
1098
+ note(config, `port ${String(servePort)} is already answering — the host came up after all; leaving it alone`)
1099
+ return 'booted'
1100
+ }
1091
1101
  // The replacement is not necessarily dead just because it has not bound the
1092
1102
  // port: on a slow host it is still starting. It gets the same grace as a
1093
1103
  // released port before the surface takes the port out from under it (#719).
package/src/regions.ts CHANGED
@@ -211,6 +211,29 @@ export function resetGithubRoutePreferences(): void {
211
211
  preferredGithubRoutes.clear()
212
212
  }
213
213
 
214
+ /**
215
+ * Put a user's own prefix in front, and keep the region's other routes (#850).
216
+ *
217
+ * Heading rather than replacing, because a public proxy dying is the whole
218
+ * reason the setting exists — the reporter's was one of these mirrors, and it
219
+ * took the rest of the chain with it: `gh-proxy.com` stopped serving on
220
+ * 2026-10-13, and every GitHub-sourced update then failed on a timeout rather
221
+ * than trying the next candidate. Replacing the list buys one attempt per
222
+ * mirror outage; heading it buys one, and then the chain that was already
223
+ * measured to work.
224
+ *
225
+ * The proxy is also dropped from the tail if it is already there, so a user
226
+ * who accepted the value the settings UI pre-suggests gets exactly the
227
+ * region's own table back rather than the same prefix twice.
228
+ *
229
+ * The direct escape is never dropped: `githubRoutes` promises every list ends
230
+ * in one, and dropping it would turn a dead mirror into an outage instead of
231
+ * a slow request.
232
+ */
233
+ function withCustomProxy(proxy: string, base: GithubRoute[]): GithubRoute[] {
234
+ return [proxy, ...base.filter(route => route !== proxy)]
235
+ }
236
+
214
237
  /**
215
238
  * The routes for a region, with environment overrides applied.
216
239
  *
@@ -234,7 +257,11 @@ export function routesFor(region: Region, env: NodeJS.ProcessEnv = process.env):
234
257
  raw: [...base.githubRoutes.raw],
235
258
  avatar: [...base.githubRoutes.avatar],
236
259
  }
237
- : { git: [githubProxy, null], raw: [githubProxy, null], avatar: [githubProxy, null] }
260
+ : {
261
+ git: withCustomProxy(githubProxy, base.githubRoutes.git),
262
+ raw: withCustomProxy(githubProxy, base.githubRoutes.raw),
263
+ avatar: withCustomProxy(githubProxy, base.githubRoutes.avatar),
264
+ }
238
265
  return {
239
266
  npmRegistry: registry,
240
267
  githubProxy: githubProxy ?? base.githubProxy,
package/src/restart.ts CHANGED
@@ -516,19 +516,39 @@ export function restartHelperSource(
516
516
  // seen from the other side. A replacement that EXITS still ends this
517
517
  // early (the `exited` break below), so a genuinely failed boot is not
518
518
  // made slower by the wider backstop.
519
+ ' const startedAt = Date.now()',
519
520
  ' const upBy = Date.now() + 45000 + SETTLE_MS',
521
+ // #854: the window is a budget for "has it come up", NOT a verdict on
522
+ // "is it dead". A cold start right after the market rewrote node_modules
523
+ // can outlast it (measured on Windows: bind at ~63s with a scanner on the
524
+ // fresh files), and handing the port over while the replacement is still
525
+ // RUNNING is what turns a slow boot into the deadlock #719 removed: the
526
+ // surface takes port 3080, the host reaches its own bind and dies on
527
+ // EADDRINUSE, and nothing can come back. So a replacement that has NOT
528
+ // exited keeps the port, up to a hard cap; one that has exited still ends
529
+ // the wait immediately, which is the failed-boot case.
530
+ ' const hardBy = Date.now() + 240000',
520
531
  ' let steadySince = null',
521
- ' while (Date.now() < upBy) {',
532
+ ' let announcedWait = false',
533
+ ' while (true) {',
522
534
  ' if (await listening()) {',
523
535
  ' if (steadySince === null) steadySince = Date.now()',
524
536
  ' else if (Date.now() - steadySince >= SETTLE_MS) return',
525
537
  ' } else {',
526
538
  ' steadySince = null',
527
539
  ' if (exited !== null) break',
540
+ ' const now = Date.now()',
541
+ ' if (now >= hardBy) break',
542
+ ' if (now >= upBy && !announcedWait) {',
543
+ ' announcedWait = true',
544
+ // The numbers are computed, not typed twice: a log line that drifts from
545
+ // the constants it describes is worse than no line at all.
546
+ ' note(`the replacement is still starting after ${String(Math.round((upBy - startedAt) / 1000))}s and has not exited — waiting up to ${String(Math.round((hardBy - startedAt) / 1000))}s for it rather than taking its port (it would die on EADDRINUSE)`)',
547
+ ' }',
528
548
  ' }',
529
549
  ' await sleep(500)',
530
550
  ' }',
531
- ' note(`the replacement never came up on port ${port}${exited === null ? "" : ` (it exited with code ${exited})`} — see the output log beside this one`)',
551
+ ' note(`the replacement never came up on port ${port}${exited === null ? " (it is still running, so the recovery surface waits for it to exit before binding)" : ` (it exited with code ${exited})`} — see the output log beside this one`)',
532
552
  // The host is not coming back on its own. Everything the user can still
533
553
  // do about it lives in the recovery surface, so start it while the
534
554
  // browser tab that asked for the restart is still open.