@linxin666/dsh-remote-web-ui 0.1.15 → 0.1.17
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/README.i18n.yaml +2 -2
- package/README.md +9 -5
- package/README.zh.md +1 -1
- package/assets/apple-touch-icon.png +0 -0
- package/lib/client.js +58 -10
- package/lib/client.js.map +1 -1
- package/lib/index.js +183 -22
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +3 -1
- package/lib/types/client/UpdatePanel.js +2 -0
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +4 -0
- package/lib/types/client/settings-form.d.ts +62 -0
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +61 -12
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +36 -10
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +19 -2
- package/lib/types/mobile-routes.d.ts.map +1 -1
- package/lib/types/mobile-routes.js +32 -0
- package/lib/types/update.d.ts +46 -4
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +134 -8
- package/package.json +2 -1
- package/src/client/PluginSettingsCard.tsx +7 -1
- package/src/client/UpdatePanel.tsx +2 -0
- package/src/client/locales.ts +4 -0
- package/src/client/settings-card.module.css +3 -0
- package/src/client/settings-form.ts +114 -12
- package/src/index.ts +34 -10
- package/src/mobile-api.ts +19 -2
- package/src/mobile-routes.ts +33 -0
- package/src/update.ts +145 -9
|
@@ -40,6 +40,15 @@ export function textField(field) {
|
|
|
40
40
|
},
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* A free-text field the Host treats as a secret and redacts from the read-back
|
|
45
|
+
* (role('secret') in the section schema). The card still edits it like text,
|
|
46
|
+
* but a save never compares the redacted value back and relies on the scope
|
|
47
|
+
* reporting the write landed.
|
|
48
|
+
*/
|
|
49
|
+
export function secretField(field) {
|
|
50
|
+
return { ...textField(field), secret: true };
|
|
51
|
+
}
|
|
43
52
|
/** A boolean field, edited through true/false draft text. */
|
|
44
53
|
export function booleanField(field) {
|
|
45
54
|
return {
|
|
@@ -84,6 +93,7 @@ export class CardForm {
|
|
|
84
93
|
listeners = new Set();
|
|
85
94
|
saving = false;
|
|
86
95
|
failed = false;
|
|
96
|
+
failedReason;
|
|
87
97
|
/** @param scope - the bound settings scope for this card's namespace. */
|
|
88
98
|
constructor(scope, specs) {
|
|
89
99
|
this.scope = scope;
|
|
@@ -108,6 +118,7 @@ export class CardForm {
|
|
|
108
118
|
invalid: plan.some(item => item.run === undefined),
|
|
109
119
|
saving: this.saving,
|
|
110
120
|
failed: this.failed,
|
|
121
|
+
...this.failedReason === undefined ? {} : { failedReason: this.failedReason },
|
|
111
122
|
};
|
|
112
123
|
}
|
|
113
124
|
/** Read one field's state from the effective section and its staged draft. */
|
|
@@ -137,37 +148,68 @@ export class CardForm {
|
|
|
137
148
|
return;
|
|
138
149
|
this.staged.clear();
|
|
139
150
|
this.failed = false;
|
|
151
|
+
this.failedReason = undefined;
|
|
140
152
|
this.publish();
|
|
141
153
|
},
|
|
142
154
|
};
|
|
143
155
|
}
|
|
144
156
|
/**
|
|
145
157
|
* Write every staged edit, then re-seed from what the Host accepted.
|
|
158
|
+
*
|
|
159
|
+
* When the scope carries the optional batch surface (the dsh-web-ui
|
|
160
|
+
* bridge scope), every planned write rides one mutation so cross-field
|
|
161
|
+
* validate hooks (baseURL+model) judge the batch as a unit instead of
|
|
162
|
+
* deadlocking on per-field writes. Otherwise the per-field loop runs.
|
|
163
|
+
* A field lands only when the Host reports it held the staged value; a
|
|
164
|
+
* landed field's draft is dropped, a failed one stays staged for the user.
|
|
146
165
|
* @returns settlement after every write and the read-back.
|
|
147
166
|
*/
|
|
148
167
|
async save() {
|
|
149
168
|
const plan = this.plan();
|
|
150
|
-
const
|
|
151
|
-
if (plan.length === 0 || this.saving ||
|
|
169
|
+
const valid = plan.filter(item => item.run !== undefined);
|
|
170
|
+
if (plan.length === 0 || this.saving || valid.length !== plan.length)
|
|
152
171
|
return;
|
|
172
|
+
const plannedWrites = valid.map(item => item.op);
|
|
153
173
|
// Snapshot the fields this save writes, so edits staged while it is in
|
|
154
174
|
// flight survive: only the staged keys this save actually wrote are cleared.
|
|
155
175
|
const fields = new Set(plan.map(item => item.field));
|
|
156
176
|
this.saving = true;
|
|
157
177
|
this.failed = false;
|
|
178
|
+
this.failedReason = undefined;
|
|
158
179
|
this.publish();
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
180
|
+
const landed = new Set();
|
|
181
|
+
const batch = this.batchedScope();
|
|
182
|
+
if (batch !== undefined) {
|
|
183
|
+
const result = await batch.mutate(plannedWrites);
|
|
184
|
+
if (result.ok) {
|
|
185
|
+
for (const field of result.fields) {
|
|
186
|
+
if (field.landed)
|
|
187
|
+
landed.add(field.field);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
this.failedReason = result.message;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
for (const item of valid) {
|
|
196
|
+
if (await item.run())
|
|
197
|
+
landed.add(item.field);
|
|
198
|
+
}
|
|
162
199
|
}
|
|
163
|
-
|
|
164
|
-
|
|
200
|
+
for (const field of fields) {
|
|
201
|
+
if (landed.has(field))
|
|
165
202
|
this.staged.delete(field);
|
|
166
203
|
}
|
|
167
204
|
this.saving = false;
|
|
168
|
-
this.failed =
|
|
205
|
+
this.failed = landed.size !== fields.size;
|
|
169
206
|
this.publish();
|
|
170
207
|
}
|
|
208
|
+
/** The scope's batch surface when it supports one; undefined conservatively otherwise. */
|
|
209
|
+
batchedScope() {
|
|
210
|
+
const candidate = this.scope;
|
|
211
|
+
return typeof candidate?.mutate === 'function' ? candidate : undefined;
|
|
212
|
+
}
|
|
171
213
|
/**
|
|
172
214
|
* Every staged edit a save would write. An entry whose draft is not a value
|
|
173
215
|
* its field accepts carries no write: the form is still dirty, and the save
|
|
@@ -181,18 +223,18 @@ export class CardForm {
|
|
|
181
223
|
const spec = this.specOf(field);
|
|
182
224
|
if (staged.clear) {
|
|
183
225
|
if (this.stored(field))
|
|
184
|
-
plan.push({ field, run: () => this.clear(field) });
|
|
226
|
+
plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) });
|
|
185
227
|
continue;
|
|
186
228
|
}
|
|
187
229
|
if (staged.text === spec.format(this.sectionValue(field)))
|
|
188
230
|
continue;
|
|
189
231
|
const write = spec.parse(staged.text);
|
|
190
232
|
if (write === undefined)
|
|
191
|
-
plan.push({ field, run: undefined });
|
|
233
|
+
plan.push({ field, op: { field, op: 'unset' }, run: undefined });
|
|
192
234
|
else if (write.kind === 'clear')
|
|
193
|
-
plan.push({ field, run: () => this.clear(field) });
|
|
235
|
+
plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) });
|
|
194
236
|
else
|
|
195
|
-
plan.push({ field, run: () => this.store(field, write.value) });
|
|
237
|
+
plan.push({ field, op: { field, op: 'set', value: write.value }, run: () => this.store(field, write.value) });
|
|
196
238
|
}
|
|
197
239
|
return plan;
|
|
198
240
|
}
|
|
@@ -202,11 +244,18 @@ export class CardForm {
|
|
|
202
244
|
}
|
|
203
245
|
async store(field, value) {
|
|
204
246
|
await this.scope.set(field, value);
|
|
247
|
+
// A redacted secret never appears in the user layer read-back; judging it
|
|
248
|
+
// by value would misreport a successful secret save as failed. The bridge
|
|
249
|
+
// reports secret writes through its secret-set markers (batch path); on
|
|
250
|
+
// the per-field path the scope resolved, so the write is landed.
|
|
251
|
+
if (this.specOf(field).secret)
|
|
252
|
+
return true;
|
|
205
253
|
return this.userLayer()?.[field] === value;
|
|
206
254
|
}
|
|
207
255
|
stage(field, edit) {
|
|
208
256
|
this.staged.set(field, edit);
|
|
209
257
|
this.failed = false;
|
|
258
|
+
this.failedReason = undefined;
|
|
210
259
|
this.publish();
|
|
211
260
|
}
|
|
212
261
|
specOf(field) {
|
package/lib/types/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAChD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAElD,OAAO,CAAC,MAAM,aAAa,CAAA;AAmB3B,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,MAAM;QACd;;;;;WAKG;QACH,UAAU,CACR,IAAI,EAAE,OAAO,EACb,OAAO,EAAE,eAAe,EACxB,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,IAAI,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,GACrC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;KAC9B;CACF;AAED,iCAAiC;AACjC,eAAO,MAAM,IAAI,kBAAkB,CAAA;AAEnC,+DAA+D;AAC/D,eAAO,MAAM,MAAM,UAA4B,CAAA;AAE/C;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,uDAAqC,CAAA;AAElF,qEAAqE;AACrE,MAAM,WAAW,MAAM;IACrB,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,yEAAyE;IACzE,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,iDAAiD;IACjD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAA;IAC9B;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,2EAA2E;IAC3E,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,eAAO,MAAM,MAAM,EAAE,CAAC,CAAC,MAAM,CAU3B,CAAA;AAyBF;;;;GAIG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAChD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAElD,OAAO,CAAC,MAAM,aAAa,CAAA;AAmB3B,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,MAAM;QACd;;;;;WAKG;QACH,UAAU,CACR,IAAI,EAAE,OAAO,EACb,OAAO,EAAE,eAAe,EACxB,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,IAAI,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,GACrC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;KAC9B;CACF;AAED,iCAAiC;AACjC,eAAO,MAAM,IAAI,kBAAkB,CAAA;AAEnC,+DAA+D;AAC/D,eAAO,MAAM,MAAM,UAA4B,CAAA;AAE/C;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,uDAAqC,CAAA;AAElF,qEAAqE;AACrE,MAAM,WAAW,MAAM;IACrB,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,yEAAyE;IACzE,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,iDAAiD;IACjD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAA;IAC9B;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,2EAA2E;IAC3E,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,eAAO,MAAM,MAAM,EAAE,CAAC,CAAC,MAAM,CAU3B,CAAA;AAyBF;;;;GAIG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAkOzD"}
|
package/lib/types/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { makeMobileRoutes } from "./mobile-routes.js";
|
|
|
18
18
|
import { makeMobileApiRoutes } from "./mobile-api.js";
|
|
19
19
|
import { lanIPv4Addresses } from "./lan.js";
|
|
20
20
|
import { TunnelManager } from "./tunnel.js";
|
|
21
|
-
import { checkUpdates, fetchLatestVersion, resolveAnchorManifest, resolveUpdateTarget,
|
|
21
|
+
import { checkUpdates, fetchLatestVersion, resolveAnchorManifest, resolveUpdateTarget, runUpdateVerified, } from "./update.js";
|
|
22
22
|
import { makeUpdateRoutes } from "./update-routes.js";
|
|
23
23
|
/** Stable cordis plugin name. */
|
|
24
24
|
export const name = 'remote-web-ui';
|
|
@@ -152,19 +152,27 @@ export function apply(ctx, config) {
|
|
|
152
152
|
}
|
|
153
153
|
// ── remote update ────────────────────────────────────────────────────────
|
|
154
154
|
// The dsh-web-ui self-update surface: probe the npm registry for family
|
|
155
|
-
// releases and run `pnpm update` in the owning profile. Resolutions
|
|
156
|
-
// on the host process's own module graph, so the update always
|
|
157
|
-
// profile the running web GUI was booted from. The
|
|
158
|
-
//
|
|
159
|
-
//
|
|
155
|
+
// releases and run `pnpm update --latest` in the owning profile. Resolutions
|
|
156
|
+
// anchor on the host process's own module graph, so the update always
|
|
157
|
+
// targets the profile the running web GUI was booted from. The anchor path
|
|
158
|
+
// is re-resolved per operation: pnpm removes the old version's .pnpm
|
|
159
|
+
// directory on update, so a boot-time captured path would fail to read
|
|
160
|
+
// after a successful update; versions are re-read from disk per check.
|
|
160
161
|
const requireFromHost = createRequire(import.meta.url);
|
|
161
|
-
const
|
|
162
|
+
const resolveAnchorPath = () => resolveAnchorManifest(specifier => {
|
|
163
|
+
try {
|
|
164
|
+
return requireFromHost.resolve(specifier);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
162
170
|
const updateRoutes = makeUpdateRoutes({
|
|
163
171
|
// Control endpoints are host-surface only: a LAN/phone origin must never
|
|
164
172
|
// trigger a real install on this machine.
|
|
165
173
|
fence: request => isTrustedApiRequest(request, []),
|
|
166
174
|
check: () => checkUpdates({
|
|
167
|
-
anchorManifestPath,
|
|
175
|
+
anchorManifestPath: resolveAnchorPath(),
|
|
168
176
|
resolve: specifier => {
|
|
169
177
|
try {
|
|
170
178
|
return requireFromHost.resolve(specifier);
|
|
@@ -176,7 +184,7 @@ export function apply(ctx, config) {
|
|
|
176
184
|
fetchLatest: name => fetchLatestVersion(name, fetch),
|
|
177
185
|
}),
|
|
178
186
|
run: async () => {
|
|
179
|
-
const target = resolveUpdateTarget({ anchorManifestPath });
|
|
187
|
+
const target = resolveUpdateTarget({ anchorManifestPath: resolveAnchorPath() });
|
|
180
188
|
if ('error' in target) {
|
|
181
189
|
const code = target.error;
|
|
182
190
|
return {
|
|
@@ -187,7 +195,25 @@ export function apply(ctx, config) {
|
|
|
187
195
|
errorCode: code,
|
|
188
196
|
};
|
|
189
197
|
}
|
|
190
|
-
|
|
198
|
+
// Verify the versions actually moved after a green pnpm exit: the pnpm
|
|
199
|
+
// 11 minimumReleaseAge gate can silently keep the installed versions
|
|
200
|
+
// (same-day releases), which a plain exit-0 check would report as
|
|
201
|
+
// success — the user then restarts and nothing changed.
|
|
202
|
+
return runUpdateVerified({
|
|
203
|
+
run: { profileDir: target.profileDir, packages: target.packages },
|
|
204
|
+
check: {
|
|
205
|
+
anchorManifestPath: resolveAnchorPath(),
|
|
206
|
+
resolve: specifier => {
|
|
207
|
+
try {
|
|
208
|
+
return requireFromHost.resolve(specifier);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
fetchLatest: name => fetchLatestVersion(name, fetch),
|
|
215
|
+
},
|
|
216
|
+
});
|
|
191
217
|
},
|
|
192
218
|
});
|
|
193
219
|
const routes = [
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mobile-api.d.ts","sourceRoot":"","sources":["../../src/mobile-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAA;AAG9D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAgDlD,iCAAiC;AACjC,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,OAAO,EAAE,cAAc,CAAA;IACvB,0DAA0D;IAC1D,QAAQ,EAAE,QAAQ,CAAA;IAClB,kEAAkE;IAClE,iBAAiB,EAAE,MAAM,OAAO,CAAA;IAChC,oFAAoF;IACpF,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B;AAED,8BAA8B;AAC9B,eAAO,MAAM,gBAAgB;;CAEnB,CAAA;AAOV;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,
|
|
1
|
+
{"version":3,"file":"mobile-api.d.ts","sourceRoot":"","sources":["../../src/mobile-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAA;AAG9D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAgDlD,iCAAiC;AACjC,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,OAAO,EAAE,cAAc,CAAA;IACvB,0DAA0D;IAC1D,QAAQ,EAAE,QAAQ,CAAA;IAClB,kEAAkE;IAClE,iBAAiB,EAAE,MAAM,OAAO,CAAA;IAChC,oFAAoF;IACpF,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B;AAED,8BAA8B;AAC9B,eAAO,MAAM,gBAAgB;;CAEnB,CAAA;AAOV;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,CA6InE"}
|
package/lib/types/mobile-api.js
CHANGED
|
@@ -77,10 +77,23 @@ const MOBILE_API_METHOD_PREFIX = `${MOBILE_API_PREFIX}/`;
|
|
|
77
77
|
export function makeMobileApiRoutes(deps) {
|
|
78
78
|
const { service, apiProxy, mobileEnterToSend } = deps;
|
|
79
79
|
const eventsHeartbeatMs = deps.eventsHeartbeatMs ?? DEFAULT_EVENTS_HEARTBEAT_MS;
|
|
80
|
+
/**
|
|
81
|
+
* Refresh the paired device's presence and report whether it is live.
|
|
82
|
+
* The mobile surface (unlike the desktop Web UI) has no `/api/pair/heartbeat`
|
|
83
|
+
* sender, so any activity on the mobile channel — a gated RPC, or the live
|
|
84
|
+
* SSE stream staying open — must count as presence. Without this, an
|
|
85
|
+
* idle-but-connected phone ages past `offlineAfterMs` and the desktop panel
|
|
86
|
+
* wrongly reports it as disconnected.
|
|
87
|
+
*/
|
|
88
|
+
const touchDeviceFor = (req) => {
|
|
89
|
+
const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
|
|
90
|
+
if (deviceId === undefined)
|
|
91
|
+
return false;
|
|
92
|
+
return service.touchDevice(deviceId);
|
|
93
|
+
};
|
|
80
94
|
/** The phone gate: a live paired-device cookie, or nothing else proceeds. */
|
|
81
95
|
const gateOk = (req) => {
|
|
82
|
-
|
|
83
|
-
return deviceId !== undefined && service.hasDevice(deviceId);
|
|
96
|
+
return touchDeviceFor(req);
|
|
84
97
|
};
|
|
85
98
|
const writeJson = (res, status, body) => {
|
|
86
99
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
@@ -164,6 +177,10 @@ export function makeMobileApiRoutes(deps) {
|
|
|
164
177
|
const heartbeat = setInterval(() => {
|
|
165
178
|
if (closed)
|
|
166
179
|
return;
|
|
180
|
+
// An open SSE stream proves the phone is still live even while the agent
|
|
181
|
+
// idles (no RPC traffic), so refresh presence alongside the transport
|
|
182
|
+
// keepalive — otherwise an idle phone drifts to "disconnected".
|
|
183
|
+
touchDeviceFor(req);
|
|
167
184
|
try {
|
|
168
185
|
res.write(': ping\n\n');
|
|
169
186
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mobile-routes.d.ts","sourceRoot":"","sources":["../../src/mobile-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAMH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;
|
|
1
|
+
{"version":3,"file":"mobile-routes.d.ts","sourceRoot":"","sources":["../../src/mobile-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAMH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAwD/D;;;GAGG;AACH,wBAAgB,gBAAgB,IAAI,QAAQ,EAAE,CAmC7C"}
|
|
@@ -14,6 +14,10 @@ import { fileURLToPath } from 'node:url';
|
|
|
14
14
|
function mobileBundlePath() {
|
|
15
15
|
return fileURLToPath(new URL('../lib/mobile.js', import.meta.url));
|
|
16
16
|
}
|
|
17
|
+
/** The apple-touch-icon shipped with the package (iOS home-screen icon, 180x180 PNG). */
|
|
18
|
+
function appleTouchIconPath() {
|
|
19
|
+
return fileURLToPath(new URL('../assets/apple-touch-icon.png', import.meta.url));
|
|
20
|
+
}
|
|
17
21
|
/** The mobile page shell: minimal, offline-safe, no external assets. */
|
|
18
22
|
function pageHtml(bundleUrl) {
|
|
19
23
|
return [
|
|
@@ -24,6 +28,10 @@ function pageHtml(bundleUrl) {
|
|
|
24
28
|
'<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">',
|
|
25
29
|
'<meta name="theme-color" content="#f3f5f9">',
|
|
26
30
|
'<meta name="referrer" content="no-referrer">',
|
|
31
|
+
// iOS "Add to Home Screen" uses apple-touch-icon for the tile icon;
|
|
32
|
+
// the PNG ships with the package (opaque background — iOS composites
|
|
33
|
+
// transparency onto black, so the icon avoids transparent regions).
|
|
34
|
+
'<link rel="apple-touch-icon" href="/m/apple-touch-icon.png">',
|
|
27
35
|
'<title>移动端远程控制</title>',
|
|
28
36
|
'</head>',
|
|
29
37
|
'<body>',
|
|
@@ -42,6 +50,15 @@ function writeStatic(res, status, type, body) {
|
|
|
42
50
|
});
|
|
43
51
|
res.end(body);
|
|
44
52
|
}
|
|
53
|
+
/** Send the PNG icon as a binary body (unlike text assets, the PNG must not be decoded to utf-8). */
|
|
54
|
+
function writePng(res, status, body) {
|
|
55
|
+
res.writeHead(status, {
|
|
56
|
+
'content-type': 'image/png',
|
|
57
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
58
|
+
'referrer-policy': 'no-referrer',
|
|
59
|
+
});
|
|
60
|
+
res.end(body);
|
|
61
|
+
}
|
|
45
62
|
/**
|
|
46
63
|
* Build the mobile page routes.
|
|
47
64
|
* @returns the two exact routes to register on webServer.
|
|
@@ -64,8 +81,23 @@ export function makeMobileRoutes() {
|
|
|
64
81
|
writeStatic(res, 500, 'text/plain', 'failed to read the mobile bundle');
|
|
65
82
|
}
|
|
66
83
|
};
|
|
84
|
+
const handleIcon = async (_req, res) => {
|
|
85
|
+
const path = appleTouchIconPath();
|
|
86
|
+
if (!existsSync(path)) {
|
|
87
|
+
writeStatic(res, 404, 'text/plain', 'apple-touch-icon not found');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const body = await readFile(path);
|
|
92
|
+
writePng(res, 200, body);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
writeStatic(res, 500, 'text/plain', 'failed to read the apple-touch-icon');
|
|
96
|
+
}
|
|
97
|
+
};
|
|
67
98
|
return [
|
|
68
99
|
{ kind: 'exact', path: '/m', handler: handlePage },
|
|
69
100
|
{ kind: 'exact', path: '/m/mobile.js', handler: handleBundle },
|
|
101
|
+
{ kind: 'exact', path: '/m/apple-touch-icon.png', handler: handleIcon },
|
|
70
102
|
];
|
|
71
103
|
}
|
package/lib/types/update.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Remote update support for the dsh-web-ui family — host half. Detects the
|
|
3
3
|
* installed aggregate package (@linxin666/dsh-web-ui-all) and its family
|
|
4
4
|
* children, probes the npm registry for newer releases, and runs the actual
|
|
5
|
-
* update as `pnpm update` inside the owning dsh profile directory.
|
|
5
|
+
* update as `pnpm update --latest` inside the owning dsh profile directory.
|
|
6
6
|
*
|
|
7
7
|
* Pure logic with injected seams (manifest reading, registry fetches, process
|
|
8
8
|
* spawning) so the whole surface is unit-testable without touching disk,
|
|
@@ -100,11 +100,13 @@ export interface UpdateCheckDeps {
|
|
|
100
100
|
}
|
|
101
101
|
/**
|
|
102
102
|
* Resolve the anchor package's manifest path. The aggregate package is the
|
|
103
|
-
* canonical entry point; this plugin's own package is the fallback.
|
|
103
|
+
* canonical entry point; this plugin's own package is the fallback. Both a
|
|
104
|
+
* throwing resolve and an undefined return mean "not installed" and move on
|
|
105
|
+
* to the next candidate.
|
|
104
106
|
* @param resolve - a Node resolve implementation scoped to the host process.
|
|
105
107
|
* @returns the absolute manifest path, or undefined when neither is installed.
|
|
106
108
|
*/
|
|
107
|
-
export declare function resolveAnchorManifest(resolve: (specifier: string) => string): string | undefined;
|
|
109
|
+
export declare function resolveAnchorManifest(resolve: (specifier: string) => string | undefined): string | undefined;
|
|
108
110
|
/** The resolved update target: the profile pnpm runs in plus the package list. */
|
|
109
111
|
export interface UpdateTarget {
|
|
110
112
|
/** Owning profile name. */
|
|
@@ -157,7 +159,11 @@ export type UpdateErrorCode =
|
|
|
157
159
|
/** The anchor is a local link/dev install pnpm cannot update. */
|
|
158
160
|
| 'link'
|
|
159
161
|
/** pnpm exited non-zero. */
|
|
160
|
-
| 'pnpm-failed'
|
|
162
|
+
| 'pnpm-failed'
|
|
163
|
+
/** pnpm exited 0 but the installed versions did not move. */
|
|
164
|
+
| 'stale'
|
|
165
|
+
/** pnpm exited 0 but the post-run version check could not verify the install. */
|
|
166
|
+
| 'verify-failed';
|
|
161
167
|
/** Result of one update run. */
|
|
162
168
|
export interface UpdateRunResult {
|
|
163
169
|
ok: boolean;
|
|
@@ -191,5 +197,41 @@ export interface UpdateRunDeps {
|
|
|
191
197
|
* @returns the outcome with captured output.
|
|
192
198
|
*/
|
|
193
199
|
export declare function runUpdate(deps: UpdateRunDeps): Promise<UpdateRunResult>;
|
|
200
|
+
/** Seam set for the verified update run (run + post-run status check). */
|
|
201
|
+
export interface UpdateRunVerifiedDeps {
|
|
202
|
+
/** The pnpm run (profile dir + package list). */
|
|
203
|
+
run: UpdateRunDeps;
|
|
204
|
+
/** The post-run status check (same seams as checkUpdates). */
|
|
205
|
+
check: UpdateCheckDeps;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Run the update, then verify the installed versions actually moved. pnpm
|
|
209
|
+
* exits 0 even when it silently kept the installed versions — runUpdate
|
|
210
|
+
* overrides pnpm 11's `minimumReleaseAge` gate (default 24 h) with
|
|
211
|
+
* `--config.minimumReleaseAge=0`, but an older pnpm without the override or
|
|
212
|
+
* another silent no-op can still keep versions in place — so a green exit
|
|
213
|
+
* alone must not report a misleading "update complete". Re-read the
|
|
214
|
+
* installed versions afterwards and surface a `stale` failure (with the
|
|
215
|
+
* captured pnpm output) when nothing moved, so the panel can tell the user
|
|
216
|
+
* how to unblock the gate instead of claiming success.
|
|
217
|
+
*
|
|
218
|
+
* The stale decision anchors on the pre-run installed versions, not on the
|
|
219
|
+
* registry latest: under a lenient gate pnpm may move 0.1.12 -> 0.1.13 while
|
|
220
|
+
* latest stays 0.1.15 — that is a real update, not stale. The post-run anchor
|
|
221
|
+
* path is re-resolved rather than reused from boot time: pnpm removes the old
|
|
222
|
+
* version's .pnpm directory on update, so a boot-time captured path would
|
|
223
|
+
* fail to read and collapse a successful update into a bogus 'missing'.
|
|
224
|
+
*
|
|
225
|
+
* A green exit whose verification has nothing to compare (registry probe
|
|
226
|
+
* outage, missing anchor, non-npm mode) is reported as `verify-failed` — it
|
|
227
|
+
* must never read as success. A green exit where no package moved but the
|
|
228
|
+
* post-run check could not prove the install is fully current (a partial
|
|
229
|
+
* probe failure hides whether a gate kept something back) is also
|
|
230
|
+
* `verify-failed`: it must not collapse into a success either.
|
|
231
|
+
* @param deps - the run and check seams.
|
|
232
|
+
* @returns the run result; `stale` when exit 0 left every version in place,
|
|
233
|
+
* `verify-failed` when the post-run check could not verify anything.
|
|
234
|
+
*/
|
|
235
|
+
export declare function runUpdateVerified(deps: UpdateRunVerifiedDeps): Promise<UpdateRunResult>;
|
|
194
236
|
export {};
|
|
195
237
|
//# sourceMappingURL=update.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../src/update.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAA;AAE1C,iDAAiD;AACjD,eAAO,MAAM,YAAY,+BAA+B,CAAA;AAExD,oEAAoE;AACpE,eAAO,MAAM,YAAY,gBAAgB,CAAA;AAEzC,sEAAsE;AACtE,eAAO,MAAM,iBAAiB,8BAA8B,CAAA;AAE5D,+EAA+E;AAC/E,eAAO,MAAM,YAAY,iCAAiC,CAAA;AAQ1D,0EAA0E;AAC1E,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,2DAA2D;IAC3D,UAAU,EAAE,MAAM,EAAE,CAAA;CACrB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS,CASlE;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CA2B5D;AAYD,yDAAyD;AACzD,MAAM,WAAW,YAAY;IAC3B,iCAAiC;IACjC,IAAI,EAAE,MAAM,CAAA;IACZ,kCAAkC;IAClC,GAAG,EAAE,MAAM,CAAA;CACZ;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,kBAAkB,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAahF;AAED,6DAA6D;AAC7D,KAAK,cAAc,GAAG,MAAM,GAAG;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAAA;AAE9D,uEAAuE;AACvE,wBAAgB,YAAY,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAG1D;AAED,kDAAkD;AAClD,MAAM,WAAW,mBAAmB;IAClC,oBAAoB;IACpB,IAAI,EAAE,MAAM,CAAA;IACZ,iCAAiC;IACjC,OAAO,EAAE,MAAM,CAAA;IACf,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,oDAAoD;IACpD,QAAQ,EAAE,OAAO,CAAA;CAClB;AAED,kEAAkE;AAClE,MAAM,WAAW,YAAY;IAC3B,iGAAiG;IACjG,IAAI,EAAE,KAAK,GAAG,MAAM,GAAG,SAAS,CAAA;IAChC,sCAAsC;IACtC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,6CAA6C;IAC7C,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,qDAAqD;IACrD,QAAQ,EAAE,mBAAmB,EAAE,CAAA;IAC/B,qDAAqD;IACrD,QAAQ,EAAE,OAAO,CAAA;IACjB,uDAAuD;IACvD,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,6EAA6E;AAC7E,MAAM,WAAW,eAAe;IAC9B,qEAAqE;IACrE,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,4EAA4E;IAC5E,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;IAC9C,oEAAoE;IACpE,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAA;CACvD;AAED
|
|
1
|
+
{"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../src/update.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAA;AAE1C,iDAAiD;AACjD,eAAO,MAAM,YAAY,+BAA+B,CAAA;AAExD,oEAAoE;AACpE,eAAO,MAAM,YAAY,gBAAgB,CAAA;AAEzC,sEAAsE;AACtE,eAAO,MAAM,iBAAiB,8BAA8B,CAAA;AAE5D,+EAA+E;AAC/E,eAAO,MAAM,YAAY,iCAAiC,CAAA;AAQ1D,0EAA0E;AAC1E,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,2DAA2D;IAC3D,UAAU,EAAE,MAAM,EAAE,CAAA;CACrB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS,CASlE;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CA2B5D;AAYD,yDAAyD;AACzD,MAAM,WAAW,YAAY;IAC3B,iCAAiC;IACjC,IAAI,EAAE,MAAM,CAAA;IACZ,kCAAkC;IAClC,GAAG,EAAE,MAAM,CAAA;CACZ;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,kBAAkB,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAahF;AAED,6DAA6D;AAC7D,KAAK,cAAc,GAAG,MAAM,GAAG;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAAA;AAE9D,uEAAuE;AACvE,wBAAgB,YAAY,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAG1D;AAED,kDAAkD;AAClD,MAAM,WAAW,mBAAmB;IAClC,oBAAoB;IACpB,IAAI,EAAE,MAAM,CAAA;IACZ,iCAAiC;IACjC,OAAO,EAAE,MAAM,CAAA;IACf,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,oDAAoD;IACpD,QAAQ,EAAE,OAAO,CAAA;CAClB;AAED,kEAAkE;AAClE,MAAM,WAAW,YAAY;IAC3B,iGAAiG;IACjG,IAAI,EAAE,KAAK,GAAG,MAAM,GAAG,SAAS,CAAA;IAChC,sCAAsC;IACtC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,6CAA6C;IAC7C,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,qDAAqD;IACrD,QAAQ,EAAE,mBAAmB,EAAE,CAAA;IAC/B,qDAAqD;IACrD,QAAQ,EAAE,OAAO,CAAA;IACjB,uDAAuD;IACvD,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,6EAA6E;AAC7E,MAAM,WAAW,eAAe;IAC9B,qEAAqE;IACrE,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,4EAA4E;IAC5E,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;IAC9C,oEAAoE;IACpE,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAA;CACvD;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,GACjD,MAAM,GAAG,SAAS,CAUpB;AAED,kFAAkF;AAClF,MAAM,WAAW,YAAY;IAC3B,2BAA2B;IAC3B,WAAW,EAAE,MAAM,CAAA;IACnB,+CAA+C;IAC/C,UAAU,EAAE,MAAM,CAAA;IAClB,qDAAqD;IACrD,QAAQ,EAAE,MAAM,EAAE,CAAA;CACnB;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE;IAAE,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAAE,GACpC,YAAY,GAAG;IAAE,KAAK,EAAE,WAAW,GAAG,MAAM,CAAA;CAAE,CAiBhD;AAED,8EAA8E;AAC9E,wBAAgB,cAAc,CAAC,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,EAAE,CAQhF;AAED;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,CAAA;CAAE,CAAC,EAC9E,SAAS,SAAS,GACjB,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAiB7B;AAqBD;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,YAAY,CAAC,CAgD/E;AAED,4DAA4D;AAC5D,MAAM,MAAM,eAAe;AACzB,2BAA2B;AACzB,cAAc;AAChB,6CAA6C;GAC3C,SAAS;AACX,2CAA2C;GACzC,WAAW;AACb,iEAAiE;GAC/D,MAAM;AACR,4BAA4B;GAC1B,aAAa;AACf,6DAA6D;GAC3D,OAAO;AACT,iFAAiF;GAC/E,eAAe,CAAA;AAEnB,gCAAgC;AAChC,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,OAAO,CAAA;IACX,0EAA0E;IAC1E,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,6DAA6D;IAC7D,MAAM,EAAE,MAAM,CAAA;IACd,0DAA0D;IAC1D,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,gEAAgE;IAChE,SAAS,CAAC,EAAE,eAAe,CAAA;CAC5B;AAED,+CAA+C;AAC/C,MAAM,WAAW,aAAa;IAC5B,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAA;IAClB,sCAAsC;IACtC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;IAC3B,oDAAoD;IACpD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;IACxB,mDAAmD;IACnD,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,mEAAmE;IACnE,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAA;CAC3B;AAuBD;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,eAAe,CAAC,CA8HvE;AAED,0EAA0E;AAC1E,MAAM,WAAW,qBAAqB;IACpC,iDAAiD;IACjD,GAAG,EAAE,aAAa,CAAA;IAClB,8DAA8D;IAC9D,KAAK,EAAE,eAAe,CAAA;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC,CAgE7F"}
|
package/lib/types/update.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Remote update support for the dsh-web-ui family — host half. Detects the
|
|
3
3
|
* installed aggregate package (@linxin666/dsh-web-ui-all) and its family
|
|
4
4
|
* children, probes the npm registry for newer releases, and runs the actual
|
|
5
|
-
* update as `pnpm update` inside the owning dsh profile directory.
|
|
5
|
+
* update as `pnpm update --latest` inside the owning dsh profile directory.
|
|
6
6
|
*
|
|
7
7
|
* Pure logic with injected seams (manifest reading, registry fetches, process
|
|
8
8
|
* spawning) so the whole surface is unit-testable without touching disk,
|
|
@@ -130,14 +130,18 @@ export function isLinkedSpec(spec) {
|
|
|
130
130
|
}
|
|
131
131
|
/**
|
|
132
132
|
* Resolve the anchor package's manifest path. The aggregate package is the
|
|
133
|
-
* canonical entry point; this plugin's own package is the fallback.
|
|
133
|
+
* canonical entry point; this plugin's own package is the fallback. Both a
|
|
134
|
+
* throwing resolve and an undefined return mean "not installed" and move on
|
|
135
|
+
* to the next candidate.
|
|
134
136
|
* @param resolve - a Node resolve implementation scoped to the host process.
|
|
135
137
|
* @returns the absolute manifest path, or undefined when neither is installed.
|
|
136
138
|
*/
|
|
137
139
|
export function resolveAnchorManifest(resolve) {
|
|
138
140
|
for (const name of [AGGREGATE_PACKAGE, SELF_PACKAGE]) {
|
|
139
141
|
try {
|
|
140
|
-
|
|
142
|
+
const path = resolve(name + '/package.json');
|
|
143
|
+
if (path !== undefined)
|
|
144
|
+
return path;
|
|
141
145
|
}
|
|
142
146
|
catch {
|
|
143
147
|
// Not installed — try the next candidate.
|
|
@@ -216,15 +220,22 @@ export async function fetchLatestVersion(name, fetchImpl, timeoutMs = 10_000) {
|
|
|
216
220
|
return undefined;
|
|
217
221
|
}
|
|
218
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Sentinel for an installed version that could not be read (missing manifest
|
|
225
|
+
* or resolve failure). It is a real-looking version so `checkUpdates` can
|
|
226
|
+
* render the affected row, but the verified-update comparison must never
|
|
227
|
+
* treat it as evidence — a read failure is not a version that "moved".
|
|
228
|
+
*/
|
|
229
|
+
const VERSION_UNKNOWN = '0.0.0';
|
|
219
230
|
/** The resolved current version of one family package (probe failure tolerated). */
|
|
220
231
|
function readInstalledVersion(resolve, name) {
|
|
221
232
|
try {
|
|
222
233
|
const path = resolve(name + '/package.json');
|
|
223
234
|
const version = path === undefined ? undefined : readManifest(path)?.version;
|
|
224
|
-
return typeof version === 'string' ? version :
|
|
235
|
+
return typeof version === 'string' ? version : VERSION_UNKNOWN;
|
|
225
236
|
}
|
|
226
237
|
catch {
|
|
227
|
-
return
|
|
238
|
+
return VERSION_UNKNOWN;
|
|
228
239
|
}
|
|
229
240
|
}
|
|
230
241
|
/**
|
|
@@ -291,6 +302,16 @@ const OUTPUT_CAP = 16 * 1024;
|
|
|
291
302
|
* detects this message instead of the spawn error event.
|
|
292
303
|
*/
|
|
293
304
|
const WIN_CMD_MISSING_RE = /not recognized as an internal or external command/i;
|
|
305
|
+
/**
|
|
306
|
+
* Bypass for pnpm 11's supply-chain gate: `minimumReleaseAge` (default 24 h)
|
|
307
|
+
* silently skips same-day releases — `pnpm update --latest` then exits 0
|
|
308
|
+
* without moving anything and the post-run verification would misreport a
|
|
309
|
+
* stale no-op. The update here is explicitly user-initiated and the panel
|
|
310
|
+
* shows exactly which versions are being installed, so the gate only adds a
|
|
311
|
+
* confusing silent no-op; override it per-invocation instead of asking every
|
|
312
|
+
* user to edit the profile's pnpm-workspace.yaml.
|
|
313
|
+
*/
|
|
314
|
+
const MIN_RELEASE_AGE_OVERRIDE = '--config.minimumReleaseAge=0';
|
|
294
315
|
/**
|
|
295
316
|
* Run the update inside the profile directory. Tries pnpm first, falls back
|
|
296
317
|
* to corepack and then npx when the previous command is missing (ENOENT);
|
|
@@ -313,10 +334,18 @@ export function runUpdate(deps) {
|
|
|
313
334
|
};
|
|
314
335
|
// Ordered fallback chain: each is tried only when the previous one is
|
|
315
336
|
// missing on PATH (ENOENT on the spawn error event, never on close).
|
|
337
|
+
// `--latest` is required: dsh plugin writes exact-version specs (e.g.
|
|
338
|
+
// "0.1.12" without a range), and plain `pnpm update` treats an exact spec
|
|
339
|
+
// as pinned — it prints "Already up to date" and exits 0 without moving
|
|
340
|
+
// the installed version, so the panel would report a false success.
|
|
341
|
+
// `MIN_RELEASE_AGE_OVERRIDE` rides along every candidate: without it the
|
|
342
|
+
// pnpm 11 minimumReleaseAge gate (default 24 h) silently keeps same-day
|
|
343
|
+
// releases in place, which the post-run verification would then have to
|
|
344
|
+
// surface as `stale` even though the update is legitimate.
|
|
316
345
|
const candidates = [
|
|
317
|
-
{ command: 'pnpm', args: ['update', ...packages] },
|
|
318
|
-
{ command: 'corepack', args: ['pnpm', 'update', ...packages] },
|
|
319
|
-
{ command: 'npx', args: ['--yes', 'pnpm', 'update', ...packages] },
|
|
346
|
+
{ command: 'pnpm', args: ['update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
|
|
347
|
+
{ command: 'corepack', args: ['pnpm', 'update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
|
|
348
|
+
{ command: 'npx', args: ['--yes', 'pnpm', 'update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
|
|
320
349
|
];
|
|
321
350
|
// `output` accumulates across candidates for UI display; `currentOutput`
|
|
322
351
|
// is reset per candidate and carries only that candidate's own diagnostics
|
|
@@ -427,3 +456,100 @@ export function runUpdate(deps) {
|
|
|
427
456
|
runCandidate(0);
|
|
428
457
|
});
|
|
429
458
|
}
|
|
459
|
+
/**
|
|
460
|
+
* Run the update, then verify the installed versions actually moved. pnpm
|
|
461
|
+
* exits 0 even when it silently kept the installed versions — runUpdate
|
|
462
|
+
* overrides pnpm 11's `minimumReleaseAge` gate (default 24 h) with
|
|
463
|
+
* `--config.minimumReleaseAge=0`, but an older pnpm without the override or
|
|
464
|
+
* another silent no-op can still keep versions in place — so a green exit
|
|
465
|
+
* alone must not report a misleading "update complete". Re-read the
|
|
466
|
+
* installed versions afterwards and surface a `stale` failure (with the
|
|
467
|
+
* captured pnpm output) when nothing moved, so the panel can tell the user
|
|
468
|
+
* how to unblock the gate instead of claiming success.
|
|
469
|
+
*
|
|
470
|
+
* The stale decision anchors on the pre-run installed versions, not on the
|
|
471
|
+
* registry latest: under a lenient gate pnpm may move 0.1.12 -> 0.1.13 while
|
|
472
|
+
* latest stays 0.1.15 — that is a real update, not stale. The post-run anchor
|
|
473
|
+
* path is re-resolved rather than reused from boot time: pnpm removes the old
|
|
474
|
+
* version's .pnpm directory on update, so a boot-time captured path would
|
|
475
|
+
* fail to read and collapse a successful update into a bogus 'missing'.
|
|
476
|
+
*
|
|
477
|
+
* A green exit whose verification has nothing to compare (registry probe
|
|
478
|
+
* outage, missing anchor, non-npm mode) is reported as `verify-failed` — it
|
|
479
|
+
* must never read as success. A green exit where no package moved but the
|
|
480
|
+
* post-run check could not prove the install is fully current (a partial
|
|
481
|
+
* probe failure hides whether a gate kept something back) is also
|
|
482
|
+
* `verify-failed`: it must not collapse into a success either.
|
|
483
|
+
* @param deps - the run and check seams.
|
|
484
|
+
* @returns the run result; `stale` when exit 0 left every version in place,
|
|
485
|
+
* `verify-failed` when the post-run check could not verify anything.
|
|
486
|
+
*/
|
|
487
|
+
export async function runUpdateVerified(deps) {
|
|
488
|
+
// Pre-run snapshot: the installed versions the update starts from. Only
|
|
489
|
+
// successfully-read versions are recorded — a read failure (VERSION_UNKNOWN)
|
|
490
|
+
// is not a baseline and must not look like a version that "moved", or a
|
|
491
|
+
// green exit that left everything in place could be mistaken for success.
|
|
492
|
+
const before = new Map();
|
|
493
|
+
for (const name of deps.run.packages) {
|
|
494
|
+
const version = readInstalledVersion(deps.check.resolve, name);
|
|
495
|
+
if (version !== VERSION_UNKNOWN)
|
|
496
|
+
before.set(name, version);
|
|
497
|
+
}
|
|
498
|
+
const result = await runUpdate(deps.run);
|
|
499
|
+
if (!result.ok)
|
|
500
|
+
return result;
|
|
501
|
+
// Re-resolve the anchor after the run: pnpm removes the old version's
|
|
502
|
+
// .pnpm directory, so a boot-time captured path no longer reads. Fall back
|
|
503
|
+
// to the provided path only when nothing resolves now.
|
|
504
|
+
const anchorManifestPath = resolveAnchorManifest(deps.check.resolve) ?? deps.check.anchorManifestPath;
|
|
505
|
+
const status = await checkUpdates({ ...deps.check, anchorManifestPath });
|
|
506
|
+
// A green exit with no comparable result is not a success: registry probe
|
|
507
|
+
// outage, missing anchor, or a non-npm install mode all mean the post-run
|
|
508
|
+
// check could not verify anything.
|
|
509
|
+
if (status.error !== undefined || status.mode !== 'npm') {
|
|
510
|
+
return {
|
|
511
|
+
...result,
|
|
512
|
+
ok: false,
|
|
513
|
+
errorCode: 'verify-failed',
|
|
514
|
+
error: 'pnpm exited 0 but the post-run version check could not verify the install',
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
// A package "moved" only when a known pre-run version differs from a known
|
|
518
|
+
// post-run version. A read failure on either side (VERSION_UNKNOWN) is not
|
|
519
|
+
// evidence of movement and must not turn a no-op update into a success; a
|
|
520
|
+
// package without a pre-run baseline is ignored — only the packages pnpm
|
|
521
|
+
// was told to update count as evidence.
|
|
522
|
+
const moved = status.packages.some(packageStatus => {
|
|
523
|
+
const beforeVersion = before.get(packageStatus.name);
|
|
524
|
+
if (beforeVersion === undefined || packageStatus.current === VERSION_UNKNOWN)
|
|
525
|
+
return false;
|
|
526
|
+
return packageStatus.current !== beforeVersion;
|
|
527
|
+
});
|
|
528
|
+
if (moved)
|
|
529
|
+
return result;
|
|
530
|
+
// Nothing moved. A green exit is only a true no-op when the post-run check
|
|
531
|
+
// proves the install is already current: every probe succeeded and nothing
|
|
532
|
+
// is outdated. If a probe failed we cannot tell "already up to date" from
|
|
533
|
+
// "a gate silently kept it back", so report verify-failed rather than a
|
|
534
|
+
// false success.
|
|
535
|
+
if (status.outdated) {
|
|
536
|
+
// stale: the check ran, a newer release exists, and no package moved
|
|
537
|
+
// (e.g. the minimumReleaseAge gate kept everything in place).
|
|
538
|
+
return {
|
|
539
|
+
...result,
|
|
540
|
+
ok: false,
|
|
541
|
+
errorCode: 'stale',
|
|
542
|
+
error: 'pnpm exited 0 but the installed versions did not change',
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
const unverifiable = status.packages.some(packageStatus => packageStatus.latest === undefined);
|
|
546
|
+
if (unverifiable) {
|
|
547
|
+
return {
|
|
548
|
+
...result,
|
|
549
|
+
ok: false,
|
|
550
|
+
errorCode: 'verify-failed',
|
|
551
|
+
error: 'pnpm exited 0 but the post-run version check could not verify the install',
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
return result;
|
|
555
|
+
}
|