toolhome 0.5.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/LICENSE +21 -0
- package/README.md +295 -0
- package/README.zh.md +281 -0
- package/dist/server/app.d.ts +16 -0
- package/dist/server/app.d.ts.map +1 -0
- package/dist/server/app.js +166 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/cli/main.d.ts +3 -0
- package/dist/server/cli/main.d.ts.map +1 -0
- package/dist/server/cli/main.js +587 -0
- package/dist/server/cli/main.js.map +1 -0
- package/dist/server/cli-plane/allow-list.d.ts +18 -0
- package/dist/server/cli-plane/allow-list.d.ts.map +1 -0
- package/dist/server/cli-plane/allow-list.js +28 -0
- package/dist/server/cli-plane/allow-list.js.map +1 -0
- package/dist/server/cli-plane/cli-service.d.ts +41 -0
- package/dist/server/cli-plane/cli-service.d.ts.map +1 -0
- package/dist/server/cli-plane/cli-service.js +282 -0
- package/dist/server/cli-plane/cli-service.js.map +1 -0
- package/dist/server/cli-plane/frames.d.ts +26 -0
- package/dist/server/cli-plane/frames.d.ts.map +1 -0
- package/dist/server/cli-plane/frames.js +12 -0
- package/dist/server/cli-plane/frames.js.map +1 -0
- package/dist/server/cli-plane/models.d.ts +174 -0
- package/dist/server/cli-plane/models.d.ts.map +1 -0
- package/dist/server/cli-plane/models.js +169 -0
- package/dist/server/cli-plane/models.js.map +1 -0
- package/dist/server/cli-plane/routes.d.ts +32 -0
- package/dist/server/cli-plane/routes.d.ts.map +1 -0
- package/dist/server/cli-plane/routes.js +94 -0
- package/dist/server/cli-plane/routes.js.map +1 -0
- package/dist/server/cli-plane/runner.d.ts +41 -0
- package/dist/server/cli-plane/runner.d.ts.map +1 -0
- package/dist/server/cli-plane/runner.js +163 -0
- package/dist/server/cli-plane/runner.js.map +1 -0
- package/dist/server/cli-plane/status.d.ts +12 -0
- package/dist/server/cli-plane/status.d.ts.map +1 -0
- package/dist/server/cli-plane/status.js +24 -0
- package/dist/server/cli-plane/status.js.map +1 -0
- package/dist/server/config/harness-import.d.ts +44 -0
- package/dist/server/config/harness-import.d.ts.map +1 -0
- package/dist/server/config/harness-import.js +166 -0
- package/dist/server/config/harness-import.js.map +1 -0
- package/dist/server/config.d.ts +20 -0
- package/dist/server/config.d.ts.map +1 -0
- package/dist/server/config.js +80 -0
- package/dist/server/config.js.map +1 -0
- package/dist/server/control/client.d.ts +24 -0
- package/dist/server/control/client.d.ts.map +1 -0
- package/dist/server/control/client.js +115 -0
- package/dist/server/control/client.js.map +1 -0
- package/dist/server/control/control-api.d.ts +39 -0
- package/dist/server/control/control-api.d.ts.map +1 -0
- package/dist/server/control/control-api.js +291 -0
- package/dist/server/control/control-api.js.map +1 -0
- package/dist/server/control/control-service.d.ts +713 -0
- package/dist/server/control/control-service.d.ts.map +1 -0
- package/dist/server/control/control-service.js +693 -0
- package/dist/server/control/control-service.js.map +1 -0
- package/dist/server/control/openapi.d.ts +2 -0
- package/dist/server/control/openapi.d.ts.map +1 -0
- package/dist/server/control/openapi.js +370 -0
- package/dist/server/control/openapi.js.map +1 -0
- package/dist/server/data-plane/data-plane.d.ts +20 -0
- package/dist/server/data-plane/data-plane.d.ts.map +1 -0
- package/dist/server/data-plane/data-plane.js +335 -0
- package/dist/server/data-plane/data-plane.js.map +1 -0
- package/dist/server/data-plane/gateway-server.d.ts +13 -0
- package/dist/server/data-plane/gateway-server.d.ts.map +1 -0
- package/dist/server/data-plane/gateway-server.js +796 -0
- package/dist/server/data-plane/gateway-server.js.map +1 -0
- package/dist/server/data-plane/legacy-session.d.ts +18 -0
- package/dist/server/data-plane/legacy-session.d.ts.map +1 -0
- package/dist/server/data-plane/legacy-session.js +137 -0
- package/dist/server/data-plane/legacy-session.js.map +1 -0
- package/dist/server/data-plane/projection.d.ts +18 -0
- package/dist/server/data-plane/projection.d.ts.map +1 -0
- package/dist/server/data-plane/projection.js +44 -0
- package/dist/server/data-plane/projection.js.map +1 -0
- package/dist/server/data-plane/registry.d.ts +31 -0
- package/dist/server/data-plane/registry.d.ts.map +1 -0
- package/dist/server/data-plane/registry.js +129 -0
- package/dist/server/data-plane/registry.js.map +1 -0
- package/dist/server/data-plane/task-extension.d.ts +7 -0
- package/dist/server/data-plane/task-extension.d.ts.map +1 -0
- package/dist/server/data-plane/task-extension.js +33 -0
- package/dist/server/data-plane/task-extension.js.map +1 -0
- package/dist/server/data-plane/virtualization.d.ts +35 -0
- package/dist/server/data-plane/virtualization.d.ts.map +1 -0
- package/dist/server/data-plane/virtualization.js +228 -0
- package/dist/server/data-plane/virtualization.js.map +1 -0
- package/dist/server/domain/errors.d.ts +9 -0
- package/dist/server/domain/errors.d.ts.map +1 -0
- package/dist/server/domain/errors.js +19 -0
- package/dist/server/domain/errors.js.map +1 -0
- package/dist/server/domain/models.d.ts +720 -0
- package/dist/server/domain/models.d.ts.map +1 -0
- package/dist/server/domain/models.js +327 -0
- package/dist/server/domain/models.js.map +1 -0
- package/dist/server/main.d.ts +2 -0
- package/dist/server/main.d.ts.map +1 -0
- package/dist/server/main.js +37 -0
- package/dist/server/main.js.map +1 -0
- package/dist/server/manage/management-mcp.d.ts +27 -0
- package/dist/server/manage/management-mcp.d.ts.map +1 -0
- package/dist/server/manage/management-mcp.js +431 -0
- package/dist/server/manage/management-mcp.js.map +1 -0
- package/dist/server/market/catalog.d.ts +111 -0
- package/dist/server/market/catalog.d.ts.map +1 -0
- package/dist/server/market/catalog.js +784 -0
- package/dist/server/market/catalog.js.map +1 -0
- package/dist/server/market/installers.d.ts +64 -0
- package/dist/server/market/installers.d.ts.map +1 -0
- package/dist/server/market/installers.js +181 -0
- package/dist/server/market/installers.js.map +1 -0
- package/dist/server/market/market-service.d.ts +146 -0
- package/dist/server/market/market-service.d.ts.map +1 -0
- package/dist/server/market/market-service.js +930 -0
- package/dist/server/market/market-service.js.map +1 -0
- package/dist/server/observability/call-recorder.d.ts +22 -0
- package/dist/server/observability/call-recorder.d.ts.map +1 -0
- package/dist/server/observability/call-recorder.js +99 -0
- package/dist/server/observability/call-recorder.js.map +1 -0
- package/dist/server/observability/logger.d.ts +16 -0
- package/dist/server/observability/logger.d.ts.map +1 -0
- package/dist/server/observability/logger.js +25 -0
- package/dist/server/observability/logger.js.map +1 -0
- package/dist/server/security/api-keys.d.ts +15 -0
- package/dist/server/security/api-keys.d.ts.map +1 -0
- package/dist/server/security/api-keys.js +35 -0
- package/dist/server/security/api-keys.js.map +1 -0
- package/dist/server/security/auth-service.d.ts +26 -0
- package/dist/server/security/auth-service.d.ts.map +1 -0
- package/dist/server/security/auth-service.js +76 -0
- package/dist/server/security/auth-service.js.map +1 -0
- package/dist/server/security/control-session.d.ts +9 -0
- package/dist/server/security/control-session.d.ts.map +1 -0
- package/dist/server/security/control-session.js +51 -0
- package/dist/server/security/control-session.js.map +1 -0
- package/dist/server/security/credential-materializer.d.ts +20 -0
- package/dist/server/security/credential-materializer.d.ts.map +1 -0
- package/dist/server/security/credential-materializer.js +82 -0
- package/dist/server/security/credential-materializer.js.map +1 -0
- package/dist/server/security/cursor-codec.d.ts +13 -0
- package/dist/server/security/cursor-codec.d.ts.map +1 -0
- package/dist/server/security/cursor-codec.js +43 -0
- package/dist/server/security/cursor-codec.js.map +1 -0
- package/dist/server/security/oauth-routes.d.ts +21 -0
- package/dist/server/security/oauth-routes.d.ts.map +1 -0
- package/dist/server/security/oauth-routes.js +100 -0
- package/dist/server/security/oauth-routes.js.map +1 -0
- package/dist/server/security/oauth-server.d.ts +16 -0
- package/dist/server/security/oauth-server.d.ts.map +1 -0
- package/dist/server/security/oauth-server.js +544 -0
- package/dist/server/security/oauth-server.js.map +1 -0
- package/dist/server/security/secret-box.d.ts +7 -0
- package/dist/server/security/secret-box.d.ts.map +1 -0
- package/dist/server/security/secret-box.js +27 -0
- package/dist/server/security/secret-box.js.map +1 -0
- package/dist/server/security/secure-action.d.ts +21 -0
- package/dist/server/security/secure-action.d.ts.map +1 -0
- package/dist/server/security/secure-action.js +69 -0
- package/dist/server/security/secure-action.js.map +1 -0
- package/dist/server/storage/sqlite-store.d.ts +70 -0
- package/dist/server/storage/sqlite-store.d.ts.map +1 -0
- package/dist/server/storage/sqlite-store.js +1522 -0
- package/dist/server/storage/sqlite-store.js.map +1 -0
- package/dist/server/storage/store.d.ts +82 -0
- package/dist/server/storage/store.d.ts.map +1 -0
- package/dist/server/storage/store.js +2 -0
- package/dist/server/storage/store.js.map +1 -0
- package/dist/server/upstream/adapter.d.ts +52 -0
- package/dist/server/upstream/adapter.d.ts.map +1 -0
- package/dist/server/upstream/adapter.js +807 -0
- package/dist/server/upstream/adapter.js.map +1 -0
- package/dist/server/upstream/credential-resolver.d.ts +14 -0
- package/dist/server/upstream/credential-resolver.d.ts.map +1 -0
- package/dist/server/upstream/credential-resolver.js +56 -0
- package/dist/server/upstream/credential-resolver.js.map +1 -0
- package/dist/server/upstream/extension-transport.d.ts +17 -0
- package/dist/server/upstream/extension-transport.d.ts.map +1 -0
- package/dist/server/upstream/extension-transport.js +207 -0
- package/dist/server/upstream/extension-transport.js.map +1 -0
- package/dist/server/upstream/manager.d.ts +23 -0
- package/dist/server/upstream/manager.d.ts.map +1 -0
- package/dist/server/upstream/manager.js +324 -0
- package/dist/server/upstream/manager.js.map +1 -0
- package/dist/server/upstream/oauth-provider.d.ts +38 -0
- package/dist/server/upstream/oauth-provider.d.ts.map +1 -0
- package/dist/server/upstream/oauth-provider.js +225 -0
- package/dist/server/upstream/oauth-provider.js.map +1 -0
- package/dist/server/upstream/oauth-refresh.d.ts +65 -0
- package/dist/server/upstream/oauth-refresh.d.ts.map +1 -0
- package/dist/server/upstream/oauth-refresh.js +240 -0
- package/dist/server/upstream/oauth-refresh.js.map +1 -0
- package/dist/server/upstream/oauth-routes.d.ts +17 -0
- package/dist/server/upstream/oauth-routes.d.ts.map +1 -0
- package/dist/server/upstream/oauth-routes.js +87 -0
- package/dist/server/upstream/oauth-routes.js.map +1 -0
- package/dist/server/upstream/oauth-service.d.ts +143 -0
- package/dist/server/upstream/oauth-service.d.ts.map +1 -0
- package/dist/server/upstream/oauth-service.js +217 -0
- package/dist/server/upstream/oauth-service.js.map +1 -0
- package/dist/server/upstream/stable-json.d.ts +3 -0
- package/dist/server/upstream/stable-json.d.ts.map +1 -0
- package/dist/server/upstream/stable-json.js +17 -0
- package/dist/server/upstream/stable-json.js.map +1 -0
- package/package.json +69 -0
- package/skills/toolhome/SKILL.md +238 -0
- package/skills/toolhome/references/cli-reference.md +242 -0
- package/skills/toolhome/references/deployment.md +113 -0
- package/skills/toolhome/references/market-guide.md +128 -0
- package/skills/toolhome/references/oauth-guide.md +96 -0
- package/skills/toolhome/references/troubleshooting.md +133 -0
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { installArtifact, installerForEntry, } from './installers.js';
|
|
5
|
+
import { marketCatalog, entryPlane } from './catalog.js';
|
|
6
|
+
import { AppError } from '../domain/errors.js';
|
|
7
|
+
import { validateCliCredentialBindings } from '../security/credential-materializer.js';
|
|
8
|
+
import { fingerprint } from '../upstream/stable-json.js';
|
|
9
|
+
export class MarketService {
|
|
10
|
+
#service;
|
|
11
|
+
#store;
|
|
12
|
+
#actions;
|
|
13
|
+
#marketDir;
|
|
14
|
+
#uvEnv;
|
|
15
|
+
#jobs = new Map();
|
|
16
|
+
#activeTasks = new Set();
|
|
17
|
+
#installerProcesses = new Set();
|
|
18
|
+
#closing = false;
|
|
19
|
+
/** One in-flight mutating operation per catalog entry in this process. */
|
|
20
|
+
#entryOperations = new Map();
|
|
21
|
+
constructor(service, store, actions, marketDir, dataDir, uvIndexUrl) {
|
|
22
|
+
this.#service = service;
|
|
23
|
+
this.#store = store;
|
|
24
|
+
this.#actions = actions;
|
|
25
|
+
this.#marketDir = marketDir;
|
|
26
|
+
const uvRoot = dataDir === undefined ? marketDir : join(dataDir, '.uv');
|
|
27
|
+
this.#uvEnv = {
|
|
28
|
+
UV_CACHE_DIR: join(uvRoot, 'cache'),
|
|
29
|
+
UV_TOOL_DIR: join(uvRoot, 'tools'),
|
|
30
|
+
UV_TOOL_BIN_DIR: join(uvRoot, 'tools', 'bin'),
|
|
31
|
+
UV_COMPILE_BYTECODE: '0',
|
|
32
|
+
...(uvIndexUrl === undefined ? {} : { UV_DEFAULT_INDEX: uvIndexUrl }),
|
|
33
|
+
};
|
|
34
|
+
// A fresh process never carries in-flight installs; surface them as
|
|
35
|
+
// interrupted so they are visible and retryable instead of silently lost.
|
|
36
|
+
this.#store.markInterruptedInstallJobs();
|
|
37
|
+
}
|
|
38
|
+
list() {
|
|
39
|
+
const servers = this.#service.listServers();
|
|
40
|
+
return marketCatalog.map((entry) => {
|
|
41
|
+
const installation = this.#store.getInstallation(entry.id);
|
|
42
|
+
const plane = entryPlane(entry);
|
|
43
|
+
const installed = plane === 'cli'
|
|
44
|
+
? this.#store.getCliBySlug(entry.id) !== null
|
|
45
|
+
: servers.some((server) => server.slug === entry.id);
|
|
46
|
+
return {
|
|
47
|
+
...entry,
|
|
48
|
+
plane,
|
|
49
|
+
installed,
|
|
50
|
+
installedVersion: installation?.entryVersion ?? null,
|
|
51
|
+
updateAvailable: installation !== null &&
|
|
52
|
+
((entry.version ?? 'unpinned') !== installation.entryVersion ||
|
|
53
|
+
fingerprint(entry) !== installation.recipeRevision),
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Per-entry version drift: installed version vs the catalog pin (deterministic,
|
|
59
|
+
* no network) plus a best-effort upstream lookup (registry JSON, short timeout).
|
|
60
|
+
*/
|
|
61
|
+
async updates() {
|
|
62
|
+
const entries = marketCatalog
|
|
63
|
+
.map((entry) => ({ entry, installation: this.#store.getInstallation(entry.id) }))
|
|
64
|
+
.filter((item) => item.installation !== null);
|
|
65
|
+
return Promise.all(entries.map(async ({ entry, installation }) => {
|
|
66
|
+
const catalogVersion = entry.version ?? 'unpinned';
|
|
67
|
+
return {
|
|
68
|
+
entryId: entry.id,
|
|
69
|
+
installedVersion: installation.entryVersion,
|
|
70
|
+
catalogVersion,
|
|
71
|
+
updateAvailable: catalogVersion !== installation.entryVersion ||
|
|
72
|
+
fingerprint(entry) !== installation.recipeRevision,
|
|
73
|
+
latestUpstream: await this.#latestUpstream(entry),
|
|
74
|
+
};
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Explicit update to the catalog's current pin. Reinstalls the package, bumps
|
|
79
|
+
* the installation record, and restarts the server — the credential and
|
|
80
|
+
* visibility configuration survive. On failure the old process keeps running.
|
|
81
|
+
*/
|
|
82
|
+
async update(id) {
|
|
83
|
+
const entry = this.#entry(id);
|
|
84
|
+
this.#assertEntryAvailable(entry.id);
|
|
85
|
+
const installation = this.#store.getInstallation(entry.id);
|
|
86
|
+
if (!installation) {
|
|
87
|
+
throw new AppError('market_not_installed', `Market entry "${id}" is not installed`, 404);
|
|
88
|
+
}
|
|
89
|
+
const catalogVersion = entry.version ?? 'unpinned';
|
|
90
|
+
const recipeRevision = fingerprint(entry);
|
|
91
|
+
if (installation.entryVersion === catalogVersion &&
|
|
92
|
+
installation.recipeRevision === recipeRevision) {
|
|
93
|
+
return { jobId: null, status: 'up_to_date' };
|
|
94
|
+
}
|
|
95
|
+
const job = this.#createJob(entry, entry.version ?? null, 'updating');
|
|
96
|
+
this.#claimEntry(entry.id, job.record.id);
|
|
97
|
+
try {
|
|
98
|
+
this.#update(job, { step: 'starting update' });
|
|
99
|
+
this.#trackTask(this.#runUpdate(entry, installation, job));
|
|
100
|
+
return { jobId: job.record.id, status: 'updating' };
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
this.#releaseEntry(entry.id, job.record.id);
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
installations() {
|
|
108
|
+
return this.#store.listInstallations();
|
|
109
|
+
}
|
|
110
|
+
async close() {
|
|
111
|
+
this.#closing = true;
|
|
112
|
+
for (const child of this.#installerProcesses) {
|
|
113
|
+
this.#killInstallerProcess(child, 'SIGTERM');
|
|
114
|
+
}
|
|
115
|
+
await Promise.allSettled([...this.#activeTasks]);
|
|
116
|
+
}
|
|
117
|
+
getJob(jobId) {
|
|
118
|
+
const live = this.#jobs.get(jobId);
|
|
119
|
+
if (live) {
|
|
120
|
+
if (live.record.status === 'awaiting_secret') {
|
|
121
|
+
return this.#expireIfActionStale(live.record);
|
|
122
|
+
}
|
|
123
|
+
return this.#view(live);
|
|
124
|
+
}
|
|
125
|
+
const record = this.#store.getInstallJob(jobId);
|
|
126
|
+
if (!record)
|
|
127
|
+
throw new AppError('market_job_not_found', 'Install job not found', 404);
|
|
128
|
+
if (record.status === 'awaiting_secret')
|
|
129
|
+
return this.#expireIfActionStale(record);
|
|
130
|
+
return {
|
|
131
|
+
id: record.id,
|
|
132
|
+
entryId: record.entryId,
|
|
133
|
+
status: record.status,
|
|
134
|
+
step: record.step,
|
|
135
|
+
output: record.boundedOutput,
|
|
136
|
+
error: record.errorCode ?? undefined,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
async install(id, values = {}, principalId = 'cli') {
|
|
140
|
+
const entry = this.#entry(id);
|
|
141
|
+
this.#assertEntryAvailable(entry.id);
|
|
142
|
+
const existing = this.#store.getInstallation(entry.id);
|
|
143
|
+
if (existing) {
|
|
144
|
+
const target = existing.targetType === 'server'
|
|
145
|
+
? this.#store.getServer(existing.targetId)
|
|
146
|
+
: this.#store.getCli(existing.targetId);
|
|
147
|
+
if (target !== null) {
|
|
148
|
+
return {
|
|
149
|
+
jobId: null,
|
|
150
|
+
status: 'already_installed',
|
|
151
|
+
installed: {
|
|
152
|
+
entryId: entry.id,
|
|
153
|
+
version: existing.entryVersion,
|
|
154
|
+
targetType: existing.targetType,
|
|
155
|
+
targetId: existing.targetId,
|
|
156
|
+
slug: target.slug,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
// A prior uninstall may have removed the target after its marker write.
|
|
161
|
+
// Remove the stale marker so a retry can provision a clean target.
|
|
162
|
+
for (const installation of this.#store
|
|
163
|
+
.listInstallations()
|
|
164
|
+
.filter((item) => item.entryId === entry.id)) {
|
|
165
|
+
this.#store.deleteInstallation(installation.id);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const requirement of entry.requires) {
|
|
169
|
+
if (requirement.required && !requirement.secret && !values[requirement.name]) {
|
|
170
|
+
throw new AppError('market_missing_value', `Missing required value ${requirement.name}`, 400);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const missingSecrets = entry.requires.filter((requirement) => requirement.required && requirement.secret && !values[requirement.name]);
|
|
174
|
+
const requestedVersion = entry.version ?? null;
|
|
175
|
+
const job = this.#createJob(entry, requestedVersion, missingSecrets.length > 0 ? 'awaiting_secret' : 'installing');
|
|
176
|
+
this.#claimEntry(entry.id, job.record.id);
|
|
177
|
+
try {
|
|
178
|
+
if (missingSecrets.length > 0) {
|
|
179
|
+
// URL-mode elicitation: never accept the secret through tool arguments.
|
|
180
|
+
const { action, url } = this.#actions.create('market_install', job.record.id, principalId);
|
|
181
|
+
this.#updateRecord(job, { actionId: action.id });
|
|
182
|
+
return {
|
|
183
|
+
jobId: job.record.id,
|
|
184
|
+
status: 'awaiting_secret',
|
|
185
|
+
actionId: action.id,
|
|
186
|
+
actionUrl: url,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
this.#startInstall(entry, values, job);
|
|
190
|
+
return { jobId: job.record.id, status: 'installing' };
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
this.#releaseEntry(entry.id, job.record.id);
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
#startInstall(entry, values, job) {
|
|
198
|
+
this.#update(job, { step: 'starting' });
|
|
199
|
+
const task = entryPlane(entry) === 'cli'
|
|
200
|
+
? this.#runCliInstall(entry, values, job)
|
|
201
|
+
: this.#runInstall(entry, values, job);
|
|
202
|
+
this.#trackTask(task);
|
|
203
|
+
}
|
|
204
|
+
#trackTask(task) {
|
|
205
|
+
this.#activeTasks.add(task);
|
|
206
|
+
void task.then(() => this.#activeTasks.delete(task), () => this.#activeTasks.delete(task));
|
|
207
|
+
}
|
|
208
|
+
/** Completes a URL-mode secret action and resumes the linked install job. */
|
|
209
|
+
async completeAction(actionId, token, principalId, values) {
|
|
210
|
+
const actionRecord = this.#store.getSecureAction(actionId);
|
|
211
|
+
if (actionRecord === null) {
|
|
212
|
+
throw new AppError('secure_action_not_found', 'Secure action not found', 404);
|
|
213
|
+
}
|
|
214
|
+
let pending;
|
|
215
|
+
try {
|
|
216
|
+
pending = this.#actions.verify(actionId, token, principalId);
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
if (actionRecord.status === 'pending' && Date.parse(actionRecord.expiresAt) <= Date.now()) {
|
|
220
|
+
const expiredJob = this.#store.getInstallJob(actionRecord.target);
|
|
221
|
+
if (expiredJob)
|
|
222
|
+
this.#releaseEntry(expiredJob.entryId, expiredJob.id);
|
|
223
|
+
}
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
const pendingJob = this.#store.getInstallJob(pending.target);
|
|
227
|
+
if (!pendingJob || pendingJob.status !== 'awaiting_secret') {
|
|
228
|
+
if (pendingJob)
|
|
229
|
+
this.#releaseEntry(pendingJob.entryId, pendingJob.id);
|
|
230
|
+
throw new AppError('market_job_not_found', 'Install job not found or not awaiting a secret', 404);
|
|
231
|
+
}
|
|
232
|
+
const entry = this.#entry(pendingJob.entryId);
|
|
233
|
+
const heldBy = this.#entryOperations.get(entry.id);
|
|
234
|
+
if (heldBy !== undefined && heldBy.id !== pendingJob.id) {
|
|
235
|
+
throw new AppError('market_operation_in_progress', `Market entry "${entry.id}" already has an operation in progress`, 409);
|
|
236
|
+
}
|
|
237
|
+
const claimed = heldBy === undefined;
|
|
238
|
+
if (claimed)
|
|
239
|
+
this.#claimEntry(entry.id, pendingJob.id);
|
|
240
|
+
try {
|
|
241
|
+
const completed = this.#store.transaction(() => {
|
|
242
|
+
// Consume the action and transition its job together. If the job is no
|
|
243
|
+
// longer resumable, the transaction rolls back and the URL remains usable.
|
|
244
|
+
const action = this.#actions.complete(actionId, token, principalId, values);
|
|
245
|
+
const record = this.#store.getInstallJob(action.target);
|
|
246
|
+
if (!record || record.status !== 'awaiting_secret') {
|
|
247
|
+
throw new AppError('market_job_not_found', 'Install job not found or not awaiting a secret', 404);
|
|
248
|
+
}
|
|
249
|
+
const job = this.#store.updateInstallJob(record.id, {
|
|
250
|
+
status: 'installing',
|
|
251
|
+
step: 'starting',
|
|
252
|
+
});
|
|
253
|
+
return { action, job };
|
|
254
|
+
});
|
|
255
|
+
const live = this.#jobs.get(completed.action.target) ?? {
|
|
256
|
+
record: completed.job,
|
|
257
|
+
output: completed.job.boundedOutput,
|
|
258
|
+
};
|
|
259
|
+
live.record = completed.job;
|
|
260
|
+
this.#jobs.set(completed.action.target, live);
|
|
261
|
+
this.#startInstall(entry, values, live);
|
|
262
|
+
return this.#view(live);
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
if (claimed)
|
|
266
|
+
this.#releaseEntry(entry.id, pendingJob.id);
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** Reads a pending action's required secret fields after validating its URL token. */
|
|
271
|
+
secureActionInfo(actionId, token, principalId) {
|
|
272
|
+
if (token === undefined || token === '') {
|
|
273
|
+
throw new AppError('secure_action_invalid', 'Secure action token is required', 400);
|
|
274
|
+
}
|
|
275
|
+
const action = this.#actions.verify(actionId, token, principalId);
|
|
276
|
+
const job = this.#store.getInstallJob(action.target);
|
|
277
|
+
const entry = job ? marketCatalog.find((item) => item.id === job.entryId) : undefined;
|
|
278
|
+
return {
|
|
279
|
+
actionId: action.id,
|
|
280
|
+
status: action.status,
|
|
281
|
+
entryId: entry?.id ?? null,
|
|
282
|
+
entryName: entry?.name ?? null,
|
|
283
|
+
fields: (entry?.requires ?? [])
|
|
284
|
+
.filter((requirement) => requirement.secret)
|
|
285
|
+
.map((requirement) => ({
|
|
286
|
+
name: requirement.name,
|
|
287
|
+
description: requirement.description,
|
|
288
|
+
})),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
async uninstall(id) {
|
|
292
|
+
const entry = this.#entry(id);
|
|
293
|
+
const operationId = `uninstall:${randomUUID()}`;
|
|
294
|
+
this.#assertEntryAvailable(entry.id);
|
|
295
|
+
this.#entryOperations.set(entry.id, { kind: 'uninstall', id: operationId });
|
|
296
|
+
try {
|
|
297
|
+
if (entryPlane(entry) === 'cli') {
|
|
298
|
+
const cli = this.#store.getCliBySlug(entry.id);
|
|
299
|
+
if (!cli) {
|
|
300
|
+
throw new AppError('market_not_installed', `Market entry "${id}" is not installed`, 404);
|
|
301
|
+
}
|
|
302
|
+
this.#store.transaction(() => {
|
|
303
|
+
if (cli.credentialId)
|
|
304
|
+
this.#store.deleteCredential(cli.credentialId);
|
|
305
|
+
this.#store.deleteCli(cli.id);
|
|
306
|
+
for (const installation of this.#store
|
|
307
|
+
.listInstallations()
|
|
308
|
+
.filter((item) => item.entryId === entry.id)) {
|
|
309
|
+
this.#store.deleteInstallation(installation.id);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
const servers = this.#service.listServers().filter((server) => server.slug === entry.id);
|
|
315
|
+
if (servers.length === 0) {
|
|
316
|
+
throw new AppError('market_not_installed', `Market entry "${id}" is not installed`, 404);
|
|
317
|
+
}
|
|
318
|
+
const installations = this.#store
|
|
319
|
+
.listInstallations()
|
|
320
|
+
.filter((item) => item.entryId === entry.id);
|
|
321
|
+
// Remove the marker before touching runtime-backed resources. If marker
|
|
322
|
+
// cleanup fails, the target and its credential remain untouched.
|
|
323
|
+
this.#store.transaction(() => {
|
|
324
|
+
for (const installation of installations) {
|
|
325
|
+
this.#store.deleteInstallation(installation.id);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
try {
|
|
329
|
+
for (const server of servers) {
|
|
330
|
+
if (server.credentialId)
|
|
331
|
+
await this.#service.deleteCredential(server.credentialId);
|
|
332
|
+
await this.#service.deleteServer(server.id);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
// Restore the marker when an upstream/runtime deletion fails so the
|
|
337
|
+
// next uninstall can retry the same installation.
|
|
338
|
+
try {
|
|
339
|
+
this.#store.transaction(() => {
|
|
340
|
+
for (const installation of installations) {
|
|
341
|
+
this.#store.createInstallation({
|
|
342
|
+
source: installation.source,
|
|
343
|
+
entryId: installation.entryId,
|
|
344
|
+
entryVersion: installation.entryVersion,
|
|
345
|
+
recipeRevision: installation.recipeRevision,
|
|
346
|
+
targetType: installation.targetType,
|
|
347
|
+
targetId: installation.targetId,
|
|
348
|
+
credentialId: installation.credentialId,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// Preserve the original deletion error if compensation also fails.
|
|
355
|
+
}
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
this.#releaseEntry(entry.id, operationId);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// ── internals ───────────────────────────────────────────────────────────
|
|
365
|
+
/**
|
|
366
|
+
* CLI-plane install: provision the credential and CLI record, verifying or
|
|
367
|
+
* fetching an installer-backed artifact before writing the installation marker.
|
|
368
|
+
*/
|
|
369
|
+
async #runCliInstall(entry, values, job) {
|
|
370
|
+
let credential;
|
|
371
|
+
let cli;
|
|
372
|
+
let installation;
|
|
373
|
+
try {
|
|
374
|
+
this.#update(job, { step: 'creating credential' });
|
|
375
|
+
credential = this.#service.createCredential({
|
|
376
|
+
name: entry.name,
|
|
377
|
+
payload: this.#credentialPayload(entry, values),
|
|
378
|
+
});
|
|
379
|
+
this.#update(job, { step: 'installing CLI artifact' });
|
|
380
|
+
const cliArtifact = installerForEntry(entry);
|
|
381
|
+
const installedRuntime = cliArtifact === null
|
|
382
|
+
? null
|
|
383
|
+
: await installArtifact(cliArtifact, this.#installerContext(job));
|
|
384
|
+
this.#update(job, { step: 'creating CLI record' });
|
|
385
|
+
const projection = this.#cliProjection(entry, installedRuntime);
|
|
386
|
+
const provisioned = this.#store.transaction(() => {
|
|
387
|
+
const createdCli = this.#store.createCli({
|
|
388
|
+
slug: entry.id,
|
|
389
|
+
name: entry.name,
|
|
390
|
+
command: projection.command,
|
|
391
|
+
executionMode: projection.executionMode,
|
|
392
|
+
entrypoint: projection.entrypoint,
|
|
393
|
+
authStrategy: projection.authStrategy,
|
|
394
|
+
containerVolumes: projection.containerVolumes,
|
|
395
|
+
allowList: projection.allowList,
|
|
396
|
+
interactive: false,
|
|
397
|
+
credentialId: credential.id,
|
|
398
|
+
credentialBindings: entry.credentialBindings ?? {},
|
|
399
|
+
platform: entry.platform ?? null,
|
|
400
|
+
probe: entry.probe ?? { command: projection.command, args: ['--version'] },
|
|
401
|
+
enabled: true,
|
|
402
|
+
timeoutMs: entry.execTimeoutMs ?? 120_000,
|
|
403
|
+
maxOutputBytes: 64 * 1024,
|
|
404
|
+
});
|
|
405
|
+
const createdInstallation = this.#store.createInstallation({
|
|
406
|
+
source: 'curated',
|
|
407
|
+
entryId: entry.id,
|
|
408
|
+
entryVersion: entry.version ?? 'unpinned',
|
|
409
|
+
recipeRevision: fingerprint(entry),
|
|
410
|
+
targetType: 'cli',
|
|
411
|
+
targetId: createdCli.id,
|
|
412
|
+
credentialId: credential.id,
|
|
413
|
+
});
|
|
414
|
+
return { cli: createdCli, installation: createdInstallation };
|
|
415
|
+
});
|
|
416
|
+
cli = provisioned.cli;
|
|
417
|
+
installation = provisioned.installation;
|
|
418
|
+
this.#update(job, {
|
|
419
|
+
step: 'done',
|
|
420
|
+
result: {
|
|
421
|
+
entryId: entry.id,
|
|
422
|
+
cliId: cli.id,
|
|
423
|
+
slug: cli.slug,
|
|
424
|
+
credential,
|
|
425
|
+
installation,
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
this.#updateRecord(job, { status: 'completed', resultReference: installation.id });
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
try {
|
|
432
|
+
if (installation !== undefined)
|
|
433
|
+
this.#store.deleteInstallation(installation.id);
|
|
434
|
+
if (cli !== undefined)
|
|
435
|
+
this.#store.deleteCli(cli.id);
|
|
436
|
+
if (credential !== undefined)
|
|
437
|
+
await this.#service.deleteCredential(credential.id);
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
// Preserve the original install error if cleanup also fails.
|
|
441
|
+
}
|
|
442
|
+
try {
|
|
443
|
+
this.#update(job, {
|
|
444
|
+
step: 'failed',
|
|
445
|
+
error: error instanceof Error ? error.message : String(error),
|
|
446
|
+
});
|
|
447
|
+
this.#updateRecord(job, {
|
|
448
|
+
status: 'failed',
|
|
449
|
+
errorCode: error instanceof AppError ? error.code : 'market_install_failed',
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
// The runtime may have been torn down mid-install; nothing to persist.
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
finally {
|
|
457
|
+
this.#releaseEntry(entry.id, job.record.id);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
#createJob(entry, requestedVersion, status) {
|
|
461
|
+
const record = this.#store.createInstallJob({
|
|
462
|
+
entryId: entry.id,
|
|
463
|
+
requestedVersion,
|
|
464
|
+
// Unique per attempt: a failed/interrupted job keeps its row (audit),
|
|
465
|
+
// and retrying must not collide on the UNIQUE constraint. Post-success
|
|
466
|
+
// duplicates are already rejected by the already-installed check.
|
|
467
|
+
idempotencyKey: `${entry.id}:${requestedVersion ?? 'latest'}:${randomUUID()}`,
|
|
468
|
+
status,
|
|
469
|
+
step: status,
|
|
470
|
+
boundedOutput: '',
|
|
471
|
+
resultReference: null,
|
|
472
|
+
actionId: null,
|
|
473
|
+
errorCode: null,
|
|
474
|
+
});
|
|
475
|
+
const live = { record, output: '' };
|
|
476
|
+
this.#jobs.set(record.id, live);
|
|
477
|
+
return live;
|
|
478
|
+
}
|
|
479
|
+
#view(live) {
|
|
480
|
+
return {
|
|
481
|
+
id: live.record.id,
|
|
482
|
+
entryId: live.record.entryId,
|
|
483
|
+
status: live.record.status,
|
|
484
|
+
step: live.record.step,
|
|
485
|
+
output: live.output || live.record.boundedOutput,
|
|
486
|
+
...(live.result === undefined ? {} : { result: live.result }),
|
|
487
|
+
...(live.error === undefined ? {} : { error: live.error }),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* A waiting job whose secret link has passed its TTL can never resume. Fail it
|
|
492
|
+
* on read so pollers observe a terminal state, and release the entry claim so
|
|
493
|
+
* a retry can start; the action itself stays pending until someone consumes
|
|
494
|
+
* or the store prunes it.
|
|
495
|
+
*/
|
|
496
|
+
#expireIfActionStale(record) {
|
|
497
|
+
const action = record.actionId === null ? null : this.#store.getSecureAction(record.actionId);
|
|
498
|
+
if (action !== null &&
|
|
499
|
+
action.status === 'pending' &&
|
|
500
|
+
Date.parse(action.expiresAt) <= Date.now()) {
|
|
501
|
+
this.#releaseEntry(record.entryId, record.id);
|
|
502
|
+
const failed = this.#store.updateInstallJob(record.id, {
|
|
503
|
+
status: 'failed',
|
|
504
|
+
step: 'secure action expired',
|
|
505
|
+
errorCode: 'secure_action_expired',
|
|
506
|
+
});
|
|
507
|
+
return {
|
|
508
|
+
id: failed.id,
|
|
509
|
+
entryId: failed.entryId,
|
|
510
|
+
status: failed.status,
|
|
511
|
+
step: failed.step,
|
|
512
|
+
output: failed.boundedOutput,
|
|
513
|
+
error: failed.errorCode ?? undefined,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
id: record.id,
|
|
518
|
+
entryId: record.entryId,
|
|
519
|
+
status: record.status,
|
|
520
|
+
step: record.step,
|
|
521
|
+
output: record.boundedOutput,
|
|
522
|
+
error: record.errorCode ?? undefined,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
#update(job, patch) {
|
|
526
|
+
if (this.#closing)
|
|
527
|
+
return;
|
|
528
|
+
if (patch.step !== undefined) {
|
|
529
|
+
this.#updateRecord(job, { step: patch.step });
|
|
530
|
+
}
|
|
531
|
+
if (patch.output !== undefined) {
|
|
532
|
+
job.output = patch.output;
|
|
533
|
+
this.#updateRecord(job, { boundedOutput: patch.output });
|
|
534
|
+
}
|
|
535
|
+
if (patch.result !== undefined)
|
|
536
|
+
job.result = patch.result;
|
|
537
|
+
if (patch.error !== undefined)
|
|
538
|
+
job.error = patch.error;
|
|
539
|
+
}
|
|
540
|
+
#updateRecord(job, patch) {
|
|
541
|
+
if (this.#closing)
|
|
542
|
+
return;
|
|
543
|
+
job.record = this.#store.updateInstallJob(job.record.id, patch);
|
|
544
|
+
}
|
|
545
|
+
async #runInstall(entry, values, job) {
|
|
546
|
+
let credential;
|
|
547
|
+
let server;
|
|
548
|
+
let installation;
|
|
549
|
+
try {
|
|
550
|
+
const artifact = installerForEntry(entry);
|
|
551
|
+
const installedRuntime = artifact === null ? null : await installArtifact(artifact, this.#installerContext(job));
|
|
552
|
+
this.#update(job, { step: 'creating credential' });
|
|
553
|
+
credential = this.#service.createCredential({
|
|
554
|
+
name: entry.name,
|
|
555
|
+
payload: this.#credentialPayload(entry, values),
|
|
556
|
+
});
|
|
557
|
+
this.#update(job, { step: 'creating server' });
|
|
558
|
+
const transport = this.#serverTransport(entry, installedRuntime, values);
|
|
559
|
+
server = await this.#service.createServer({
|
|
560
|
+
slug: entry.id,
|
|
561
|
+
name: entry.name,
|
|
562
|
+
kind: installedRuntime === null ? 'remote' : 'home',
|
|
563
|
+
transport,
|
|
564
|
+
credentialId: credential.id,
|
|
565
|
+
enabled: true,
|
|
566
|
+
});
|
|
567
|
+
installation = this.#store.createInstallation({
|
|
568
|
+
source: 'curated',
|
|
569
|
+
entryId: entry.id,
|
|
570
|
+
entryVersion: entry.version ?? 'unpinned',
|
|
571
|
+
recipeRevision: fingerprint(entry),
|
|
572
|
+
targetType: 'server',
|
|
573
|
+
targetId: server.id,
|
|
574
|
+
credentialId: credential.id,
|
|
575
|
+
});
|
|
576
|
+
const result = { server, credential, installation };
|
|
577
|
+
this.#update(job, { step: 'done', result });
|
|
578
|
+
this.#updateRecord(job, { status: 'completed', resultReference: installation.id });
|
|
579
|
+
}
|
|
580
|
+
catch (error) {
|
|
581
|
+
try {
|
|
582
|
+
if (installation !== undefined)
|
|
583
|
+
this.#store.deleteInstallation(installation.id);
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
// Preserve the original install error if compensation also fails.
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
if (server !== undefined)
|
|
590
|
+
await this.#service.deleteServer(server.id);
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
// Preserve the original install error if compensation also fails.
|
|
594
|
+
}
|
|
595
|
+
try {
|
|
596
|
+
if (credential !== undefined)
|
|
597
|
+
await this.#service.deleteCredential(credential.id);
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
// Preserve the original install error if compensation also fails.
|
|
601
|
+
}
|
|
602
|
+
try {
|
|
603
|
+
this.#update(job, {
|
|
604
|
+
step: 'failed',
|
|
605
|
+
error: error instanceof Error ? error.message : String(error),
|
|
606
|
+
});
|
|
607
|
+
this.#updateRecord(job, {
|
|
608
|
+
status: 'failed',
|
|
609
|
+
errorCode: error instanceof AppError ? error.code : 'market_install_failed',
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
// The runtime may have been torn down mid-install; nothing to persist.
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
finally {
|
|
617
|
+
this.#releaseEntry(entry.id, job.record.id);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
#entry(id) {
|
|
621
|
+
const entry = marketCatalog.find((item) => item.id === id);
|
|
622
|
+
if (!entry)
|
|
623
|
+
throw new AppError('market_not_found', `Market entry "${id}" not found`, 404);
|
|
624
|
+
return entry;
|
|
625
|
+
}
|
|
626
|
+
#assertEntryAvailable(entryId) {
|
|
627
|
+
const operation = this.#entryOperations.get(entryId);
|
|
628
|
+
if (operation === undefined)
|
|
629
|
+
return;
|
|
630
|
+
if (operation.kind === 'job') {
|
|
631
|
+
const job = this.#store.getInstallJob(operation.id);
|
|
632
|
+
const active = job !== null && ['awaiting_secret', 'installing', 'updating'].includes(job.status);
|
|
633
|
+
if (!active) {
|
|
634
|
+
this.#entryOperations.delete(entryId);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
if (job.status === 'awaiting_secret') {
|
|
638
|
+
const action = job.actionId === null ? null : this.#store.getSecureAction(job.actionId);
|
|
639
|
+
if (action === null ||
|
|
640
|
+
action.status !== 'pending' ||
|
|
641
|
+
Date.parse(action.expiresAt) <= Date.now()) {
|
|
642
|
+
this.#entryOperations.delete(entryId);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
throw new AppError('market_operation_in_progress', `Market entry "${entryId}" already has an operation in progress`, 409);
|
|
648
|
+
}
|
|
649
|
+
#claimEntry(entryId, operationId) {
|
|
650
|
+
this.#assertEntryAvailable(entryId);
|
|
651
|
+
this.#entryOperations.set(entryId, { kind: 'job', id: operationId });
|
|
652
|
+
}
|
|
653
|
+
#releaseEntry(entryId, operationId) {
|
|
654
|
+
if (this.#entryOperations.get(entryId)?.id === operationId) {
|
|
655
|
+
this.#entryOperations.delete(entryId);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
async #runUpdate(entry, installation, job) {
|
|
659
|
+
try {
|
|
660
|
+
const artifact = installerForEntry(entry);
|
|
661
|
+
const installedRuntime = artifact === null
|
|
662
|
+
? null
|
|
663
|
+
: await installArtifact(artifact, this.#installerContext(job), { force: true });
|
|
664
|
+
if (installation.targetType === 'server') {
|
|
665
|
+
const server = this.#store.getServer(installation.targetId);
|
|
666
|
+
if (server === null) {
|
|
667
|
+
throw new AppError('server_not_found', 'Installed Market server not found', 404);
|
|
668
|
+
}
|
|
669
|
+
const values = this.#credentialEnvValues(installation.credentialId);
|
|
670
|
+
const transport = this.#serverTransport(entry, installedRuntime, values);
|
|
671
|
+
this.#update(job, { step: 'updating server runtime' });
|
|
672
|
+
await this.#service.updateServer(server.id, { transport });
|
|
673
|
+
this.#update(job, { step: 'restarting server' });
|
|
674
|
+
// A failed restart must not roll the job back — the updated target is
|
|
675
|
+
// persisted and can be restarted manually later.
|
|
676
|
+
let restartError;
|
|
677
|
+
try {
|
|
678
|
+
await this.#service.restartServer(server.id);
|
|
679
|
+
}
|
|
680
|
+
catch (error) {
|
|
681
|
+
restartError = error instanceof Error ? error.message : String(error);
|
|
682
|
+
}
|
|
683
|
+
this.#update(job, { step: 'updating installation record' });
|
|
684
|
+
const updated = this.#store.updateInstallation(installation.id, {
|
|
685
|
+
entryVersion: entry.version ?? 'unpinned',
|
|
686
|
+
recipeRevision: fingerprint(entry),
|
|
687
|
+
});
|
|
688
|
+
this.#update(job, {
|
|
689
|
+
step: 'done',
|
|
690
|
+
result: {
|
|
691
|
+
entryId: entry.id,
|
|
692
|
+
version: updated.entryVersion,
|
|
693
|
+
targetType: installation.targetType,
|
|
694
|
+
targetId: installation.targetId,
|
|
695
|
+
...(restartError === undefined ? {} : { restartError }),
|
|
696
|
+
},
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
const cli = this.#store.getCli(installation.targetId);
|
|
701
|
+
if (cli === null) {
|
|
702
|
+
throw new AppError('cli_not_found', 'Installed Market CLI not found', 404);
|
|
703
|
+
}
|
|
704
|
+
const credentialPayload = installation.credentialId === null
|
|
705
|
+
? null
|
|
706
|
+
: this.#store.getCredentialPayload(installation.credentialId);
|
|
707
|
+
if (credentialPayload !== null) {
|
|
708
|
+
validateCliCredentialBindings(credentialPayload, entry.credentialBindings ?? {});
|
|
709
|
+
}
|
|
710
|
+
const projection = this.#cliProjection(entry, installedRuntime);
|
|
711
|
+
this.#update(job, { step: 'updating CLI runtime' });
|
|
712
|
+
this.#store.updateCli(cli.id, {
|
|
713
|
+
command: projection.command,
|
|
714
|
+
executionMode: projection.executionMode,
|
|
715
|
+
entrypoint: projection.entrypoint,
|
|
716
|
+
authStrategy: projection.authStrategy,
|
|
717
|
+
containerVolumes: projection.containerVolumes,
|
|
718
|
+
allowList: projection.allowList,
|
|
719
|
+
credentialBindings: entry.credentialBindings ?? {},
|
|
720
|
+
platform: entry.platform ?? null,
|
|
721
|
+
probe: entry.probe ?? { command: projection.command, args: ['--version'] },
|
|
722
|
+
});
|
|
723
|
+
this.#update(job, { step: 'updating installation record' });
|
|
724
|
+
const updated = this.#store.updateInstallation(installation.id, {
|
|
725
|
+
entryVersion: entry.version ?? 'unpinned',
|
|
726
|
+
recipeRevision: fingerprint(entry),
|
|
727
|
+
});
|
|
728
|
+
this.#update(job, {
|
|
729
|
+
step: 'done',
|
|
730
|
+
result: {
|
|
731
|
+
entryId: entry.id,
|
|
732
|
+
version: updated.entryVersion,
|
|
733
|
+
targetType: installation.targetType,
|
|
734
|
+
targetId: installation.targetId,
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
this.#updateRecord(job, { status: 'completed', resultReference: installation.id });
|
|
739
|
+
}
|
|
740
|
+
catch (error) {
|
|
741
|
+
try {
|
|
742
|
+
this.#update(job, {
|
|
743
|
+
step: 'failed',
|
|
744
|
+
error: error instanceof Error ? error.message : String(error),
|
|
745
|
+
});
|
|
746
|
+
this.#updateRecord(job, {
|
|
747
|
+
status: 'failed',
|
|
748
|
+
errorCode: error instanceof AppError ? error.code : 'market_update_failed',
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
// Runtime torn down mid-update; nothing to persist.
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
finally {
|
|
756
|
+
this.#releaseEntry(entry.id, job.record.id);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
/** Best-effort latest upstream version; null when unreachable or unsupported. */
|
|
760
|
+
async #latestUpstream(entry) {
|
|
761
|
+
const pkg = entry.package;
|
|
762
|
+
if (pkg === undefined)
|
|
763
|
+
return null;
|
|
764
|
+
const url = entry.kind === 'uvx'
|
|
765
|
+
? `https://pypi.org/pypi/${pkg}/json`
|
|
766
|
+
: entry.kind === 'home-stdio'
|
|
767
|
+
? `https://registry.npmmirror.com/${pkg}/latest`
|
|
768
|
+
: null;
|
|
769
|
+
if (url === null)
|
|
770
|
+
return null;
|
|
771
|
+
try {
|
|
772
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
773
|
+
if (!response.ok)
|
|
774
|
+
return null;
|
|
775
|
+
const body = (await response.json());
|
|
776
|
+
const version = typeof body.version === 'string'
|
|
777
|
+
? body.version
|
|
778
|
+
: typeof body.info?.version === 'string'
|
|
779
|
+
? body.info.version
|
|
780
|
+
: null;
|
|
781
|
+
return version;
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
#serverTransport(entry, runtime, values) {
|
|
788
|
+
if (runtime === null) {
|
|
789
|
+
return {
|
|
790
|
+
type: 'streamable-http',
|
|
791
|
+
url: entry.url ?? '',
|
|
792
|
+
protocolMode: 'auto',
|
|
793
|
+
allowSseFallback: false,
|
|
794
|
+
headers: {},
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
const args = (entry.argsTemplate ?? []).map((argument) => argument.replace(/\$\{([^}]+)\}/g, (_, key) => values[key] ?? ''));
|
|
798
|
+
if (runtime.executionMode === 'host') {
|
|
799
|
+
return {
|
|
800
|
+
type: 'stdio',
|
|
801
|
+
command: runtime.command,
|
|
802
|
+
args,
|
|
803
|
+
env: entry.kind === 'uvx' ? { ...this.#uvEnv } : {},
|
|
804
|
+
protocolMode: 'auto',
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
return {
|
|
808
|
+
type: 'stdio',
|
|
809
|
+
command: 'docker',
|
|
810
|
+
args: [
|
|
811
|
+
'run',
|
|
812
|
+
'--rm',
|
|
813
|
+
'-i',
|
|
814
|
+
runtime.command,
|
|
815
|
+
...(runtime.entrypoint === null ? [] : [runtime.entrypoint]),
|
|
816
|
+
],
|
|
817
|
+
env: {},
|
|
818
|
+
protocolMode: 'auto',
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
#credentialEnvValues(credentialId) {
|
|
822
|
+
if (credentialId === null)
|
|
823
|
+
return {};
|
|
824
|
+
const payload = this.#store.getCredentialPayload(credentialId);
|
|
825
|
+
return payload?.type === 'env' ? payload.variables : {};
|
|
826
|
+
}
|
|
827
|
+
#cliProjection(entry, runtime = null) {
|
|
828
|
+
const artifact = installerForEntry(entry);
|
|
829
|
+
const allowList = entry.allowList;
|
|
830
|
+
if (allowList === undefined || allowList.allow.length === 0) {
|
|
831
|
+
throw new AppError('market_cli_allow_list_required', `Market CLI entry "${entry.id}" must declare an argv allow-list`, 400);
|
|
832
|
+
}
|
|
833
|
+
return {
|
|
834
|
+
command: runtime?.command ??
|
|
835
|
+
(entry.kind === 'cli-image'
|
|
836
|
+
? artifact?.type === 'docker'
|
|
837
|
+
? artifact.image
|
|
838
|
+
: (entry.image ?? '')
|
|
839
|
+
: (entry.bin ?? entry.id)),
|
|
840
|
+
executionMode: runtime?.executionMode ?? (entry.kind === 'cli-image' ? 'docker' : 'host'),
|
|
841
|
+
entrypoint: runtime?.entrypoint ?? (entry.kind === 'cli-image' ? (entry.entrypoint ?? null) : null),
|
|
842
|
+
authStrategy: entry.cliRuntime?.authStrategy ?? 'none',
|
|
843
|
+
containerVolumes: (entry.cliRuntime?.containerVolumes ?? []).map((volume) => ({
|
|
844
|
+
source: volume.source,
|
|
845
|
+
target: volume.target,
|
|
846
|
+
readOnly: volume.readOnly ?? false,
|
|
847
|
+
})),
|
|
848
|
+
allowList,
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
#credentialPayload(entry, values) {
|
|
852
|
+
switch (entry.credential.type) {
|
|
853
|
+
case 'oauth':
|
|
854
|
+
return { type: 'oauth', tokenType: 'Bearer' };
|
|
855
|
+
case 'env':
|
|
856
|
+
return { type: 'env', variables: values };
|
|
857
|
+
case 'bearer':
|
|
858
|
+
return { type: 'bearer', token: values[entry.credential.tokenKey] ?? '' };
|
|
859
|
+
case 'api-key':
|
|
860
|
+
return {
|
|
861
|
+
type: 'api-key',
|
|
862
|
+
headerName: entry.credential.headerName,
|
|
863
|
+
value: values[entry.credential.valueKey] ?? '',
|
|
864
|
+
};
|
|
865
|
+
case 'headers':
|
|
866
|
+
return {
|
|
867
|
+
type: 'headers',
|
|
868
|
+
headers: Object.fromEntries(entry.credential.headers.map((header) => [
|
|
869
|
+
header.name,
|
|
870
|
+
header.valueKey ? (values[header.valueKey] ?? '') : (header.value ?? ''),
|
|
871
|
+
])),
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
#installerContext(job) {
|
|
876
|
+
return {
|
|
877
|
+
marketDir: this.#marketDir,
|
|
878
|
+
uvEnv: this.#uvEnv,
|
|
879
|
+
update: (patch) => this.#update(job, patch),
|
|
880
|
+
runCommand: (command, args, options) => this.#runInstallerCommand(job, command, args, options),
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
#killInstallerProcess(child, signal) {
|
|
884
|
+
if (child.pid === undefined)
|
|
885
|
+
return;
|
|
886
|
+
try {
|
|
887
|
+
process.kill(-child.pid, signal);
|
|
888
|
+
}
|
|
889
|
+
catch {
|
|
890
|
+
try {
|
|
891
|
+
child.kill(signal);
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
// process already exited
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
#runInstallerCommand(job, command, args, options = {}) {
|
|
899
|
+
return new Promise((resolve) => {
|
|
900
|
+
const child = spawn(command, args, {
|
|
901
|
+
detached: true,
|
|
902
|
+
env: { ...process.env, ...(options.env ?? {}) },
|
|
903
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
904
|
+
});
|
|
905
|
+
this.#installerProcesses.add(child);
|
|
906
|
+
let output = '';
|
|
907
|
+
const append = (chunk) => {
|
|
908
|
+
output = `${output}${chunk}`.slice(-4000);
|
|
909
|
+
this.#update(job, { output });
|
|
910
|
+
};
|
|
911
|
+
child.stdout.on('data', (chunk) => append(String(chunk)));
|
|
912
|
+
child.stderr.on('data', (chunk) => append(String(chunk)));
|
|
913
|
+
const timer = setTimeout(() => this.#killInstallerProcess(child, 'SIGKILL'), options.timeoutMs ?? 300_000);
|
|
914
|
+
const cleanup = () => {
|
|
915
|
+
this.#installerProcesses.delete(child);
|
|
916
|
+
};
|
|
917
|
+
child.on('close', (code) => {
|
|
918
|
+
clearTimeout(timer);
|
|
919
|
+
cleanup();
|
|
920
|
+
resolve({ code: code ?? 1, output });
|
|
921
|
+
});
|
|
922
|
+
child.on('error', (error) => {
|
|
923
|
+
clearTimeout(timer);
|
|
924
|
+
cleanup();
|
|
925
|
+
resolve({ code: 1, output: `${command}: ${error.message}` });
|
|
926
|
+
});
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
//# sourceMappingURL=market-service.js.map
|