dshmarket 1.32.0 → 1.33.0
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.md +1 -0
- package/README.zh.md +1 -0
- package/UPDATE-API-V1.md +115 -0
- package/lib/check.js +28 -5
- package/lib/routes.js +282 -6
- package/lib/types/update-api-v1.d.ts +66 -0
- package/lib/update-api-v1.js +207 -0
- package/package.json +6 -1
- package/src/check.ts +32 -6
- package/src/routes.ts +286 -6
- package/src/update-api-v1.ts +266 -0
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ its own dsh: it may be older than the one `npm` would give you (#139).
|
|
|
37
37
|
- **One-click install** — confirm the source, watch live progress; most plugins go live after a page refresh, no restart
|
|
38
38
|
- **Backup & restore** — export your profile's plugin list and configuration as readable JSON, import it on another machine, store it on WebDAV with daily auto-backup, or sync through a private GitHub Gist; restores **merge** (plugins installed after the backup are kept), validate before writing, and roll back on failure
|
|
39
39
|
- **Updates** — per-plugin update checks (npm version or pinned commit vs HEAD), one-click update, or update everything at once; the market updates itself the same way
|
|
40
|
+
- **Public update API** — plugin-owned settings pages can use the versioned, capability-gated [update API v1](UPDATE-API-V1.md) (beta) instead of copying package-manager logic or depending on private Market UI responses
|
|
40
41
|
- **Uninstall** — two-step confirm; plugins installed this session are removed live
|
|
41
42
|
- **Hot disable / enable** — toggles write `- id: …` + `disabled: true|false` into the profile's `cordis.patch.yml` (the official patch layer, mechanism ported from [dsh-plugin-hub](https://github.com/Noob-stupid/dsh-plugin-hub)): DSH's HMR re-composes within ~1s, no restart, and the loader re-applies the choice on every boot; hand-edited patch rows show as badges, host-infrastructure plugins are protected from toggling, and a malformed patch file is never made worse
|
|
42
43
|
- **Restart when needed** — changes that cannot hot-load show a one-click restart beside the pending-change banner; the action is restricted to same-origin loopback requests
|
package/README.zh.md
CHANGED
|
@@ -35,6 +35,7 @@ dsh plugin --profile web add dshmarket
|
|
|
35
35
|
- **一键安装**——确认来源,实时进度;多数插件刷新页面即可用,无需重启
|
|
36
36
|
- **备份与恢复**——把 profile 的插件清单与配置导出为可读 JSON,换机导入,存到 WebDAV 并每日自动备份,或通过私有 GitHub Gist 跨机器同步;恢复采用**合并**方式(备份之后新装的插件会保留),写入前校验、失败自动回滚
|
|
37
37
|
- **更新**——逐插件检测(npm 版本或锁定 commit 对比 HEAD),一键更新或全部更新;市场自己也走同一通道升级
|
|
38
|
+
- **公共更新接口**——插件自己的设置页可调用带版本号、能力探测和回滚状态的[更新 API v1](UPDATE-API-V1.md)(beta),无需复制包管理逻辑,也不依赖市场 UI 的私有响应字段
|
|
38
39
|
- **卸载**——两步确认防误触;本次会话装的插件即点即卸
|
|
39
40
|
- **热禁用 / 启用**——开关会往 profile 的 `cordis.patch.yml`(官方补丁层,机制移植自 [dsh-plugin-hub](https://github.com/Noob-stupid/dsh-plugin-hub))写入 `- id: …` + `disabled: true|false`:DSH 的 HMR 约 1 秒内重新组合,无需重启,loader 每次启动都会重新应用这个选择;手工改过的补丁行会显示成徽标,宿主基础设施插件禁止开关,补丁文件格式不对时绝不会被写得更糟
|
|
40
41
|
- **按需重启**——无法热加载的变更会在待重启提示旁显示一键重启;操作仅接受本机同源请求
|
package/UPDATE-API-V1.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Public plugin update API v1
|
|
2
|
+
|
|
3
|
+
> **Status: beta.** The shape described here may still change between
|
|
4
|
+
> releases. `GET /dsh-market/api/v1/capabilities` reports
|
|
5
|
+
> `"stability": "beta"` while that is true, and `"stable"` once it stops
|
|
6
|
+
> moving — read that field rather than assuming from the `v1` in the path.
|
|
7
|
+
>
|
|
8
|
+
> Nothing here is going away; what is not yet promised is that field names
|
|
9
|
+
> and response shapes will survive untouched. If you ship against it now,
|
|
10
|
+
> say so in an issue: a shape somebody depends on is a much stronger reason
|
|
11
|
+
> not to move it, and it is how this reaches `stable`.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
`dshmarket` exposes a small, versioned, same-origin JSON API for plugin-owned
|
|
15
|
+
update surfaces. It lets a plugin show its own update button without spawning a
|
|
16
|
+
package manager, copying the Market installation algorithm, or binding to the
|
|
17
|
+
Market UI's private response fields.
|
|
18
|
+
|
|
19
|
+
All responses carry:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{ "schema": "dsh-market/update-api/v1" }
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Clients must discover the API before enabling mutation controls:
|
|
26
|
+
|
|
27
|
+
```http
|
|
28
|
+
GET /dsh-market/api/v1/capabilities
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The response names the Market version, profile, runtime (`web` or `desktop`),
|
|
32
|
+
supported features, restart owner and endpoint paths. A client must hide its
|
|
33
|
+
restart button when `restart.supported` is false. Desktop and supervised hosts
|
|
34
|
+
normally delegate restart to their owning shell or operator.
|
|
35
|
+
|
|
36
|
+
## Check one installed package
|
|
37
|
+
|
|
38
|
+
```http
|
|
39
|
+
GET /dsh-market/api/v1/updates?name=dsh-mcp-connector&force=1
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The response includes the installed version, target version, source kind and
|
|
43
|
+
whether the target is a forward update. Omitting `force=1` allows the Market's
|
|
44
|
+
short update-check cache.
|
|
45
|
+
|
|
46
|
+
## Start and observe an update
|
|
47
|
+
|
|
48
|
+
Mutation requests require the same-origin protection used by the Market UI.
|
|
49
|
+
|
|
50
|
+
```http
|
|
51
|
+
POST /dsh-market/api/v1/updates
|
|
52
|
+
Content-Type: application/json
|
|
53
|
+
|
|
54
|
+
{ "packageName": "dsh-mcp-connector" }
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
An accepted request returns HTTP `202` immediately with an `operationId`.
|
|
58
|
+
Passing `"force": true` opts this one operation out of the registry release-age
|
|
59
|
+
wait; clients should offer it only after the normal operation reports
|
|
60
|
+
`RELEASE_TOO_FRESH` or `VERSION_UNCHANGED`.
|
|
61
|
+
|
|
62
|
+
Poll the operation by id:
|
|
63
|
+
|
|
64
|
+
```http
|
|
65
|
+
GET /dsh-market/api/v1/operations?operationId=<id>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
States are `queued`, `running`, `succeeded`, `failed`, `cancelled` and
|
|
69
|
+
`rolled-back`. Running operations include structured package progress when
|
|
70
|
+
pnpm provides it. Terminal operations include:
|
|
71
|
+
|
|
72
|
+
- the before and actually installed versions;
|
|
73
|
+
- `refreshRequired` and `restartRequired` outcomes;
|
|
74
|
+
- a stable failure code, bounded user-facing message and retryability;
|
|
75
|
+
- whether a compatibility rollback is currently available.
|
|
76
|
+
|
|
77
|
+
Up to 50 operation records live in the current Host process. A boot id is
|
|
78
|
+
embedded in every operation id, so a client never mistakes a stale browser
|
|
79
|
+
record for a task belonging to the replacement process.
|
|
80
|
+
|
|
81
|
+
## Roll back
|
|
82
|
+
|
|
83
|
+
```http
|
|
84
|
+
POST /dsh-market/api/v1/rollback
|
|
85
|
+
Content-Type: application/json
|
|
86
|
+
|
|
87
|
+
{ "operationId": "<id>" }
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Rollback is intentionally capability- and operation-scoped. It is available
|
|
91
|
+
only when the Market's compatibility verification retained a recovery point;
|
|
92
|
+
a later mutation may supersede it. The normalized result is written back to
|
|
93
|
+
the same operation record.
|
|
94
|
+
|
|
95
|
+
## Restart
|
|
96
|
+
|
|
97
|
+
```http
|
|
98
|
+
POST /dsh-market/api/v1/restart
|
|
99
|
+
Content-Type: application/json
|
|
100
|
+
|
|
101
|
+
{}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
This preserves the Market's stricter restart guard: direct loopback,
|
|
105
|
+
same-origin, no forwarding headers, no package mutation in progress, and a Host
|
|
106
|
+
whose lifecycle is not owned by Desktop or a supervisor. Clients must feature
|
|
107
|
+
detect it; they must not invent an alternative process-control path.
|
|
108
|
+
|
|
109
|
+
## Compatibility policy
|
|
110
|
+
|
|
111
|
+
- New optional response fields may be added within v1.
|
|
112
|
+
- Existing v1 fields and meanings are not repurposed.
|
|
113
|
+
- A breaking change uses a new path and schema version.
|
|
114
|
+
- When discovery is unavailable, plugin UIs should fall back to opening the
|
|
115
|
+
Market rather than calling legacy `/dsh-market/*` mutation routes directly.
|
package/lib/check.js
CHANGED
|
@@ -221,7 +221,7 @@ function readNodeModulesVersion(base, name) {
|
|
|
221
221
|
* hoisting (`<profiles>/node_modules/…` when the profile lives under
|
|
222
222
|
* `<profiles>/<name>`) and mirrors the Loader's package search roots.
|
|
223
223
|
*/
|
|
224
|
-
function resolvePackageDir(anchorPackageJson, name) {
|
|
224
|
+
function resolvePackageDir(anchorPackageJson, name, ignoredPackageDirectory) {
|
|
225
225
|
let paths = [];
|
|
226
226
|
try {
|
|
227
227
|
paths = createRequire(anchorPackageJson).resolve.paths(name) ?? [];
|
|
@@ -229,8 +229,19 @@ function resolvePackageDir(anchorPackageJson, name) {
|
|
|
229
229
|
catch {
|
|
230
230
|
return null;
|
|
231
231
|
}
|
|
232
|
+
const ignored = ignoredPackageDirectory === undefined
|
|
233
|
+
? null
|
|
234
|
+
: resolve(ignoredPackageDirectory);
|
|
232
235
|
for (const searchPath of paths) {
|
|
233
236
|
const candidate = join(searchPath, name);
|
|
237
|
+
if (ignored !== null) {
|
|
238
|
+
const resolvedCandidate = resolve(candidate);
|
|
239
|
+
const matchesIgnored = process.platform === 'win32'
|
|
240
|
+
? resolvedCandidate.toLowerCase() === ignored.toLowerCase()
|
|
241
|
+
: resolvedCandidate === ignored;
|
|
242
|
+
if (matchesIgnored)
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
234
245
|
if (existsSync(join(candidate, 'package.json')))
|
|
235
246
|
return candidate;
|
|
236
247
|
}
|
|
@@ -709,15 +720,27 @@ function lockfileCoreVersions(profileDir) {
|
|
|
709
720
|
*/
|
|
710
721
|
export function buildBundleLayers(profileDirectory, bundleNames, specs, dshInstallDir) {
|
|
711
722
|
const bundles = bundleNames.map((name) => {
|
|
723
|
+
// The real loader gives the DSH installation first refusal for in-box
|
|
724
|
+
// bundles. Desktop keeps that installation private from plugins, so a
|
|
725
|
+
// DIRECT profile-local copy with the same official name is only a stale
|
|
726
|
+
// shadow, never evidence for the layer the running host loaded (#371).
|
|
727
|
+
// Keep walking the profile anchor's parent search paths: Desktop heals an
|
|
728
|
+
// authoritative host fallback at <profiles>/node_modules.
|
|
729
|
+
const ignoredProfilePackage = dshInstallDir === null && INBOX_BUNDLES.has(name)
|
|
730
|
+
? join(profileDirectory, 'node_modules', name)
|
|
731
|
+
: undefined;
|
|
712
732
|
const anchors = [
|
|
713
|
-
dshInstallDir !== null ? join(dshInstallDir, 'package.json') : null,
|
|
714
|
-
|
|
733
|
+
{ anchor: dshInstallDir !== null ? join(dshInstallDir, 'package.json') : null },
|
|
734
|
+
{
|
|
735
|
+
anchor: join(profileDirectory, 'package.json'),
|
|
736
|
+
ignoredPackageDirectory: ignoredProfilePackage,
|
|
737
|
+
},
|
|
715
738
|
];
|
|
716
739
|
let directory = null;
|
|
717
|
-
for (const anchor of anchors) {
|
|
740
|
+
for (const { anchor, ignoredPackageDirectory } of anchors) {
|
|
718
741
|
if (anchor === null)
|
|
719
742
|
continue;
|
|
720
|
-
directory = resolvePackageDir(anchor, name);
|
|
743
|
+
directory = resolvePackageDir(anchor, name, ignoredPackageDirectory);
|
|
721
744
|
if (directory !== null)
|
|
722
745
|
break;
|
|
723
746
|
}
|
package/lib/routes.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
|
+
import { Readable } from 'node:stream';
|
|
12
13
|
import { forgetCatalog, loadRegistry, pluginCategories } from './registry.js';
|
|
13
14
|
import { cleanHotDir, hotMount, hotUnmount, listHotMounts, MAX_NOTE, mountClientOnlyDeps, purgeMarketState, readMarketState, writeMarketState, } from './hot.js';
|
|
14
15
|
import { createGroup, deleteGroup, removeFromGroups, renameGroup, setGroupMembers } from './groups.js';
|
|
@@ -38,6 +39,7 @@ import { activationAfterReplace, brokenClientBundles, checkClientBundle, hasHost
|
|
|
38
39
|
import { carrierDisableIds, disableRow, enableRow, findUserPatchPath, isProtectedModule, packagePatchFlags, readUserPatchState, removeRowBlocks, rowIdsForPackage, userPatchPackageReferences, } from './patch.js';
|
|
39
40
|
import { createProfileBackup, downloadWebdav, MAX_BACKUP_BYTES, mergeRestoreManifest, restoreProfileBackup, unportableDeps, uploadWebdav, } from './backup.js';
|
|
40
41
|
import { createGist, fitsGistLimit, GistError, gistErrorCode, parseGistId, readGist, resolveGistTokenSource, updateGist, verifyGistToken, } from './gist.js';
|
|
42
|
+
import { MAX_UPDATE_OPERATIONS_V1, UpdateOperationStoreV1, UPDATE_API_V1_SCHEMA } from './update-api-v1.js';
|
|
41
43
|
/**
|
|
42
44
|
* The market's own version, read once from its installed package.json.
|
|
43
45
|
*
|
|
@@ -658,7 +660,281 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
658
660
|
throw error;
|
|
659
661
|
}
|
|
660
662
|
}
|
|
663
|
+
const legacyHandlers = new Map();
|
|
664
|
+
const captureLegacy = (path, route) => {
|
|
665
|
+
legacyHandlers.set(path, route.handler);
|
|
666
|
+
return route;
|
|
667
|
+
};
|
|
668
|
+
const operationsV1 = new UpdateOperationStoreV1(BOOT_ID);
|
|
669
|
+
/** Invoke one existing route in memory so v1 reuses the battle-tested executor. */
|
|
670
|
+
async function invokeLegacy(path, source, method, body, url = path) {
|
|
671
|
+
const handler = legacyHandlers.get(path);
|
|
672
|
+
if (handler === undefined)
|
|
673
|
+
throw new Error(`legacy route is unavailable: ${path}`);
|
|
674
|
+
const chunks = body === undefined ? [] : [Buffer.from(JSON.stringify(body))];
|
|
675
|
+
const replay = Readable.from(chunks);
|
|
676
|
+
Object.assign(replay, {
|
|
677
|
+
method,
|
|
678
|
+
url,
|
|
679
|
+
headers: { ...source.headers },
|
|
680
|
+
socket: source.socket,
|
|
681
|
+
});
|
|
682
|
+
let status = 200;
|
|
683
|
+
let text = '';
|
|
684
|
+
const captured = {
|
|
685
|
+
writeHead(code) { status = code; return this; },
|
|
686
|
+
end(chunk) {
|
|
687
|
+
if (chunk !== undefined)
|
|
688
|
+
text += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk;
|
|
689
|
+
return this;
|
|
690
|
+
},
|
|
691
|
+
};
|
|
692
|
+
await handler(replay, captured);
|
|
693
|
+
let payload = null;
|
|
694
|
+
try {
|
|
695
|
+
payload = text === '' ? null : JSON.parse(text);
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
payload = { error: text };
|
|
699
|
+
}
|
|
700
|
+
return { status, payload };
|
|
701
|
+
}
|
|
702
|
+
const packageNameFrom = (request) => {
|
|
703
|
+
try {
|
|
704
|
+
return new URL(request.url ?? '', 'http://localhost').searchParams.get('name') ?? '';
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
return '';
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
const operationIdFrom = (request) => {
|
|
711
|
+
try {
|
|
712
|
+
return new URL(request.url ?? '', 'http://localhost').searchParams.get('operationId') ?? '';
|
|
713
|
+
}
|
|
714
|
+
catch {
|
|
715
|
+
return '';
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
const forceCheckFrom = (request) => {
|
|
719
|
+
try {
|
|
720
|
+
return new URL(request.url ?? '', 'http://localhost').searchParams.get('force') === '1';
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
};
|
|
661
726
|
const disposers = [
|
|
727
|
+
host.webServer.register({
|
|
728
|
+
kind: 'exact',
|
|
729
|
+
path: '/dsh-market/api/v1/capabilities',
|
|
730
|
+
handler: (request, response) => {
|
|
731
|
+
if (request.method !== 'GET') {
|
|
732
|
+
response.writeHead(405, { allow: 'GET' });
|
|
733
|
+
response.end();
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
const canRestart = restartAllowed(config);
|
|
737
|
+
sendJson(response, 200, {
|
|
738
|
+
schema: UPDATE_API_V1_SCHEMA,
|
|
739
|
+
apiVersion: 1,
|
|
740
|
+
// Machine-readable, because a policy that lives only in a markdown
|
|
741
|
+
// file is one a client never reads. `beta` says the shape may still
|
|
742
|
+
// change; it becomes `stable` once a release stops moving it, and
|
|
743
|
+
// that is the point at which the compatibility promise starts.
|
|
744
|
+
stability: 'beta',
|
|
745
|
+
marketVersion: marketVersion(),
|
|
746
|
+
profile: config.profile,
|
|
747
|
+
bootId: BOOT_ID,
|
|
748
|
+
runtime: config.profileDirectory === undefined ? 'web' : 'desktop',
|
|
749
|
+
features: {
|
|
750
|
+
check: true,
|
|
751
|
+
update: true,
|
|
752
|
+
progress: true,
|
|
753
|
+
rollback: true,
|
|
754
|
+
restart: canRestart,
|
|
755
|
+
},
|
|
756
|
+
restart: {
|
|
757
|
+
supported: canRestart,
|
|
758
|
+
managedBy: canRestart ? 'market' : config.profileDirectory === undefined ? 'operator' : 'desktop-host',
|
|
759
|
+
supervisor: detectedSupervisor(),
|
|
760
|
+
},
|
|
761
|
+
operationRetention: 'current-process',
|
|
762
|
+
operationLimit: MAX_UPDATE_OPERATIONS_V1,
|
|
763
|
+
endpoints: {
|
|
764
|
+
updates: '/dsh-market/api/v1/updates',
|
|
765
|
+
operations: '/dsh-market/api/v1/operations',
|
|
766
|
+
rollback: '/dsh-market/api/v1/rollback',
|
|
767
|
+
restart: '/dsh-market/api/v1/restart',
|
|
768
|
+
},
|
|
769
|
+
});
|
|
770
|
+
},
|
|
771
|
+
}),
|
|
772
|
+
host.webServer.register({
|
|
773
|
+
kind: 'exact',
|
|
774
|
+
path: '/dsh-market/api/v1/updates',
|
|
775
|
+
handler: async (request, response) => {
|
|
776
|
+
if (request.method === 'GET') {
|
|
777
|
+
const name = packageNameFrom(request);
|
|
778
|
+
if (!NPM_NAME_RE.test(name)) {
|
|
779
|
+
sendJson(response, 400, { schema: UPDATE_API_V1_SCHEMA, error: 'a valid package name is required' });
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
try {
|
|
783
|
+
const force = forceCheckFrom(request);
|
|
784
|
+
const channel = activeChannel();
|
|
785
|
+
const channelFor = SELF_NAMES.has(name) ? new Map([[name, channel]]) : undefined;
|
|
786
|
+
const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor))[name];
|
|
787
|
+
if (update === undefined) {
|
|
788
|
+
sendJson(response, 404, { schema: UPDATE_API_V1_SCHEMA, error: 'plugin is not installed' });
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
sendJson(response, 200, {
|
|
792
|
+
schema: UPDATE_API_V1_SCHEMA,
|
|
793
|
+
package: {
|
|
794
|
+
name,
|
|
795
|
+
source: update.kind,
|
|
796
|
+
installedVersion: update.current ?? update.version,
|
|
797
|
+
latestVersion: update.latest,
|
|
798
|
+
updateAvailable: update.updateAvailable,
|
|
799
|
+
channelSwitch: update.channelSwitch ?? null,
|
|
800
|
+
},
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
catch (error) {
|
|
804
|
+
sendJson(response, 500, {
|
|
805
|
+
schema: UPDATE_API_V1_SCHEMA,
|
|
806
|
+
error: error instanceof Error ? error.message : String(error),
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (request.method !== 'POST') {
|
|
812
|
+
response.writeHead(405, { allow: 'GET, POST' });
|
|
813
|
+
response.end();
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
if (!sameOrigin(request)) {
|
|
817
|
+
sendJson(response, 403, { schema: UPDATE_API_V1_SCHEMA, error: 'untrusted origin' });
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
try {
|
|
821
|
+
const body = (await readJsonBody(request));
|
|
822
|
+
const packageName = typeof body.packageName === 'string' ? body.packageName : '';
|
|
823
|
+
if (!NPM_NAME_RE.test(packageName)) {
|
|
824
|
+
sendJson(response, 400, { schema: UPDATE_API_V1_SCHEMA, error: 'a valid package name is required' });
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (operationsV1.hasActive()) {
|
|
828
|
+
sendJson(response, 409, {
|
|
829
|
+
schema: UPDATE_API_V1_SCHEMA,
|
|
830
|
+
error: 'another public update operation is already running',
|
|
831
|
+
failure: {
|
|
832
|
+
code: 'OPERATION_BUSY',
|
|
833
|
+
message: 'another public update operation is already running',
|
|
834
|
+
retryable: true,
|
|
835
|
+
},
|
|
836
|
+
});
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const installedVersion = readInstalledVersion(config.profile, packageName, activeProfileDir);
|
|
840
|
+
if (installedVersion === null) {
|
|
841
|
+
sendJson(response, 404, {
|
|
842
|
+
schema: UPDATE_API_V1_SCHEMA,
|
|
843
|
+
error: 'plugin is not installed',
|
|
844
|
+
failure: {
|
|
845
|
+
code: 'PLUGIN_NOT_INSTALLED',
|
|
846
|
+
message: 'plugin is not installed in this profile',
|
|
847
|
+
retryable: false,
|
|
848
|
+
},
|
|
849
|
+
});
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
const operation = operationsV1.create(packageName, installedVersion);
|
|
853
|
+
operationsV1.start(operation.operationId);
|
|
854
|
+
void invokeLegacy('/dsh-market/update', request, 'POST', {
|
|
855
|
+
name: packageName,
|
|
856
|
+
...(body.force === true ? { force: true } : {}),
|
|
857
|
+
}).then(({ status, payload }) => {
|
|
858
|
+
operationsV1.finish(operation.operationId, status, payload, readInstalledVersion(config.profile, packageName, activeProfileDir));
|
|
859
|
+
}).catch((error) => {
|
|
860
|
+
operationsV1.finish(operation.operationId, 500, { error: error instanceof Error ? error.message : String(error) }, readInstalledVersion(config.profile, packageName, activeProfileDir));
|
|
861
|
+
});
|
|
862
|
+
sendJson(response, 202, {
|
|
863
|
+
schema: UPDATE_API_V1_SCHEMA,
|
|
864
|
+
operation: operationsV1.get(operation.operationId),
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
catch (error) {
|
|
868
|
+
sendJson(response, 400, {
|
|
869
|
+
schema: UPDATE_API_V1_SCHEMA,
|
|
870
|
+
error: error instanceof Error ? error.message : String(error),
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
},
|
|
874
|
+
}),
|
|
875
|
+
host.webServer.register({
|
|
876
|
+
kind: 'exact',
|
|
877
|
+
path: '/dsh-market/api/v1/operations',
|
|
878
|
+
handler: (request, response) => {
|
|
879
|
+
if (request.method !== 'GET') {
|
|
880
|
+
response.writeHead(405, { allow: 'GET' });
|
|
881
|
+
response.end();
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const operation = operationsV1.get(operationIdFrom(request), progress);
|
|
885
|
+
if (operation === null) {
|
|
886
|
+
sendJson(response, 404, { schema: UPDATE_API_V1_SCHEMA, error: 'operation not found in this host process' });
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
sendJson(response, 200, { schema: UPDATE_API_V1_SCHEMA, operation });
|
|
890
|
+
},
|
|
891
|
+
}),
|
|
892
|
+
host.webServer.register({
|
|
893
|
+
kind: 'exact',
|
|
894
|
+
path: '/dsh-market/api/v1/rollback',
|
|
895
|
+
handler: async (request, response) => {
|
|
896
|
+
if (request.method !== 'POST') {
|
|
897
|
+
response.writeHead(405, { allow: 'POST' });
|
|
898
|
+
response.end();
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (!sameOrigin(request)) {
|
|
902
|
+
sendJson(response, 403, { schema: UPDATE_API_V1_SCHEMA, error: 'untrusted origin' });
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
try {
|
|
906
|
+
const body = (await readJsonBody(request));
|
|
907
|
+
const operationId = typeof body.operationId === 'string' ? body.operationId : '';
|
|
908
|
+
const legacyRollbackId = operationsV1.beginRollback(operationId);
|
|
909
|
+
if (legacyRollbackId === null) {
|
|
910
|
+
sendJson(response, 409, { schema: UPDATE_API_V1_SCHEMA, error: 'rollback is not available for this operation' });
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
const result = await invokeLegacy('/dsh-market/rollback', request, 'POST', { rollbackId: legacyRollbackId });
|
|
914
|
+
const operation = operationsV1.finishRollback(operationId, result.status, result.payload);
|
|
915
|
+
sendJson(response, 200, { schema: UPDATE_API_V1_SCHEMA, operation });
|
|
916
|
+
}
|
|
917
|
+
catch (error) {
|
|
918
|
+
sendJson(response, 400, {
|
|
919
|
+
schema: UPDATE_API_V1_SCHEMA,
|
|
920
|
+
error: error instanceof Error ? error.message : String(error),
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
},
|
|
924
|
+
}),
|
|
925
|
+
host.webServer.register({
|
|
926
|
+
kind: 'exact',
|
|
927
|
+
path: '/dsh-market/api/v1/restart',
|
|
928
|
+
handler: async (request, response) => {
|
|
929
|
+
if (request.method !== 'POST') {
|
|
930
|
+
response.writeHead(405, { allow: 'POST' });
|
|
931
|
+
response.end();
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const result = await invokeLegacy('/dsh-market/restart', request, 'POST', {});
|
|
935
|
+
sendJson(response, result.status, { schema: UPDATE_API_V1_SCHEMA, result: result.payload });
|
|
936
|
+
},
|
|
937
|
+
}),
|
|
662
938
|
host.webServer.register({
|
|
663
939
|
kind: 'exact',
|
|
664
940
|
path: '/dsh-market/backup',
|
|
@@ -1633,7 +1909,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
1633
1909
|
}
|
|
1634
1910
|
},
|
|
1635
1911
|
}),
|
|
1636
|
-
host.webServer.register({
|
|
1912
|
+
host.webServer.register(captureLegacy('/dsh-market/update', {
|
|
1637
1913
|
kind: 'exact',
|
|
1638
1914
|
path: '/dsh-market/update',
|
|
1639
1915
|
handler: async (request, response) => {
|
|
@@ -1997,7 +2273,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
1997
2273
|
sendJson(response, 500, { error: message });
|
|
1998
2274
|
}
|
|
1999
2275
|
},
|
|
2000
|
-
}),
|
|
2276
|
+
})),
|
|
2001
2277
|
host.webServer.register({
|
|
2002
2278
|
kind: 'exact',
|
|
2003
2279
|
path: '/dsh-market/setup-pnpm',
|
|
@@ -2227,7 +2503,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
2227
2503
|
}
|
|
2228
2504
|
},
|
|
2229
2505
|
}),
|
|
2230
|
-
host.webServer.register({
|
|
2506
|
+
host.webServer.register(captureLegacy('/dsh-market/restart', {
|
|
2231
2507
|
kind: 'exact',
|
|
2232
2508
|
path: '/dsh-market/restart',
|
|
2233
2509
|
handler: (request, response) => {
|
|
@@ -2266,7 +2542,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
2266
2542
|
sendJson(response, 500, { error: message });
|
|
2267
2543
|
}
|
|
2268
2544
|
},
|
|
2269
|
-
}),
|
|
2545
|
+
})),
|
|
2270
2546
|
host.webServer.register({
|
|
2271
2547
|
kind: 'exact',
|
|
2272
2548
|
path: '/dsh-market/approve-builds',
|
|
@@ -2562,7 +2838,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
2562
2838
|
}
|
|
2563
2839
|
},
|
|
2564
2840
|
}),
|
|
2565
|
-
host.webServer.register({
|
|
2841
|
+
host.webServer.register(captureLegacy('/dsh-market/rollback', {
|
|
2566
2842
|
kind: 'exact',
|
|
2567
2843
|
path: '/dsh-market/rollback',
|
|
2568
2844
|
handler: async (request, response) => {
|
|
@@ -2630,7 +2906,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
|
|
|
2630
2906
|
sendJson(response, 500, { error: message });
|
|
2631
2907
|
}
|
|
2632
2908
|
},
|
|
2633
|
-
}),
|
|
2909
|
+
})),
|
|
2634
2910
|
host.webServer.register({
|
|
2635
2911
|
kind: 'exact',
|
|
2636
2912
|
path: '/dsh-market/install',
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable, versioned contract for plugin-owned update surfaces.
|
|
3
|
+
*
|
|
4
|
+
* The market UI has richer internal response shapes that evolve with its UI.
|
|
5
|
+
* Third-party plugins must not depend on those shapes, so this module owns the
|
|
6
|
+
* small JSON envelope exposed under `/dsh-market/api/v1`.
|
|
7
|
+
*/
|
|
8
|
+
import type { InstallProgress } from './dsh-cli.ts';
|
|
9
|
+
export declare const UPDATE_API_V1_SCHEMA: 'dsh-market/update-api/v1';
|
|
10
|
+
export declare const MAX_UPDATE_OPERATIONS_V1 = 50;
|
|
11
|
+
export type UpdateOperationState = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'rolled-back';
|
|
12
|
+
export interface UpdateFailureV1 {
|
|
13
|
+
code: string;
|
|
14
|
+
message: string;
|
|
15
|
+
retryable: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface UpdateOperationV1 {
|
|
18
|
+
schema: typeof UPDATE_API_V1_SCHEMA;
|
|
19
|
+
operationId: string;
|
|
20
|
+
kind: 'update';
|
|
21
|
+
packageName: string;
|
|
22
|
+
state: UpdateOperationState;
|
|
23
|
+
createdAt: number;
|
|
24
|
+
startedAt: number | null;
|
|
25
|
+
finishedAt: number | null;
|
|
26
|
+
beforeVersion: string | null;
|
|
27
|
+
installedVersion: string | null;
|
|
28
|
+
progress: {
|
|
29
|
+
phase: string | null;
|
|
30
|
+
done: number;
|
|
31
|
+
total: number | null;
|
|
32
|
+
percent: number | null;
|
|
33
|
+
currentPackage: string | null;
|
|
34
|
+
detail: string | null;
|
|
35
|
+
downloaded: number | null;
|
|
36
|
+
size: number | null;
|
|
37
|
+
};
|
|
38
|
+
outcome: {
|
|
39
|
+
refreshRequired: boolean;
|
|
40
|
+
restartRequired: boolean;
|
|
41
|
+
rollback: {
|
|
42
|
+
available: boolean;
|
|
43
|
+
state: 'unavailable' | 'available' | 'running' | 'succeeded' | 'failed';
|
|
44
|
+
detail: string | null;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
failure: UpdateFailureV1 | null;
|
|
48
|
+
}
|
|
49
|
+
/** Process-local operation registry. A boot id scopes ids across restarts. */
|
|
50
|
+
export declare class UpdateOperationStoreV1 {
|
|
51
|
+
private readonly bootId;
|
|
52
|
+
private readonly now;
|
|
53
|
+
private readonly maxOperations;
|
|
54
|
+
private sequence;
|
|
55
|
+
private readonly operations;
|
|
56
|
+
private activeId;
|
|
57
|
+
constructor(bootId: string, now?: () => number, maxOperations?: number);
|
|
58
|
+
hasActive(): boolean;
|
|
59
|
+
create(packageName: string, beforeVersion: string | null): UpdateOperationV1;
|
|
60
|
+
start(operationId: string): void;
|
|
61
|
+
finish(operationId: string, status: number, payload: unknown, installedVersion: string | null): UpdateOperationV1 | null;
|
|
62
|
+
beginRollback(operationId: string): string | null;
|
|
63
|
+
finishRollback(operationId: string, status: number, payload: unknown): UpdateOperationV1 | null;
|
|
64
|
+
get(operationId: string, progress?: InstallProgress): UpdateOperationV1 | null;
|
|
65
|
+
private snapshot;
|
|
66
|
+
}
|