@parall/cli 1.52.1 → 1.52.2
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/dist/commands/clip.d.ts.map +1 -1
- package/dist/commands/clip.js +236 -141
- package/dist/commands/wechat.d.ts +12 -0
- package/dist/commands/wechat.d.ts.map +1 -0
- package/dist/commands/wechat.js +49 -0
- package/dist/index.js +2 -0
- package/dist/lib/publish-source.d.ts +14 -0
- package/dist/lib/publish-source.d.ts.map +1 -0
- package/dist/lib/publish-source.js +158 -0
- package/package.json +4 -4
- package/dist/lib/clip-invoke.d.ts +0 -45
- package/dist/lib/clip-invoke.d.ts.map +0 -1
- package/dist/lib/clip-invoke.js +0 -61
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"clip.d.ts","sourceRoot":"","sources":["../../src/commands/clip.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"clip.d.ts","sourceRoot":"","sources":["../../src/commands/clip.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAiFpC,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,QA2XpD"}
|
package/dist/commands/clip.js
CHANGED
|
@@ -1,53 +1,97 @@
|
|
|
1
1
|
import { resolveCredentials } from '../lib/client.js';
|
|
2
|
-
import { invokeClipAwaitingActivation } from '../lib/clip-invoke.js';
|
|
3
2
|
import { execEdgeClipWithBoundedWait } from '../lib/edge-exec.js';
|
|
4
3
|
import { printJson, printError, parsePositiveInt } from '../lib/output.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
if (
|
|
13
|
-
const
|
|
14
|
-
if (
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
4
|
+
import { buildPublishedManifest, collectPublishSource } from '../lib/publish-source.js';
|
|
5
|
+
const NO_ALIAS_HINT = 'no alias — reference this connection by its ccn_ id, or ask a human to name it in the Clip Console so callers can tell the accounts apart';
|
|
6
|
+
function connectionAnnotations(conn, deviceById) {
|
|
7
|
+
const out = { target: 'orphaned' };
|
|
8
|
+
if (conn.mcp_config_id) {
|
|
9
|
+
out.target = 'mcp';
|
|
10
|
+
}
|
|
11
|
+
else if (conn.device_id) {
|
|
12
|
+
const device = deviceById?.get(conn.device_id);
|
|
13
|
+
if (!device) {
|
|
14
|
+
// Absent row ≠ deleted device: the device list may have failed to load
|
|
15
|
+
// (deviceById null) or lag a concurrent delete. 'orphaned' is reserved
|
|
16
|
+
// for the provable case (device_id itself nulled by the server).
|
|
17
|
+
out.target = 'device';
|
|
18
|
+
}
|
|
19
|
+
else if (device.placement === 'hosted') {
|
|
20
|
+
out.target = 'cloud';
|
|
21
|
+
out.device = device.name;
|
|
18
22
|
}
|
|
23
|
+
else {
|
|
24
|
+
out.target = 'desktop';
|
|
25
|
+
out.device = device.name;
|
|
26
|
+
out.device_status = device.status;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (!conn.alias)
|
|
30
|
+
out.hint = NO_ALIAS_HINT;
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/** Compact connection form for discovery output (`clip list`). */
|
|
34
|
+
function summarizeConnection(conn, deviceById) {
|
|
35
|
+
return {
|
|
36
|
+
id: conn.id,
|
|
37
|
+
alias: conn.alias ?? null,
|
|
38
|
+
...(conn.profile ? { profile: conn.profile } : {}),
|
|
39
|
+
...connectionAnnotations(conn, deviceById),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Device index for annotating connections; null = list unavailable (degrade
|
|
43
|
+
* to target 'device' rather than failing discovery or guessing placement). */
|
|
44
|
+
async function loadDeviceIndex(client, orgId) {
|
|
45
|
+
try {
|
|
46
|
+
const resp = await client.listEdgeDevices(orgId);
|
|
47
|
+
return new Map((resp.data ?? []).map((d) => [d.id, d]));
|
|
19
48
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (atIdx > 0)
|
|
23
|
-
s = s.slice(0, atIdx);
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
24
51
|
}
|
|
25
|
-
// Normalize to server's allowed alias charset: ^[a-z0-9][a-z0-9-_]*$
|
|
26
|
-
return s
|
|
27
|
-
.toLowerCase()
|
|
28
|
-
.replace(/[^a-z0-9-_]/g, '-')
|
|
29
|
-
.replace(/^[^a-z0-9]+/, '');
|
|
30
52
|
}
|
|
31
53
|
export function registerClipCommands(program) {
|
|
32
|
-
const clip = program
|
|
54
|
+
const clip = program
|
|
55
|
+
.command('clip')
|
|
56
|
+
.description('Discover, install and execute clips installed in the organization');
|
|
33
57
|
clip
|
|
34
58
|
.command('list')
|
|
35
|
-
.description('List clips in the organization')
|
|
59
|
+
.description('List clips installed in the organization, each with its connections — the --connection refs `clip exec` takes')
|
|
36
60
|
.action(async () => {
|
|
37
61
|
try {
|
|
38
62
|
const { client, orgId } = resolveCredentials();
|
|
39
|
-
|
|
40
|
-
//
|
|
63
|
+
// Reads the v3 registry installs. The legacy listClips() reads the v2
|
|
64
|
+
// `clips` table, whose machine-bound registry installs are retired — a
|
|
65
|
+
// clip installed through the Console never appeared there, which is why
|
|
66
|
+
// this command used to under-report.
|
|
67
|
+
const clips = await client.listInstalledRegistryClips(orgId);
|
|
41
68
|
if (clips.length === 0) {
|
|
42
|
-
printJson({ data: [], message: 'No clips
|
|
69
|
+
printJson({ data: [], message: 'No clips installed' });
|
|
43
70
|
return;
|
|
44
71
|
}
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
72
|
+
const deviceById = await loadDeviceIndex(client, orgId);
|
|
73
|
+
// One list answers both discovery questions — WHICH clip and WHERE it
|
|
74
|
+
// can run — so an agent resolves "call <clip> for me" in a single
|
|
75
|
+
// command instead of a per-clip connections crawl.
|
|
76
|
+
const rows = await Promise.all(clips.map(async (c) => {
|
|
77
|
+
const row = {
|
|
78
|
+
id: c.id,
|
|
79
|
+
name: c.name,
|
|
80
|
+
...(c.description ? { description: c.description } : {}),
|
|
81
|
+
version: c.version ?? '-',
|
|
82
|
+
visibility: c.visibility,
|
|
83
|
+
};
|
|
84
|
+
try {
|
|
85
|
+
const resp = await client.listClipConnections(orgId, c.id);
|
|
86
|
+
row.connections = (resp.data ?? []).map((conn) => summarizeConnection(conn, deviceById));
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
// One clip's broken connections read must not take down
|
|
90
|
+
// discovery of every other clip — degrade that row, honestly.
|
|
91
|
+
row.connections = null;
|
|
92
|
+
row.connections_error = err instanceof Error ? err.message : String(err);
|
|
93
|
+
}
|
|
94
|
+
return row;
|
|
51
95
|
}));
|
|
52
96
|
printJson(rows);
|
|
53
97
|
}
|
|
@@ -57,23 +101,25 @@ export function registerClipCommands(program) {
|
|
|
57
101
|
});
|
|
58
102
|
clip
|
|
59
103
|
.command('install')
|
|
60
|
-
.description('Install a clip
|
|
61
|
-
.argument('<
|
|
62
|
-
.
|
|
63
|
-
.requiredOption('-m, --machine-id <machineId>', 'Machine ID that runs this clip')
|
|
64
|
-
.action(async (source, opts) => {
|
|
104
|
+
.description('Install a registry clip into the organization')
|
|
105
|
+
.argument('<clip>', 'Registry clip id (crg_...) or name')
|
|
106
|
+
.action(async (clip) => {
|
|
65
107
|
try {
|
|
66
108
|
const { client, orgId } = resolveCredentials();
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
109
|
+
// v3 installs a REFERENCE into clip_installs, keyed by the registry id.
|
|
110
|
+
// There is no machine to pick: a v3 clip runs wherever the connection
|
|
111
|
+
// it is bound to points, which is why the old --machine-id is gone.
|
|
112
|
+
// listOrgRegistryClips is the surface carrying `crg_` ids — the Pinix
|
|
113
|
+
// catalog proxy does not.
|
|
114
|
+
const registry = await client.listOrgRegistryClips(orgId);
|
|
115
|
+
const found = registry.find((c) => c.id === clip || c.name === clip);
|
|
116
|
+
if (!found) {
|
|
117
|
+
printError(new Error(`No registry clip matching "${clip}". Pass a crg_ id or the exact registry name; a private clip from another org is not installable.`));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Idempotent server-side: installing an already-installed clip is a no-op.
|
|
121
|
+
const resp = await client.installRegistryClip(orgId, found.id);
|
|
122
|
+
printJson({ ...resp, id: found.id, name: found.name });
|
|
77
123
|
}
|
|
78
124
|
catch (err) {
|
|
79
125
|
printError(err);
|
|
@@ -81,29 +127,65 @@ export function registerClipCommands(program) {
|
|
|
81
127
|
});
|
|
82
128
|
clip
|
|
83
129
|
.command('info')
|
|
84
|
-
.description(
|
|
85
|
-
.argument('<
|
|
86
|
-
.action(async (
|
|
130
|
+
.description("Show detailed clip information, including its manifest commands and params. For your org's OWN clips this includes review_status/review_note (the platform-review state of a public publish)")
|
|
131
|
+
.argument('<clip>', 'Registry clip id (crg_...) or name')
|
|
132
|
+
.action(async (clip) => {
|
|
87
133
|
try {
|
|
88
134
|
const { client, orgId } = resolveCredentials();
|
|
89
|
-
|
|
90
|
-
|
|
135
|
+
// Resolve installed-first (the historical semantic), then fall back to
|
|
136
|
+
// the org registry: an author checking a clip they published but never
|
|
137
|
+
// installed — the publish→review→revise loop — must not dead-end here.
|
|
138
|
+
const installed = await client.listInstalledRegistryClips(orgId);
|
|
139
|
+
let found = installed.find((c) => c.id === clip || c.name === clip);
|
|
91
140
|
if (!found) {
|
|
92
|
-
|
|
141
|
+
const registry = await client.listOrgRegistryClips(orgId);
|
|
142
|
+
found = registry.find((c) => c.id === clip || c.name === clip);
|
|
143
|
+
}
|
|
144
|
+
let entry = found;
|
|
145
|
+
if (found) {
|
|
146
|
+
// The detail read is what carries review_status/review_note (server
|
|
147
|
+
// attaches them only for the owning org). A cross-org entry can lose
|
|
148
|
+
// detail visibility between the list and this read — the list entry
|
|
149
|
+
// is still correct data, so fall back to it rather than failing info.
|
|
150
|
+
try {
|
|
151
|
+
entry = await client.getRegistryClip(orgId, found.id);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
entry = found;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else if (clip.startsWith('crg_')) {
|
|
158
|
+
// Both lists are single pages (registry capped at 100 entries), so a
|
|
159
|
+
// valid id can sit beyond them — an exact crg_ id needs no list
|
|
160
|
+
// resolution at all; go straight to the detail endpoint before
|
|
161
|
+
// concluding not-found.
|
|
162
|
+
try {
|
|
163
|
+
entry = await client.getRegistryClip(orgId, clip);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// fall through to the not-found error below
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (!entry) {
|
|
170
|
+
// `return` matters: without it the lookup falls through and dies
|
|
171
|
+
// with a TypeError instead of the message just printed.
|
|
172
|
+
printError(new Error(`No clip matching "${clip}" (see \`parall clip list\`)`));
|
|
173
|
+
return;
|
|
93
174
|
}
|
|
94
|
-
// Display full details including manifest commands
|
|
95
175
|
const info = {
|
|
96
|
-
id:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
176
|
+
id: entry.id,
|
|
177
|
+
name: entry.name,
|
|
178
|
+
version: entry.version,
|
|
179
|
+
description: entry.description,
|
|
180
|
+
visibility: entry.visibility,
|
|
181
|
+
manifest: entry.manifest,
|
|
182
|
+
created_at: entry.created_at,
|
|
183
|
+
updated_at: entry.updated_at,
|
|
184
|
+
// Approved pointer = the snapshot cross-org installs serve; absent =
|
|
185
|
+
// never approved. review_* appear only for this org's own clips.
|
|
186
|
+
...(entry.approved_version_id ? { approved_version_id: entry.approved_version_id } : {}),
|
|
187
|
+
...(entry.review_status ? { review_status: entry.review_status } : {}),
|
|
188
|
+
...(entry.review_note ? { review_note: entry.review_note } : {}),
|
|
107
189
|
};
|
|
108
190
|
printJson(info);
|
|
109
191
|
}
|
|
@@ -113,22 +195,30 @@ export function registerClipCommands(program) {
|
|
|
113
195
|
});
|
|
114
196
|
clip
|
|
115
197
|
.command('connections')
|
|
116
|
-
.description(
|
|
117
|
-
.argument('<
|
|
118
|
-
.action(async (
|
|
198
|
+
.description("List a clip's connections in full — each row's ccn_ id or alias is what `clip exec --connection` takes")
|
|
199
|
+
.argument('<clip>', 'Registry clip id (crg_...) or name')
|
|
200
|
+
.action(async (clip) => {
|
|
119
201
|
try {
|
|
120
202
|
const { client, orgId } = resolveCredentials();
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
203
|
+
// clip_connections.clip_id is a foreign key onto clip_registry, so this
|
|
204
|
+
// endpoint is keyed by the REGISTRY id. Resolving through the legacy
|
|
205
|
+
// listClips() handed it a `clp_` id from the v2 table, which matches
|
|
206
|
+
// nothing — the command returned an empty list for every clip.
|
|
207
|
+
const installed = await client.listInstalledRegistryClips(orgId);
|
|
208
|
+
const found = installed.find((c) => c.id === clip || c.name === clip);
|
|
124
209
|
if (!found) {
|
|
125
|
-
printError(new Error(`
|
|
210
|
+
printError(new Error(`No installed clip matching "${clip}" (pass a crg_ id or the registry name; see \`parall clip list\`)`));
|
|
211
|
+
return;
|
|
126
212
|
}
|
|
127
213
|
const resp = await client.listClipConnections(orgId, found.id);
|
|
128
214
|
// A Go nil slice serializes as `null`, not `[]` — normalize so an
|
|
129
215
|
// empty result always prints `[]` (ts/AGENTS.md § Cross-Language
|
|
130
216
|
// Contract Edge Cases).
|
|
131
|
-
|
|
217
|
+
const conns = resp.data ?? [];
|
|
218
|
+
const deviceById = conns.some((c) => c.device_id)
|
|
219
|
+
? await loadDeviceIndex(client, orgId)
|
|
220
|
+
: new Map();
|
|
221
|
+
printJson(conns.map((c) => ({ ...c, ...connectionAnnotations(c, deviceById) })));
|
|
132
222
|
}
|
|
133
223
|
catch (err) {
|
|
134
224
|
printError(err);
|
|
@@ -136,16 +226,17 @@ export function registerClipCommands(program) {
|
|
|
136
226
|
});
|
|
137
227
|
clip
|
|
138
228
|
.command('tools')
|
|
139
|
-
.description('List the MCP tool schemas (name, description, inputSchema) of an MCP clip
|
|
140
|
-
.argument('<
|
|
141
|
-
.action(async (
|
|
229
|
+
.description('List the MCP tool schemas (name, description, inputSchema) of an MCP clip — the discovery step before `clip exec <clip> <tool>`')
|
|
230
|
+
.argument('<clip>', 'Registry clip id (crg_...) or name')
|
|
231
|
+
.action(async (clip) => {
|
|
142
232
|
try {
|
|
143
233
|
const { client, orgId } = resolveCredentials();
|
|
144
|
-
//
|
|
145
|
-
const
|
|
146
|
-
const found =
|
|
234
|
+
// Same lookup `clip connections`/`info` use.
|
|
235
|
+
const installed = await client.listInstalledRegistryClips(orgId);
|
|
236
|
+
const found = installed.find((c) => c.id === clip || c.name === clip);
|
|
147
237
|
if (!found) {
|
|
148
|
-
printError(new Error(`
|
|
238
|
+
printError(new Error(`No installed clip matching "${clip}" (see \`parall clip list\`)`));
|
|
239
|
+
return;
|
|
149
240
|
}
|
|
150
241
|
// MCP tools are NOT frozen in the clip manifest, so `clip info` cannot
|
|
151
242
|
// show them; the live server snapshot behind getClipMCPConfig is the
|
|
@@ -160,62 +251,18 @@ export function registerClipCommands(program) {
|
|
|
160
251
|
printError(err);
|
|
161
252
|
}
|
|
162
253
|
});
|
|
163
|
-
clip
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
.argument('[input]', 'Input data (JSON string or plain text)')
|
|
169
|
-
.option('--timeout <ms>', 'Timeout in milliseconds', '30000')
|
|
170
|
-
.action(async (alias, command, input, opts) => {
|
|
171
|
-
try {
|
|
172
|
-
const { client, orgId } = resolveCredentials();
|
|
173
|
-
// Parse input: try JSON first, fall back to plain text wrapping.
|
|
174
|
-
let parsedInput = undefined;
|
|
175
|
-
if (input !== undefined) {
|
|
176
|
-
try {
|
|
177
|
-
parsedInput = JSON.parse(input);
|
|
178
|
-
}
|
|
179
|
-
catch {
|
|
180
|
-
// Not valid JSON — send as a plain string.
|
|
181
|
-
parsedInput = input;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
let timeoutMs;
|
|
185
|
-
if (opts?.timeout) {
|
|
186
|
-
timeoutMs = parseInt(opts.timeout, 10);
|
|
187
|
-
if (isNaN(timeoutMs) || timeoutMs <= 0) {
|
|
188
|
-
printError(new Error('--timeout must be a positive integer (milliseconds)'));
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
const req = {
|
|
192
|
-
alias,
|
|
193
|
-
command,
|
|
194
|
-
input: parsedInput,
|
|
195
|
-
timeout_ms: timeoutMs,
|
|
196
|
-
};
|
|
197
|
-
// Hosted-browser clips may hit a cold profile: absorb the (pre-dispatch,
|
|
198
|
-
// side-effect-free) BROWSER_PROFILE_ACTIVATING window here instead of
|
|
199
|
-
// making every agent hand-roll a retry loop. See lib/clip-invoke.ts.
|
|
200
|
-
const result = await invokeClipAwaitingActivation(client, orgId, req);
|
|
201
|
-
if (result.error) {
|
|
202
|
-
printError(new Error(result.error));
|
|
203
|
-
}
|
|
204
|
-
else {
|
|
205
|
-
printJson(result.output);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
catch (err) {
|
|
209
|
-
printError(err);
|
|
210
|
-
}
|
|
211
|
-
});
|
|
254
|
+
// `clip invoke` (the v2 clip-service path) is deliberately GONE, not hidden:
|
|
255
|
+
// discovery above lists v3 registry installs only, and a v3 name fed to the
|
|
256
|
+
// v2 invoke endpoint answered CLIP_NOT_FOUND — a dead verb that only misled
|
|
257
|
+
// callers into the wrong invocation path. `clip exec` is the one execution
|
|
258
|
+
// verb; the server endpoint retires separately with the rest of v2.
|
|
212
259
|
clip
|
|
213
260
|
.command('exec')
|
|
214
261
|
.description('Execute a registry (Edge) clip command on an Edge device. A cloud (hosted) profile is reachable ONLY via --connection; with neither --connection nor --edge, the server resolves just your OWN online desktop device (legacy BYOC fallback — never a cloud profile)')
|
|
215
262
|
.argument('<clip>', 'Clip name in the org clip registry')
|
|
216
263
|
.argument('<command>', 'Command name to execute')
|
|
217
264
|
.argument('[args]', 'Command arguments (JSON string or plain text)')
|
|
218
|
-
.option('--connection <ref>', 'Clip connection id (ccn_…) or alias
|
|
265
|
+
.option('--connection <ref>', 'Clip connection id (ccn_…) or alias — discover them with `parall clip connections <clip>`. REQUIRED to reach a cloud (hosted) profile: the binding its maintainer created is the authorization')
|
|
219
266
|
.option('--edge <edgeId>', 'A desktop (BYOC) device you own. Mutually exclusive with --connection')
|
|
220
267
|
.option('--profile <name>', 'Browser profile (with --connection it may only restate the granted one)')
|
|
221
268
|
.option('--timeout <ms>', 'Execution timeout in milliseconds', '30000')
|
|
@@ -271,21 +318,69 @@ export function registerClipCommands(program) {
|
|
|
271
318
|
printError(err);
|
|
272
319
|
}
|
|
273
320
|
});
|
|
321
|
+
clip
|
|
322
|
+
.command('publish')
|
|
323
|
+
.description('Publish a clip directory (manifest.json or site.json + its top-level .js files) into the org registry. Re-publishing an existing name is author-only and replaces the file set; "visibility": "public" additionally submits the version for platform review before it can spread cross-org')
|
|
324
|
+
.argument('<directory>', 'Clip source directory')
|
|
325
|
+
.action(async (directory) => {
|
|
326
|
+
try {
|
|
327
|
+
const { client, orgId } = resolveCredentials();
|
|
328
|
+
const { manifest, files, directoryName } = collectPublishSource(directory);
|
|
329
|
+
// Same request derivation as the Edge CLI (`parall-clip publish`):
|
|
330
|
+
// manifest fields win, directory name / 0.0.1 / private fill the gaps.
|
|
331
|
+
// Send the TRIMMED values — name is the org-wide upsert key, so
|
|
332
|
+
// `" google "` must not mint an entry distinct from `google`.
|
|
333
|
+
const manifestName = typeof manifest.name === 'string' ? manifest.name.trim() : '';
|
|
334
|
+
const manifestVersion = typeof manifest.version === 'string' ? manifest.version.trim() : '';
|
|
335
|
+
const entry = await client.publishRegistryClip(orgId, {
|
|
336
|
+
name: manifestName !== '' ? manifestName : directoryName,
|
|
337
|
+
description: typeof manifest.description === 'string' ? manifest.description : undefined,
|
|
338
|
+
version: manifestVersion !== '' ? manifestVersion : '0.0.1',
|
|
339
|
+
visibility: manifest.visibility === 'public' ? 'public' : 'private',
|
|
340
|
+
manifest: buildPublishedManifest(manifest),
|
|
341
|
+
files,
|
|
342
|
+
});
|
|
343
|
+
printJson({
|
|
344
|
+
id: entry.id,
|
|
345
|
+
name: entry.name,
|
|
346
|
+
version: entry.version ?? null,
|
|
347
|
+
visibility: entry.visibility,
|
|
348
|
+
files: Object.keys(files).sort(),
|
|
349
|
+
// Present only when a public publish queued a review submission.
|
|
350
|
+
...(entry.review_status ? { review_status: entry.review_status } : {}),
|
|
351
|
+
});
|
|
352
|
+
if (entry.review_status === 'pending') {
|
|
353
|
+
// stderr, keeping stdout parseable — same convention as printRefHint.
|
|
354
|
+
// A public publish is NOT cross-org visible until platform review
|
|
355
|
+
// approves; tell the publisher where to watch that state.
|
|
356
|
+
console.error(`Review pending: this clip stays invisible to other orgs until platform review approves it (your own org can use it immediately). ` +
|
|
357
|
+
`Check status with \`parall clip info ${entry.name}\` (review_status / review_note) ` +
|
|
358
|
+
`or GET /api/v1/orgs/${orgId}/clip-registry/${entry.id}.`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
printError(err);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
274
365
|
clip
|
|
275
366
|
.command('remove')
|
|
276
|
-
.description(
|
|
277
|
-
.argument('<
|
|
278
|
-
.action(async (
|
|
367
|
+
.description("Remove the organization's install of a clip")
|
|
368
|
+
.argument('<clip>', 'Registry clip id (crg_...) or name')
|
|
369
|
+
.action(async (clip) => {
|
|
279
370
|
try {
|
|
280
371
|
const { client, orgId } = resolveCredentials();
|
|
281
|
-
//
|
|
282
|
-
|
|
283
|
-
|
|
372
|
+
// Uninstall drops the org's reference in clip_installs. The v2
|
|
373
|
+
// deleteClip() operates on a different table and cannot reach a clip
|
|
374
|
+
// installed through the registry — it would report "not found" for one
|
|
375
|
+
// that plainly shows up in `clip list`.
|
|
376
|
+
const installed = await client.listInstalledRegistryClips(orgId);
|
|
377
|
+
const found = installed.find((c) => c.id === clip || c.name === clip);
|
|
284
378
|
if (!found) {
|
|
285
|
-
printError(new Error(`
|
|
379
|
+
printError(new Error(`No installed clip matching "${clip}" (see \`parall clip list\`)`));
|
|
380
|
+
return;
|
|
286
381
|
}
|
|
287
|
-
await client.
|
|
288
|
-
printJson({ ok: true, removed:
|
|
382
|
+
await client.uninstallRegistryClip(orgId, found.id);
|
|
383
|
+
printJson({ ok: true, removed: found.name, id: found.id });
|
|
289
384
|
}
|
|
290
385
|
catch (err) {
|
|
291
386
|
printError(err);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
/**
|
|
3
|
+
* `parall wechat …` — the per-vendor platform verb for the personal-WeChat
|
|
4
|
+
* protocol channel (wechatapi.net, tier B in the multi-channel
|
|
5
|
+
* architecture). The agent sends as the connected WeChat account itself;
|
|
6
|
+
* the vendor token + device id never reach the runtime — api-server
|
|
7
|
+
* performs the vendor call in-process.
|
|
8
|
+
* INTERNAL research preview: the capability is flag-gated to the internal
|
|
9
|
+
* org. Design: docs/engineering-design/wechat-channel-design.md §3.
|
|
10
|
+
*/
|
|
11
|
+
export declare function registerWechatCommands(program: Command): void;
|
|
12
|
+
//# sourceMappingURL=wechat.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wechat.d.ts","sourceRoot":"","sources":["../../src/commands/wechat.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAIpC;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,QA6CtD"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { resolveCredentials } from '../lib/client.js';
|
|
2
|
+
import { printError, printJson } from '../lib/output.js';
|
|
3
|
+
/**
|
|
4
|
+
* `parall wechat …` — the per-vendor platform verb for the personal-WeChat
|
|
5
|
+
* protocol channel (wechatapi.net, tier B in the multi-channel
|
|
6
|
+
* architecture). The agent sends as the connected WeChat account itself;
|
|
7
|
+
* the vendor token + device id never reach the runtime — api-server
|
|
8
|
+
* performs the vendor call in-process.
|
|
9
|
+
* INTERNAL research preview: the capability is flag-gated to the internal
|
|
10
|
+
* org. Design: docs/engineering-design/wechat-channel-design.md §3.
|
|
11
|
+
*/
|
|
12
|
+
export function registerWechatCommands(program) {
|
|
13
|
+
const wechat = program
|
|
14
|
+
.command('wechat')
|
|
15
|
+
.description('WeChat channel platform verbs (agent-only; sends as the connected account)');
|
|
16
|
+
wechat
|
|
17
|
+
.command('send')
|
|
18
|
+
.description('Send a message to a WeChat conversation this connection has seen inbound')
|
|
19
|
+
.requiredOption('--to <wxid>', 'Vendor-native conversation id from the inbound event (a friend wxid, or a room id ending in @chatroom)')
|
|
20
|
+
.requiredOption('--text <text>', 'Message text (plain text)')
|
|
21
|
+
.option('--at <wxid>', 'In group replies: @-mention one member (wxid of the person you are answering)')
|
|
22
|
+
.action(async (opts) => {
|
|
23
|
+
try {
|
|
24
|
+
const { client, orgId } = resolveCredentials();
|
|
25
|
+
const sent = await client.sendChannelMessage(orgId, {
|
|
26
|
+
channel_type: 'wechat',
|
|
27
|
+
conversation_id: opts.to,
|
|
28
|
+
text: opts.text,
|
|
29
|
+
...(opts.at ? { at: opts.at } : {}),
|
|
30
|
+
});
|
|
31
|
+
printJson(sent);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
printError(err);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
wechat
|
|
38
|
+
.command('contacts')
|
|
39
|
+
.description('List the account address book: friend wxids, saved group room ids, followed official accounts (ids only — the vendor returns no names)')
|
|
40
|
+
.action(async () => {
|
|
41
|
+
try {
|
|
42
|
+
const { client, orgId } = resolveCredentials();
|
|
43
|
+
printJson(await client.listWechatContacts(orgId));
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
printError(err);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { registerFileCommands } from './commands/files.js';
|
|
|
21
21
|
import { registerMachineCommands } from './commands/machines.js';
|
|
22
22
|
import { registerClipCommands } from './commands/clip.js';
|
|
23
23
|
import { registerSlackCommands } from './commands/slack.js';
|
|
24
|
+
import { registerWechatCommands } from './commands/wechat.js';
|
|
24
25
|
const require = createRequire(import.meta.url);
|
|
25
26
|
const pkg = require('../package.json');
|
|
26
27
|
const program = new Command();
|
|
@@ -48,4 +49,5 @@ registerFileCommands(program);
|
|
|
48
49
|
registerMachineCommands(program);
|
|
49
50
|
registerClipCommands(program);
|
|
50
51
|
registerSlackCommands(program);
|
|
52
|
+
registerWechatCommands(program);
|
|
51
53
|
program.parse();
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface PublishSource {
|
|
2
|
+
manifest: Record<string, unknown>;
|
|
3
|
+
files: Record<string, string>;
|
|
4
|
+
directoryName: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function collectPublishSource(directory: string): PublishSource;
|
|
7
|
+
/**
|
|
8
|
+
* The manifest subset that gets stored in the registry: the commands map plus
|
|
9
|
+
* the metadata keys the exec/display sides read. Mirrors the Edge CLI's
|
|
10
|
+
* buildPublishedManifest — local-only fields (name/version/visibility ride as
|
|
11
|
+
* top-level request fields) stay out of the stored manifest.
|
|
12
|
+
*/
|
|
13
|
+
export declare function buildPublishedManifest(manifest: Record<string, unknown>): Record<string, unknown>;
|
|
14
|
+
//# sourceMappingURL=publish-source.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"publish-source.d.ts","sourceRoot":"","sources":["../../src/lib/publish-source.ts"],"names":[],"mappings":"AA8BA,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,aAAa,EAAE,MAAM,CAAC;CACvB;AA4FD,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,aAAa,CAmCrE;AAsBD;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASjG"}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync, statSync, } from 'node:fs';
|
|
2
|
+
import { basename, isAbsolute, join, relative, sep } from 'node:path';
|
|
3
|
+
function fileIdentityMatches(a, b) {
|
|
4
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
5
|
+
}
|
|
6
|
+
function assertContained(root, candidate, name) {
|
|
7
|
+
const pathFromRoot = relative(root, candidate);
|
|
8
|
+
if (pathFromRoot === '..' || pathFromRoot.startsWith(`..${sep}`) || isAbsolute(pathFromRoot)) {
|
|
9
|
+
throw new Error(`Publish source escapes the clip directory: ${name}`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function assertSourceRootIdentity(root, expected) {
|
|
13
|
+
let current;
|
|
14
|
+
try {
|
|
15
|
+
current = lstatSync(root);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Error('Publish source directory changed while reading');
|
|
19
|
+
}
|
|
20
|
+
if (!current.isDirectory() || !fileIdentityMatches(expected, current)) {
|
|
21
|
+
throw new Error('Publish source directory changed while reading');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function readRegularFile(root, name) {
|
|
25
|
+
if (basename(name) !== name)
|
|
26
|
+
throw new Error(`Invalid publish source path: ${name}`);
|
|
27
|
+
const path = join(root, name);
|
|
28
|
+
const metadata = lstatSync(path);
|
|
29
|
+
if (metadata.isSymbolicLink())
|
|
30
|
+
throw new Error(`Publish source must not be a symlink: ${name}`);
|
|
31
|
+
if (!metadata.isFile())
|
|
32
|
+
throw new Error(`Publish source must be a regular file: ${name}`);
|
|
33
|
+
if (metadata.nlink !== 1)
|
|
34
|
+
throw new Error(`Publish source must not be a hard link: ${name}`);
|
|
35
|
+
const realPath = realpathSync(path);
|
|
36
|
+
assertContained(root, realPath, name);
|
|
37
|
+
const noFollow = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
|
|
38
|
+
let descriptor;
|
|
39
|
+
try {
|
|
40
|
+
descriptor = openSync(path, constants.O_RDONLY | noFollow);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
const code = error?.code;
|
|
44
|
+
if (code === 'ELOOP' || code === 'EMLINK') {
|
|
45
|
+
throw new Error(`Publish source must not be a symlink: ${name}`);
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const openedMetadata = fstatSync(descriptor);
|
|
51
|
+
if (!fileIdentityMatches(metadata, openedMetadata)) {
|
|
52
|
+
throw new Error(`Publish source changed while opening: ${name}`);
|
|
53
|
+
}
|
|
54
|
+
if (!openedMetadata.isFile()) {
|
|
55
|
+
throw new Error(`Publish source must be a regular file: ${name}`);
|
|
56
|
+
}
|
|
57
|
+
if (openedMetadata.nlink !== 1) {
|
|
58
|
+
throw new Error(`Publish source must not be a hard link: ${name}`);
|
|
59
|
+
}
|
|
60
|
+
return readFileSync(descriptor, 'utf8');
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
closeSync(descriptor);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function readManifest(root, entries, displayDirectory) {
|
|
67
|
+
const manifestNames = ['manifest.json', 'site.json'].filter((name) => entries.has(name));
|
|
68
|
+
if (manifestNames.length === 0) {
|
|
69
|
+
throw new Error(`No manifest.json or site.json found in ${displayDirectory}`);
|
|
70
|
+
}
|
|
71
|
+
let lastError;
|
|
72
|
+
for (const manifestName of manifestNames) {
|
|
73
|
+
// A readRegularFile refusal (symlink, swap, escape) propagates — safety
|
|
74
|
+
// refusals are not "try the next manifest" territory; only parse failures
|
|
75
|
+
// fall through to site.json.
|
|
76
|
+
const raw = readRegularFile(root, manifestName);
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(raw);
|
|
79
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
80
|
+
throw new Error('manifest must be a JSON object');
|
|
81
|
+
}
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
lastError = new Error(`Invalid ${manifestName}: ${error.message}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
throw lastError;
|
|
89
|
+
}
|
|
90
|
+
export function collectPublishSource(directory) {
|
|
91
|
+
let root;
|
|
92
|
+
let rootMetadata;
|
|
93
|
+
try {
|
|
94
|
+
root = realpathSync(directory);
|
|
95
|
+
rootMetadata = statSync(root);
|
|
96
|
+
if (!rootMetadata.isDirectory())
|
|
97
|
+
throw new Error('not a directory');
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new Error(`Clip directory is unavailable: ${directory}`);
|
|
101
|
+
}
|
|
102
|
+
const entries = new Map(readdirSync(root, { withFileTypes: true }).map((entry) => [entry.name, entry]));
|
|
103
|
+
assertSourceRootIdentity(root, rootMetadata);
|
|
104
|
+
const manifest = readManifest(root, entries, directory);
|
|
105
|
+
assertSourceRootIdentity(root, rootMetadata);
|
|
106
|
+
const scriptNames = [...entries.keys()].filter((name) => name.endsWith('.js')).sort();
|
|
107
|
+
if (scriptNames.length === 0) {
|
|
108
|
+
throw new Error(`No .js files found in ${directory}`);
|
|
109
|
+
}
|
|
110
|
+
const files = {};
|
|
111
|
+
for (const name of scriptNames) {
|
|
112
|
+
assertSourceRootIdentity(root, rootMetadata);
|
|
113
|
+
files[name] = readRegularFile(root, name);
|
|
114
|
+
assertSourceRootIdentity(root, rootMetadata);
|
|
115
|
+
}
|
|
116
|
+
// Fallback-name derivation: keep the name the user explicitly selected
|
|
117
|
+
// (including a symlinked directory's own name), but "." / ".." carry no
|
|
118
|
+
// name at all — resolve those through the real root, or `publish .` would
|
|
119
|
+
// fall back to a clip literally named "." (name is the org-wide upsert key).
|
|
120
|
+
const rawName = basename(directory);
|
|
121
|
+
const directoryName = rawName === '' || rawName === '.' || rawName === '..' ? basename(root) : rawName;
|
|
122
|
+
return { manifest, files, directoryName };
|
|
123
|
+
}
|
|
124
|
+
const PUBLISHED_METADATA_KEYS = ['type', 'domain', 'requires_login', 'server', 'auth'];
|
|
125
|
+
/**
|
|
126
|
+
* The explicit `type` values the platform recognizes. Absent/null means
|
|
127
|
+
* browser (the original clip.json shape predates the field), so only a
|
|
128
|
+
* declared-but-unrecognized value is refused — locally, before any request
|
|
129
|
+
* leaves the machine, mirroring the server's publish gate. "clip" gets a named
|
|
130
|
+
* hint because every Pinix v2 package manifest declares it, and a v2 manifest
|
|
131
|
+
* republished as-is used to be classified as an MCP clip downstream.
|
|
132
|
+
*/
|
|
133
|
+
function assertPublishableManifestType(manifest) {
|
|
134
|
+
const type = manifest.type;
|
|
135
|
+
if (type == null || type === 'browser' || type === 'mcp')
|
|
136
|
+
return;
|
|
137
|
+
const message = type === 'clip'
|
|
138
|
+
? 'manifest "type" "clip" is the legacy Pinix v2 package value — declare "browser" (or omit the field) for a browser clip, "mcp" for an MCP clip'
|
|
139
|
+
: `manifest "type" must be "browser" or "mcp" (or omitted for a browser clip); got ${JSON.stringify(type)}`;
|
|
140
|
+
throw new Error(`Invalid manifest: ${message}`);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The manifest subset that gets stored in the registry: the commands map plus
|
|
144
|
+
* the metadata keys the exec/display sides read. Mirrors the Edge CLI's
|
|
145
|
+
* buildPublishedManifest — local-only fields (name/version/visibility ride as
|
|
146
|
+
* top-level request fields) stay out of the stored manifest.
|
|
147
|
+
*/
|
|
148
|
+
export function buildPublishedManifest(manifest) {
|
|
149
|
+
assertPublishableManifestType(manifest);
|
|
150
|
+
// `||`, matching the Edge CLI exactly: a falsy non-object `commands` (a
|
|
151
|
+
// malformed manifest) normalizes to {} rather than being published as-is.
|
|
152
|
+
const published = { commands: manifest.commands || {} };
|
|
153
|
+
for (const key of PUBLISHED_METADATA_KEYS) {
|
|
154
|
+
if (manifest[key] !== undefined)
|
|
155
|
+
published[key] = manifest[key];
|
|
156
|
+
}
|
|
157
|
+
return published;
|
|
158
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parall/cli",
|
|
3
|
-
"version": "1.52.
|
|
3
|
+
"version": "1.52.2",
|
|
4
4
|
"description": "CLI client for Parall — universal agent & human access to Parall API",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,14 +36,14 @@
|
|
|
36
36
|
"diff": "^8.0.3",
|
|
37
37
|
"js-yaml": "^4.1.0",
|
|
38
38
|
"zod": "^4.3.6",
|
|
39
|
-
"@parall/
|
|
40
|
-
"@parall/
|
|
39
|
+
"@parall/agent-core": "1.52.2",
|
|
40
|
+
"@parall/sdk": "1.52.2"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/js-yaml": "^4.0.9",
|
|
44
44
|
"@types/node": "^22.0.0",
|
|
45
45
|
"typescript": "^5.7.0",
|
|
46
|
-
"@parall/agent-core": "1.52.
|
|
46
|
+
"@parall/agent-core": "1.52.2"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
49
|
"build": "tsc",
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import type { InvokeClipRequest, InvokeClipResponse, ParallClient } from '@parall/sdk';
|
|
2
|
-
/**
|
|
3
|
-
* Hosted browser activation wait — CLI-side bounded retry for `clip invoke`.
|
|
4
|
-
*
|
|
5
|
-
* A clip bound to a `placement=hosted` BrowserProfile whose pod is cold returns
|
|
6
|
-
* 503 BROWSER_PROFILE_ACTIVATING from the invoke pre-flight: the controller is
|
|
7
|
-
* spawning + registering the pod, and the clip command has NOT been dispatched
|
|
8
|
-
* yet, so retrying the same request cannot double-execute anything. This module
|
|
9
|
-
* absorbs that one precise error so a single `parall clip invoke` rides through
|
|
10
|
-
* cold start instead of forcing the caller (agent or human) into a blind
|
|
11
|
-
* retry loop.
|
|
12
|
-
*
|
|
13
|
-
* Deliberately NOT in the SDK: `invokeClip()` keeps its throw-on-503 semantics
|
|
14
|
-
* for every other consumer; only the CLI invoke behavior waits.
|
|
15
|
-
*/
|
|
16
|
-
/** Total activation wait budget. Independent of the clip command --timeout:
|
|
17
|
-
* each attempt keeps its own full command timeout, so worst-case wall clock is
|
|
18
|
-
* roughly this budget plus one command timeout. */
|
|
19
|
-
export declare const ACTIVATION_WAIT_BUDGET_MS = 60000;
|
|
20
|
-
/** Backoff schedule between activation retries; the last entry repeats. */
|
|
21
|
-
export declare const ACTIVATION_BACKOFF_MS: number[];
|
|
22
|
-
/** Injectable time hooks so tests never really sleep. */
|
|
23
|
-
export type ActivationWaitHooks = {
|
|
24
|
-
sleep?: (ms: number) => Promise<void>;
|
|
25
|
-
now?: () => number;
|
|
26
|
-
};
|
|
27
|
-
/**
|
|
28
|
-
* The ONLY retryable error: a typed ApiError carrying exactly 503 +
|
|
29
|
-
* BROWSER_PROFILE_ACTIVATING. Everything else — consent, permission, provider
|
|
30
|
-
* offline/not-running, command errors, timeouts, other 5xx — means retrying is
|
|
31
|
-
* either pointless or unsafe (the command may have been dispatched), so it
|
|
32
|
-
* propagates unchanged on the first occurrence.
|
|
33
|
-
*/
|
|
34
|
-
export declare function isActivationPending(err: unknown): boolean;
|
|
35
|
-
/**
|
|
36
|
-
* Invoke a clip, transparently absorbing the hosted-browser activation window.
|
|
37
|
-
*
|
|
38
|
-
* Retries the identical request while the server reports
|
|
39
|
-
* BROWSER_PROFILE_ACTIVATING, backing off 500ms → 1s → 2s (capped) until the
|
|
40
|
-
* 60s activation budget is spent. Once spent, the LAST typed ApiError is
|
|
41
|
-
* rethrown as-is — status/code intact — so the printed error stays
|
|
42
|
-
* machine-readable. Any other failure is rethrown immediately.
|
|
43
|
-
*/
|
|
44
|
-
export declare function invokeClipAwaitingActivation(client: Pick<ParallClient, 'invokeClip'>, orgId: string, req: InvokeClipRequest, hooks?: ActivationWaitHooks): Promise<InvokeClipResponse>;
|
|
45
|
-
//# sourceMappingURL=clip-invoke.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"clip-invoke.d.ts","sourceRoot":"","sources":["../../src/lib/clip-invoke.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAGvF;;;;;;;;;;;;;GAaG;AAEH;;oDAEoD;AACpD,eAAO,MAAM,yBAAyB,QAAS,CAAC;AAEhD,2EAA2E;AAC3E,eAAO,MAAM,qBAAqB,UAAsB,CAAC;AAEzD,yDAAyD;AACzD,MAAM,MAAM,mBAAmB,GAAG;IAChC,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB,CAAC;AAIF;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAEzD;AAED;;;;;;;;GAQG;AACH,wBAAsB,4BAA4B,CAChD,MAAM,EAAE,IAAI,CAAC,YAAY,EAAE,YAAY,CAAC,EACxC,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,iBAAiB,EACtB,KAAK,CAAC,EAAE,mBAAmB,GAC1B,OAAO,CAAC,kBAAkB,CAAC,CAiB7B"}
|
package/dist/lib/clip-invoke.js
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { ApiError } from '@parall/sdk';
|
|
2
|
-
/**
|
|
3
|
-
* Hosted browser activation wait — CLI-side bounded retry for `clip invoke`.
|
|
4
|
-
*
|
|
5
|
-
* A clip bound to a `placement=hosted` BrowserProfile whose pod is cold returns
|
|
6
|
-
* 503 BROWSER_PROFILE_ACTIVATING from the invoke pre-flight: the controller is
|
|
7
|
-
* spawning + registering the pod, and the clip command has NOT been dispatched
|
|
8
|
-
* yet, so retrying the same request cannot double-execute anything. This module
|
|
9
|
-
* absorbs that one precise error so a single `parall clip invoke` rides through
|
|
10
|
-
* cold start instead of forcing the caller (agent or human) into a blind
|
|
11
|
-
* retry loop.
|
|
12
|
-
*
|
|
13
|
-
* Deliberately NOT in the SDK: `invokeClip()` keeps its throw-on-503 semantics
|
|
14
|
-
* for every other consumer; only the CLI invoke behavior waits.
|
|
15
|
-
*/
|
|
16
|
-
/** Total activation wait budget. Independent of the clip command --timeout:
|
|
17
|
-
* each attempt keeps its own full command timeout, so worst-case wall clock is
|
|
18
|
-
* roughly this budget plus one command timeout. */
|
|
19
|
-
export const ACTIVATION_WAIT_BUDGET_MS = 60_000;
|
|
20
|
-
/** Backoff schedule between activation retries; the last entry repeats. */
|
|
21
|
-
export const ACTIVATION_BACKOFF_MS = [500, 1_000, 2_000];
|
|
22
|
-
const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
-
/**
|
|
24
|
-
* The ONLY retryable error: a typed ApiError carrying exactly 503 +
|
|
25
|
-
* BROWSER_PROFILE_ACTIVATING. Everything else — consent, permission, provider
|
|
26
|
-
* offline/not-running, command errors, timeouts, other 5xx — means retrying is
|
|
27
|
-
* either pointless or unsafe (the command may have been dispatched), so it
|
|
28
|
-
* propagates unchanged on the first occurrence.
|
|
29
|
-
*/
|
|
30
|
-
export function isActivationPending(err) {
|
|
31
|
-
return err instanceof ApiError && err.status === 503 && err.code === 'BROWSER_PROFILE_ACTIVATING';
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* Invoke a clip, transparently absorbing the hosted-browser activation window.
|
|
35
|
-
*
|
|
36
|
-
* Retries the identical request while the server reports
|
|
37
|
-
* BROWSER_PROFILE_ACTIVATING, backing off 500ms → 1s → 2s (capped) until the
|
|
38
|
-
* 60s activation budget is spent. Once spent, the LAST typed ApiError is
|
|
39
|
-
* rethrown as-is — status/code intact — so the printed error stays
|
|
40
|
-
* machine-readable. Any other failure is rethrown immediately.
|
|
41
|
-
*/
|
|
42
|
-
export async function invokeClipAwaitingActivation(client, orgId, req, hooks) {
|
|
43
|
-
const sleep = hooks?.sleep ?? realSleep;
|
|
44
|
-
const now = hooks?.now ?? Date.now;
|
|
45
|
-
const deadline = now() + ACTIVATION_WAIT_BUDGET_MS;
|
|
46
|
-
for (let attempt = 0;; attempt++) {
|
|
47
|
-
try {
|
|
48
|
-
return await client.invokeClip(orgId, req);
|
|
49
|
-
}
|
|
50
|
-
catch (err) {
|
|
51
|
-
if (!isActivationPending(err))
|
|
52
|
-
throw err;
|
|
53
|
-
const remaining = deadline - now();
|
|
54
|
-
if (remaining <= 0)
|
|
55
|
-
throw err;
|
|
56
|
-
const backoff = ACTIVATION_BACKOFF_MS[Math.min(attempt, ACTIVATION_BACKOFF_MS.length - 1)];
|
|
57
|
-
// Clamp to the remaining budget so the final sleep can't overrun it.
|
|
58
|
-
await sleep(Math.min(backoff, remaining));
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|