@parall/daemon 1.30.0 → 1.32.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/bundle/manifest.json +17 -11
- package/bundle/package.json +1 -0
- package/bundle/parall-claude-agent.js +27671 -337
- package/bundle/parall-codex-agent.js +27715 -375
- package/bundle/parall-daemon.js +30421 -1355
- package/bundle/parall-openclaw-agent.js +51 -26
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +122 -72
- package/dist/clip-runtime/clip-installer.d.ts +44 -0
- package/dist/clip-runtime/clip-installer.d.ts.map +1 -0
- package/dist/clip-runtime/clip-installer.js +501 -0
- package/dist/clip-runtime/clip-provider.d.ts +76 -0
- package/dist/clip-runtime/clip-provider.d.ts.map +1 -0
- package/dist/clip-runtime/clip-provider.js +402 -0
- package/dist/clip-runtime/index.d.ts +7 -0
- package/dist/clip-runtime/index.d.ts.map +1 -0
- package/dist/clip-runtime/index.js +5 -0
- package/dist/clip-runtime/ipc.d.ts +94 -0
- package/dist/clip-runtime/ipc.d.ts.map +1 -0
- package/dist/clip-runtime/ipc.js +98 -0
- package/dist/clip-runtime/manifest.d.ts +74 -0
- package/dist/clip-runtime/manifest.d.ts.map +1 -0
- package/dist/clip-runtime/manifest.js +181 -0
- package/dist/clip-runtime/process-manager.d.ts +57 -0
- package/dist/clip-runtime/process-manager.d.ts.map +1 -0
- package/dist/clip-runtime/process-manager.js +354 -0
- package/dist/clip-runtime/process.d.ts +59 -0
- package/dist/clip-runtime/process.d.ts.map +1 -0
- package/dist/clip-runtime/process.js +350 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +36 -19
- package/dist/filesystem.d.ts +1 -1
- package/dist/filesystem.d.ts.map +1 -1
- package/dist/filesystem.js +51 -53
- package/dist/index.js +46 -14
- package/dist/runtimes.d.ts +10 -8
- package/dist/runtimes.d.ts.map +1 -1
- package/dist/runtimes.js +63 -95
- package/dist/supervisor.d.ts +12 -3
- package/dist/supervisor.d.ts.map +1 -1
- package/dist/supervisor.js +272 -71
- package/dist/updater-manifest.d.ts +41 -0
- package/dist/updater-manifest.d.ts.map +1 -0
- package/dist/updater-manifest.js +94 -0
- package/dist/updater.d.ts +60 -0
- package/dist/updater.d.ts.map +1 -0
- package/dist/updater.js +427 -0
- package/dist/workspace.d.ts +2 -2
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +112 -112
- package/package.json +6 -6
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClipInstaller — Downloads and installs clips from the Pinix registry.
|
|
3
|
+
*
|
|
4
|
+
* Flow: parse source → resolve version → download tarball → verify checksum →
|
|
5
|
+
* extract to clipsDir/{alias}/ → install deps → return result.
|
|
6
|
+
*
|
|
7
|
+
* Uses only Node.js built-ins (no external npm packages).
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import * as crypto from 'node:crypto';
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { pipeline } from 'node:stream/promises';
|
|
14
|
+
const DEFAULT_REGISTRY_URL = 'https://api.pinixai.com';
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Source parsing
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
/**
|
|
19
|
+
* Parse a source string into scope, name, and optional version.
|
|
20
|
+
* "@scope/name" → scope="scope", name="name", version=null
|
|
21
|
+
* "@scope/name@1.0.0" → scope="scope", name="name", version="1.0.0"
|
|
22
|
+
* "name" → scope=null, name="name", version=null
|
|
23
|
+
* "name@1.0.0" → scope=null, name="name", version="1.0.0"
|
|
24
|
+
*/
|
|
25
|
+
export function parseSource(source) {
|
|
26
|
+
const trimmed = source.trim();
|
|
27
|
+
if (!trimmed)
|
|
28
|
+
throw new Error('empty source string');
|
|
29
|
+
if (trimmed.startsWith('@')) {
|
|
30
|
+
// Scoped: "@scope/name" or "@scope/name@version"
|
|
31
|
+
const slashIdx = trimmed.indexOf('/');
|
|
32
|
+
if (slashIdx < 0)
|
|
33
|
+
throw new Error(`invalid scoped source "${trimmed}" — missing "/"`);
|
|
34
|
+
const scope = trimmed.slice(1, slashIdx);
|
|
35
|
+
const rest = trimmed.slice(slashIdx + 1);
|
|
36
|
+
// The version delimiter is the *last* "@" after the scope prefix
|
|
37
|
+
const atIdx = rest.indexOf('@');
|
|
38
|
+
if (atIdx > 0) {
|
|
39
|
+
const name = rest.slice(0, atIdx);
|
|
40
|
+
const version = rest.slice(atIdx + 1);
|
|
41
|
+
if (!version)
|
|
42
|
+
throw new Error(`invalid source "${trimmed}" — empty version after "@"`);
|
|
43
|
+
return { scope, name, fullName: `@${scope}/${name}`, version };
|
|
44
|
+
}
|
|
45
|
+
return { scope, name: rest, fullName: `@${scope}/${rest}`, version: null };
|
|
46
|
+
}
|
|
47
|
+
// Unscoped: "name" or "name@version"
|
|
48
|
+
const atIdx = trimmed.indexOf('@');
|
|
49
|
+
if (atIdx > 0) {
|
|
50
|
+
const name = trimmed.slice(0, atIdx);
|
|
51
|
+
const version = trimmed.slice(atIdx + 1);
|
|
52
|
+
if (!version)
|
|
53
|
+
throw new Error(`invalid source "${trimmed}" — empty version after "@"`);
|
|
54
|
+
return { scope: null, name, fullName: name, version };
|
|
55
|
+
}
|
|
56
|
+
return { scope: null, name: trimmed, fullName: trimmed, version: null };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Derive a filesystem-safe alias from a package name.
|
|
60
|
+
* "@pinix/github-tools" → "github-tools"
|
|
61
|
+
* "my-clip" → "my-clip"
|
|
62
|
+
*/
|
|
63
|
+
function deriveAlias(parsed) {
|
|
64
|
+
// Normalize to server's allowed alias charset: ^[a-z0-9][a-z0-9-_]*$
|
|
65
|
+
return parsed.name
|
|
66
|
+
.toLowerCase()
|
|
67
|
+
.replace(/[^a-z0-9-_]/g, '-')
|
|
68
|
+
.replace(/^[^a-z0-9]+/, '');
|
|
69
|
+
}
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// HTTP helper (node:https / node:http)
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
async function httpGet(url, maxRedirects = 10) {
|
|
74
|
+
if (maxRedirects < 0)
|
|
75
|
+
throw new Error(`too many redirects for ${url}`);
|
|
76
|
+
const mod = url.startsWith('https') ? await import('node:https') : await import('node:http');
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const req = mod.get(url, { headers: { Accept: 'application/json' } }, (res) => {
|
|
79
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
80
|
+
httpGet(res.headers.location, maxRedirects - 1).then(resolve, reject);
|
|
81
|
+
res.resume();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const chunks = [];
|
|
85
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
86
|
+
res.on('end', () => {
|
|
87
|
+
resolve({
|
|
88
|
+
statusCode: res.statusCode ?? 0,
|
|
89
|
+
headers: res.headers,
|
|
90
|
+
body: Buffer.concat(chunks),
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
res.on('error', reject);
|
|
94
|
+
});
|
|
95
|
+
req.on('error', reject);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Download a URL to a file, returning the file path.
|
|
100
|
+
* Follows redirects. Streams to disk (does not buffer the entire tarball in memory).
|
|
101
|
+
*/
|
|
102
|
+
async function httpDownload(url, destPath, maxRedirects = 10) {
|
|
103
|
+
if (maxRedirects < 0)
|
|
104
|
+
throw new Error(`too many redirects for ${url}`);
|
|
105
|
+
const mod = url.startsWith('https') ? await import('node:https') : await import('node:http');
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
const req = mod.get(url, (res) => {
|
|
108
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
109
|
+
httpDownload(res.headers.location, destPath, maxRedirects - 1).then(resolve, reject);
|
|
110
|
+
res.resume();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (!res.statusCode || res.statusCode >= 400) {
|
|
114
|
+
res.resume();
|
|
115
|
+
reject(new Error(`download failed: HTTP ${res.statusCode} for ${url}`));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const ws = fs.createWriteStream(destPath);
|
|
119
|
+
pipeline(res, ws).then(resolve, reject);
|
|
120
|
+
});
|
|
121
|
+
req.on('error', reject);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Registry client
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
/**
|
|
128
|
+
* Resolve version metadata from the Pinix registry.
|
|
129
|
+
*
|
|
130
|
+
* Tries the Pinix REST API first, falls back to npm-style endpoints.
|
|
131
|
+
*/
|
|
132
|
+
async function resolveVersionMeta(registryUrl, parsed) {
|
|
133
|
+
// Build the package path for the API
|
|
134
|
+
const pkgPath = parsed.scope
|
|
135
|
+
? `${encodeURIComponent(parsed.scope)}/${encodeURIComponent(parsed.name)}`
|
|
136
|
+
: encodeURIComponent(parsed.name);
|
|
137
|
+
// --- Attempt 1: Pinix REST API ---
|
|
138
|
+
const pinixResult = await tryPinixApi(registryUrl, pkgPath, parsed);
|
|
139
|
+
if (pinixResult)
|
|
140
|
+
return pinixResult;
|
|
141
|
+
// --- Attempt 2: npm-style registry ---
|
|
142
|
+
const npmResult = await tryNpmApi(registryUrl, parsed);
|
|
143
|
+
if (npmResult)
|
|
144
|
+
return npmResult;
|
|
145
|
+
throw new Error(`could not resolve "${parsed.fullName}${parsed.version ? `@${parsed.version}` : ''}" ` +
|
|
146
|
+
`from registry ${registryUrl}`);
|
|
147
|
+
}
|
|
148
|
+
async function tryPinixApi(registryUrl, pkgPath, parsed) {
|
|
149
|
+
try {
|
|
150
|
+
// Pinix registry API: GET /packages/{name} (not /api/v1/packages/)
|
|
151
|
+
// For scoped packages, URL-encode each segment: /packages/%40scope/name
|
|
152
|
+
const scopedPath = parsed.scope
|
|
153
|
+
? `${encodeURIComponent(`@${parsed.scope}`)}/${encodeURIComponent(parsed.name)}`
|
|
154
|
+
: encodeURIComponent(parsed.fullName);
|
|
155
|
+
// If a specific version is requested, try the versioned endpoint first
|
|
156
|
+
if (parsed.version) {
|
|
157
|
+
const versionUrl = `${registryUrl}/packages/${scopedPath}/${encodeURIComponent(parsed.version)}`;
|
|
158
|
+
const vRes = await httpGet(versionUrl);
|
|
159
|
+
if (vRes.statusCode === 200) {
|
|
160
|
+
return extractPinixVersionMeta(vRes.body, parsed, registryUrl, scopedPath);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Fetch package metadata (all versions / dist-tags)
|
|
164
|
+
const metaUrl = `${registryUrl}/packages/${scopedPath}`;
|
|
165
|
+
const res = await httpGet(metaUrl);
|
|
166
|
+
if (res.statusCode !== 200)
|
|
167
|
+
return null;
|
|
168
|
+
const meta = JSON.parse(res.body.toString('utf-8'));
|
|
169
|
+
// Find the target version — check both dist_tags (snake_case) and dist-tags (kebab)
|
|
170
|
+
let version = parsed.version;
|
|
171
|
+
if (!version) {
|
|
172
|
+
const distTags = (meta['dist_tags'] ?? meta['dist-tags']);
|
|
173
|
+
version = distTags?.latest ?? distTags?.stable ?? null;
|
|
174
|
+
if (!version) {
|
|
175
|
+
const versions = meta.versions;
|
|
176
|
+
if (versions) {
|
|
177
|
+
const keys = Object.keys(versions);
|
|
178
|
+
version = keys[keys.length - 1] ?? null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!version)
|
|
183
|
+
return null;
|
|
184
|
+
// Get version-specific metadata from embedded versions
|
|
185
|
+
const versions = meta.versions;
|
|
186
|
+
const versionMeta = versions?.[version];
|
|
187
|
+
if (versionMeta) {
|
|
188
|
+
return extractVersionFromMeta(versionMeta, version, registryUrl, parsed);
|
|
189
|
+
}
|
|
190
|
+
// If not embedded, fetch via versioned endpoint
|
|
191
|
+
const versionUrl = `${registryUrl}/packages/${scopedPath}/${encodeURIComponent(version)}`;
|
|
192
|
+
const vRes = await httpGet(versionUrl);
|
|
193
|
+
if (vRes.statusCode === 200) {
|
|
194
|
+
return extractPinixVersionMeta(vRes.body, parsed, registryUrl, scopedPath);
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async function tryNpmApi(registryUrl, parsed) {
|
|
203
|
+
try {
|
|
204
|
+
// npm-style: GET /{fullName} (e.g., /@pinix/github-tools)
|
|
205
|
+
const encodedName = parsed.scope
|
|
206
|
+
? `@${encodeURIComponent(parsed.scope)}%2f${encodeURIComponent(parsed.name)}`
|
|
207
|
+
: encodeURIComponent(parsed.name);
|
|
208
|
+
const metaUrl = `${registryUrl}/${encodedName}`;
|
|
209
|
+
const res = await httpGet(metaUrl);
|
|
210
|
+
if (res.statusCode !== 200)
|
|
211
|
+
return null;
|
|
212
|
+
const meta = JSON.parse(res.body.toString('utf-8'));
|
|
213
|
+
let version = parsed.version;
|
|
214
|
+
if (!version) {
|
|
215
|
+
const distTags = meta['dist-tags'];
|
|
216
|
+
version = distTags?.latest ?? null;
|
|
217
|
+
}
|
|
218
|
+
if (!version)
|
|
219
|
+
return null;
|
|
220
|
+
const versions = meta.versions;
|
|
221
|
+
const versionMeta = versions?.[version];
|
|
222
|
+
if (!versionMeta)
|
|
223
|
+
return null;
|
|
224
|
+
return extractVersionFromMeta(versionMeta, version, registryUrl, parsed);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function extractPinixVersionMeta(body, parsed, registryUrl, scopedPath) {
|
|
231
|
+
const data = JSON.parse(body.toString('utf-8'));
|
|
232
|
+
const version = data.version ?? parsed.version;
|
|
233
|
+
if (!version)
|
|
234
|
+
return null;
|
|
235
|
+
const meta = extractVersionFromMeta(data, version, registryUrl, parsed);
|
|
236
|
+
if (meta && !meta.tarball) {
|
|
237
|
+
// Pinix download endpoint: GET /packages/{name}/{version}/download
|
|
238
|
+
meta.tarball = `${registryUrl}/packages/${scopedPath}/${encodeURIComponent(version)}/download`;
|
|
239
|
+
}
|
|
240
|
+
return meta;
|
|
241
|
+
}
|
|
242
|
+
function extractVersionFromMeta(meta, version, registryUrl, parsed) {
|
|
243
|
+
const dist = meta.dist;
|
|
244
|
+
// Pinix uses "tarball" / "TarballURL" (absolute) or "tarball_url" (relative marker).
|
|
245
|
+
let tarball = dist?.tarball ?? dist?.TarballURL ?? meta.tarball;
|
|
246
|
+
if (!tarball && dist?.tarball_url && registryUrl) {
|
|
247
|
+
// Pinix registry: dist.tarball_url is a relative pointer; the actual fetch
|
|
248
|
+
// route is GET /packages/{scope}/{name}/{version}/download (streams the blob).
|
|
249
|
+
const scopedPath = parsed.scope
|
|
250
|
+
? `${encodeURIComponent(`@${parsed.scope}`)}/${encodeURIComponent(parsed.name)}`
|
|
251
|
+
: encodeURIComponent(parsed.name);
|
|
252
|
+
tarball = `${registryUrl}/packages/${scopedPath}/${encodeURIComponent(version)}/download`;
|
|
253
|
+
}
|
|
254
|
+
if (!tarball && registryUrl) {
|
|
255
|
+
// npm-style fallback tarball URL
|
|
256
|
+
const namePart = parsed.scope ? `${parsed.scope}-${parsed.name}` : parsed.name;
|
|
257
|
+
tarball = `${registryUrl}/${parsed.fullName}/-/${namePart}-${version}.tgz`;
|
|
258
|
+
}
|
|
259
|
+
// Checksum detection — pinixd uses length-based: 64 hex chars = SHA256, else SHA1
|
|
260
|
+
let checksumAlgo = 'sha1';
|
|
261
|
+
let checksumHex = '';
|
|
262
|
+
if (dist?.integrity) {
|
|
263
|
+
// Subresource Integrity: "sha256-<base64>"
|
|
264
|
+
const parts = dist.integrity.split('-', 2);
|
|
265
|
+
if (parts.length === 2 && parts[0] && parts[1]) {
|
|
266
|
+
checksumAlgo = parts[0];
|
|
267
|
+
checksumHex = Buffer.from(parts[1], 'base64').toString('hex');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
else if (dist?.shasum) {
|
|
271
|
+
// pinixd: length 64 = SHA256, otherwise SHA1
|
|
272
|
+
checksumHex = dist.shasum.trim();
|
|
273
|
+
checksumAlgo = checksumHex.length === 64 ? 'sha256' : 'sha1';
|
|
274
|
+
}
|
|
275
|
+
else if (dist?.sha256) {
|
|
276
|
+
checksumAlgo = 'sha256';
|
|
277
|
+
checksumHex = dist.sha256;
|
|
278
|
+
}
|
|
279
|
+
else if (dist?.sha1) {
|
|
280
|
+
checksumAlgo = 'sha1';
|
|
281
|
+
checksumHex = dist.sha1;
|
|
282
|
+
}
|
|
283
|
+
return { version, tarball: tarball ?? '', checksumAlgo, checksumHex };
|
|
284
|
+
}
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Tarball verification and extraction
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
function verifyChecksum(filePath, algo, expectedHex) {
|
|
289
|
+
if (!expectedHex) {
|
|
290
|
+
console.warn(`[clip-installer] WARNING: no checksum provided by registry — installing without integrity verification`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const hash = crypto.createHash(algo);
|
|
294
|
+
const content = fs.readFileSync(filePath);
|
|
295
|
+
hash.update(content);
|
|
296
|
+
const actual = hash.digest('hex');
|
|
297
|
+
if (actual !== expectedHex) {
|
|
298
|
+
throw new Error(`checksum mismatch for ${path.basename(filePath)}: ` +
|
|
299
|
+
`expected ${algo}:${expectedHex}, got ${algo}:${actual}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Validate that every entry in the tar listing is safe:
|
|
304
|
+
* - No absolute paths
|
|
305
|
+
* - No ".." traversal
|
|
306
|
+
*/
|
|
307
|
+
function validateTarEntries(tarballPath) {
|
|
308
|
+
let listing;
|
|
309
|
+
try {
|
|
310
|
+
listing = execFileSync('tar', ['tzf', tarballPath], {
|
|
311
|
+
encoding: 'utf-8',
|
|
312
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
throw new Error(`failed to list tarball contents: ${err instanceof Error ? err.message : String(err)}`);
|
|
317
|
+
}
|
|
318
|
+
for (const entry of listing.split('\n')) {
|
|
319
|
+
const trimmed = entry.trim();
|
|
320
|
+
if (!trimmed)
|
|
321
|
+
continue;
|
|
322
|
+
if (path.isAbsolute(trimmed)) {
|
|
323
|
+
throw new Error(`tarball contains absolute path: "${trimmed}"`);
|
|
324
|
+
}
|
|
325
|
+
const normalized = path.normalize(trimmed);
|
|
326
|
+
if (normalized.startsWith('..') || normalized.includes(`${path.sep}..${path.sep}`)) {
|
|
327
|
+
throw new Error(`tarball contains path traversal: "${trimmed}"`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Extract tarball to destDir, stripping the top-level prefix.
|
|
333
|
+
*
|
|
334
|
+
* Pinix tarballs typically have a "package/" prefix inside. We detect this
|
|
335
|
+
* by checking whether all entries share a common first directory component
|
|
336
|
+
* and strip accordingly.
|
|
337
|
+
*/
|
|
338
|
+
function extractTarball(tarballPath, destDir) {
|
|
339
|
+
// Determine the strip depth by inspecting the tarball listing
|
|
340
|
+
const stripComponents = detectStripComponents(tarballPath);
|
|
341
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
342
|
+
try {
|
|
343
|
+
execFileSync('tar', [
|
|
344
|
+
'xzf',
|
|
345
|
+
tarballPath,
|
|
346
|
+
'-C',
|
|
347
|
+
destDir,
|
|
348
|
+
...(stripComponents > 0 ? [`--strip-components=${stripComponents}`] : []),
|
|
349
|
+
], {
|
|
350
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
throw new Error(`failed to extract tarball: ${err instanceof Error ? err.message : String(err)}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Detect how many leading path components to strip. If all entries share
|
|
359
|
+
* a common prefix directory (e.g., "package/"), return 1. Otherwise 0.
|
|
360
|
+
*/
|
|
361
|
+
function detectStripComponents(tarballPath) {
|
|
362
|
+
let listing;
|
|
363
|
+
try {
|
|
364
|
+
listing = execFileSync('tar', ['tzf', tarballPath], {
|
|
365
|
+
encoding: 'utf-8',
|
|
366
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
const entries = listing
|
|
373
|
+
.split('\n')
|
|
374
|
+
.map((l) => l.trim())
|
|
375
|
+
.filter(Boolean);
|
|
376
|
+
if (entries.length === 0)
|
|
377
|
+
return 0;
|
|
378
|
+
// Find the common first component
|
|
379
|
+
let commonPrefix = null;
|
|
380
|
+
for (const entry of entries) {
|
|
381
|
+
const firstSlash = entry.indexOf('/');
|
|
382
|
+
const firstComponent = firstSlash > 0 ? entry.slice(0, firstSlash) : entry;
|
|
383
|
+
if (commonPrefix === null) {
|
|
384
|
+
commonPrefix = firstComponent;
|
|
385
|
+
}
|
|
386
|
+
else if (firstComponent !== commonPrefix) {
|
|
387
|
+
return 0; // no common prefix
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// All entries share a common first directory component — strip it
|
|
391
|
+
return commonPrefix ? 1 : 0;
|
|
392
|
+
}
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// Dependency installation
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
function installDeps(clipDir) {
|
|
397
|
+
const pkgJsonPath = path.join(clipDir, 'package.json');
|
|
398
|
+
if (!fs.existsSync(pkgJsonPath))
|
|
399
|
+
return;
|
|
400
|
+
// Check if the package actually declares dependencies
|
|
401
|
+
let hasDeps = false;
|
|
402
|
+
try {
|
|
403
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
404
|
+
hasDeps = !!(pkg.dependencies && Object.keys(pkg.dependencies).length > 0);
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
/* ignore parse error, try install anyway */
|
|
408
|
+
}
|
|
409
|
+
const runners = [
|
|
410
|
+
{ cmd: 'bun', args: ['install', '--frozen-lockfile'] },
|
|
411
|
+
{ cmd: 'bun', args: ['install'] },
|
|
412
|
+
{ cmd: 'npm', args: ['install', '--production'] },
|
|
413
|
+
];
|
|
414
|
+
for (const runner of runners) {
|
|
415
|
+
try {
|
|
416
|
+
execFileSync(runner.cmd, runner.args, {
|
|
417
|
+
cwd: clipDir,
|
|
418
|
+
stdio: 'pipe',
|
|
419
|
+
timeout: 120_000,
|
|
420
|
+
});
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
// try next runner
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (hasDeps) {
|
|
428
|
+
throw new Error(`failed to install dependencies in ${clipDir} — clip declares dependencies but no package manager succeeded`);
|
|
429
|
+
}
|
|
430
|
+
console.warn(`[clip-installer] could not run package manager in ${clipDir} (no dependencies declared, continuing)`);
|
|
431
|
+
}
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
// Public API
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
export async function installClip(opts) {
|
|
436
|
+
const registryUrl = (opts.registryUrl ?? DEFAULT_REGISTRY_URL).replace(/\/+$/, '');
|
|
437
|
+
const parsed = parseSource(opts.source);
|
|
438
|
+
const alias = opts.alias ?? deriveAlias(parsed);
|
|
439
|
+
const destDir = path.join(opts.clipsDir, alias);
|
|
440
|
+
// 1. Resolve version
|
|
441
|
+
const versionMeta = await resolveVersionMeta(registryUrl, parsed);
|
|
442
|
+
// 2. Prepare temp directory for download + staging
|
|
443
|
+
const tmpDir = path.join(opts.clipsDir, `.tmp-${alias}-${Date.now()}`);
|
|
444
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
445
|
+
const tarballPath = path.join(tmpDir, `${alias}-${versionMeta.version}.tgz`);
|
|
446
|
+
const stageDir = path.join(tmpDir, 'stage');
|
|
447
|
+
try {
|
|
448
|
+
// 3. Download tarball
|
|
449
|
+
await httpDownload(versionMeta.tarball, tarballPath);
|
|
450
|
+
// 4. Verify checksum
|
|
451
|
+
verifyChecksum(tarballPath, versionMeta.checksumAlgo, versionMeta.checksumHex);
|
|
452
|
+
// 5. Validate tarball (security)
|
|
453
|
+
validateTarEntries(tarballPath);
|
|
454
|
+
// 6. Extract to staging directory (not final destination)
|
|
455
|
+
extractTarball(tarballPath, stageDir);
|
|
456
|
+
// 7. Install dependencies in staging directory
|
|
457
|
+
installDeps(stageDir);
|
|
458
|
+
// 8. Atomic swap: old → backup, staged → final, then remove backup.
|
|
459
|
+
// If anything fails after backup, restore the old version.
|
|
460
|
+
const backupDir = path.join(opts.clipsDir, `.backup-${alias}-${Date.now()}`);
|
|
461
|
+
let backedUp = false;
|
|
462
|
+
try {
|
|
463
|
+
if (fs.existsSync(destDir)) {
|
|
464
|
+
fs.renameSync(destDir, backupDir);
|
|
465
|
+
backedUp = true;
|
|
466
|
+
}
|
|
467
|
+
fs.renameSync(stageDir, destDir);
|
|
468
|
+
if (backedUp) {
|
|
469
|
+
fs.rmSync(backupDir, { recursive: true, force: true });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
catch (swapErr) {
|
|
473
|
+
// Restore old version if swap failed
|
|
474
|
+
if (backedUp && !fs.existsSync(destDir)) {
|
|
475
|
+
try {
|
|
476
|
+
fs.renameSync(backupDir, destDir);
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
/* best effort */
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
throw swapErr;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
finally {
|
|
486
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
alias,
|
|
490
|
+
name: parsed.fullName,
|
|
491
|
+
version: versionMeta.version,
|
|
492
|
+
path: destDir,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
export async function removeClip(clipsDir, alias) {
|
|
496
|
+
const clipDir = path.join(clipsDir, alias);
|
|
497
|
+
if (!fs.existsSync(clipDir)) {
|
|
498
|
+
throw new Error(`clip "${alias}" not found at ${clipDir}`);
|
|
499
|
+
}
|
|
500
|
+
fs.rmSync(clipDir, { recursive: true, force: true });
|
|
501
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClipProvider — Connect-RPC client that connects the Daemon to the Clip
|
|
3
|
+
* Service (Hub) as a Provider. Uses Node.js http2 directly with Connect
|
|
4
|
+
* protocol bidi streaming (envelope-framed JSON over HTTP/2).
|
|
5
|
+
*
|
|
6
|
+
* Protocol: POST /clip.v1.ClipHubService/ProviderStream
|
|
7
|
+
* Content-Type: application/connect+json
|
|
8
|
+
* Authorization: Bearer mck_xxx
|
|
9
|
+
* Body: envelope-framed ProviderMessage stream
|
|
10
|
+
* Response: envelope-framed HubMessage stream
|
|
11
|
+
*
|
|
12
|
+
* Connect envelope format (JSON mode):
|
|
13
|
+
* [flags:1][length:4 big-endian][JSON payload]
|
|
14
|
+
* flags=0x00 data, flags=0x02 end-of-stream (trailers)
|
|
15
|
+
*/
|
|
16
|
+
import type { ClipProcessManager } from './process-manager.js';
|
|
17
|
+
export interface ClipProviderOptions {
|
|
18
|
+
/** Clip Service URL (e.g., http://localhost:8095). */
|
|
19
|
+
serviceUrl: string;
|
|
20
|
+
/** mck_xxx machine key for authorization. */
|
|
21
|
+
authKey: string;
|
|
22
|
+
/** Org to register for. */
|
|
23
|
+
orgId: string;
|
|
24
|
+
/** Unique provider name (typically machine ID). */
|
|
25
|
+
providerName: string;
|
|
26
|
+
/** ClipProcessManager to delegate invoke commands to. */
|
|
27
|
+
clipManager: ClipProcessManager;
|
|
28
|
+
/** Log adapter. */
|
|
29
|
+
log: {
|
|
30
|
+
info(msg: string): void;
|
|
31
|
+
warn(msg: string): void;
|
|
32
|
+
error(msg: string): void;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export declare class ClipProvider {
|
|
36
|
+
private readonly opts;
|
|
37
|
+
private session;
|
|
38
|
+
private stream;
|
|
39
|
+
private stopped;
|
|
40
|
+
private connected;
|
|
41
|
+
private reconnectAttempt;
|
|
42
|
+
private reconnectTimer;
|
|
43
|
+
private heartbeatTimer;
|
|
44
|
+
private statusUnsubscribe;
|
|
45
|
+
private manifestUnsubscribe;
|
|
46
|
+
private needsReregister;
|
|
47
|
+
private intentionalClose;
|
|
48
|
+
private static RECONNECT_BASE_MS;
|
|
49
|
+
private static RECONNECT_MAX_MS;
|
|
50
|
+
private static HEARTBEAT_INTERVAL_MS;
|
|
51
|
+
constructor(opts: ClipProviderOptions);
|
|
52
|
+
/** Connect to the Clip Service and register local clips. Reconnects on failure. */
|
|
53
|
+
connect(): Promise<void>;
|
|
54
|
+
/** Disconnect and stop reconnection. */
|
|
55
|
+
disconnect(): Promise<void>;
|
|
56
|
+
isConnected(): boolean;
|
|
57
|
+
private openStream;
|
|
58
|
+
private handleDisconnect;
|
|
59
|
+
private scheduleReconnect;
|
|
60
|
+
private clearReconnectTimer;
|
|
61
|
+
private clearHeartbeatTimer;
|
|
62
|
+
private startHeartbeat;
|
|
63
|
+
private closeStream;
|
|
64
|
+
private closeSession;
|
|
65
|
+
private subscribeStatusChanges;
|
|
66
|
+
private unsubscribeStatusChanges;
|
|
67
|
+
private handleHubMessage;
|
|
68
|
+
private handleRegistered;
|
|
69
|
+
private handleInvokeCommand;
|
|
70
|
+
private sendRegister;
|
|
71
|
+
private sendProviderMessage;
|
|
72
|
+
private buildClipRegistrations;
|
|
73
|
+
private clipConfigToRegistration;
|
|
74
|
+
private commandDetailToInfo;
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=clip-provider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"clip-provider.d.ts","sourceRoot":"","sources":["../../src/clip-runtime/clip-provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AA4H/D,MAAM,WAAW,mBAAmB;IAClC,sDAAsD;IACtD,UAAU,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,OAAO,EAAE,MAAM,CAAC;IAChB,2BAA2B;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,mDAAmD;IACnD,YAAY,EAAE,MAAM,CAAC;IACrB,yDAAyD;IACzD,WAAW,EAAE,kBAAkB,CAAC;IAChC,mBAAmB;IACnB,GAAG,EAAE;QAAE,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;CACrF;AAED,qBAAa,YAAY;IAiBX,OAAO,CAAC,QAAQ,CAAC,IAAI;IAhBjC,OAAO,CAAC,OAAO,CAAyC;IACxD,OAAO,CAAC,MAAM,CAAwC;IACtD,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,iBAAiB,CAA6B;IACtD,OAAO,CAAC,mBAAmB,CAA6B;IACxD,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,gBAAgB,CAAS;IAEjC,OAAO,CAAC,MAAM,CAAC,iBAAiB,CAAS;IACzC,OAAO,CAAC,MAAM,CAAC,gBAAgB,CAAU;IACzC,OAAO,CAAC,MAAM,CAAC,qBAAqB,CAAU;gBAEjB,IAAI,EAAE,mBAAmB;IAEtD,mFAAmF;IAC7E,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAS9B,wCAAwC;IAClC,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAUjC,WAAW,IAAI,OAAO;YAQR,UAAU;IAoExB,OAAO,CAAC,gBAAgB;IAQxB,OAAO,CAAC,iBAAiB;IAiBzB,OAAO,CAAC,mBAAmB;IAO3B,OAAO,CAAC,mBAAmB;IAO3B,OAAO,CAAC,cAAc;IAyBtB,OAAO,CAAC,WAAW;IAWnB,OAAO,CAAC,YAAY;IAepB,OAAO,CAAC,sBAAsB;IAyB9B,OAAO,CAAC,wBAAwB;IAWhC,OAAO,CAAC,gBAAgB;IAWxB,OAAO,CAAC,gBAAgB;YAeV,mBAAmB;YAsDnB,YAAY;YAWZ,mBAAmB;IAiBjC,OAAO,CAAC,sBAAsB;IAK9B,OAAO,CAAC,wBAAwB;IAchC,OAAO,CAAC,mBAAmB;CAU5B"}
|