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 +61 -40
- package/lib/recovery.js +10 -0
- package/lib/regions.js +27 -1
- package/lib/restart.js +22 -2
- package/lib/types/profile.d.ts +11 -0
- package/package.json +1 -1
- package/src/profile.ts +58 -36
- package/src/recovery.ts +10 -0
- package/src/regions.ts +28 -1
- package/src/restart.ts +22 -2
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 =
|
|
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 (
|
|
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
|
-
|
|
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
|
|
865
|
-
|
|
866
|
-
|
|
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
|
|
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
|
|
877
|
-
*
|
|
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
|
-
//
|
|
887
|
-
|
|
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
|
|
916
|
-
// whose config is a LIST, because its children are
|
|
917
|
-
// the rule userPatchPackageReferences has always
|
|
918
|
-
// shape — a plain plugin may use a list of
|
|
919
|
-
// options, and those must stay payload.
|
|
920
|
-
//
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
: {
|
|
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
|
-
'
|
|
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.
|
package/lib/types/profile.d.ts
CHANGED
|
@@ -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.
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
if (
|
|
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
|
|
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
|
|
921
|
-
*
|
|
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
|
-
//
|
|
930
|
-
|
|
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
|
|
955
|
-
// whose config is a LIST, because its children are
|
|
956
|
-
// the rule userPatchPackageReferences has always
|
|
957
|
-
// shape — a plain plugin may use a list of
|
|
958
|
-
// options, and those must stay payload.
|
|
959
|
-
//
|
|
960
|
-
|
|
961
|
-
|
|
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
|
-
: {
|
|
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
|
-
'
|
|
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.
|