@postplus/cli 0.1.39 → 0.1.41
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/build/doctor.js +78 -6
- package/build/generated/hosted-execution-manifest.generated.js +2529 -0
- package/build/hosted-domain-commands.js +926 -164
- package/build/hosted-manifest-index.js +106 -0
- package/build/hosted-request-schemas.js +171 -322
- package/build/index.js +10 -9
- package/package.json +3 -2
- package/build/hosted-schema-catalog.js +0 -300
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { resolveFreshRemoteAuth } from './auth-session.js';
|
|
5
6
|
import { buildPostPlusClientCompatibilityHeaders, formatPostPlusCompatibilityError, } from './client-compatibility.js';
|
|
6
7
|
import { buildHostedRequestSchemaReport, buildMediaGenerationRequestDimensions, } from './hosted-request-schemas.js';
|
|
8
|
+
import { buildVerbTargetIndex, } from './hosted-manifest-index.js';
|
|
7
9
|
import { readLargeCreditQuoteConfirmationChallenge, } from './quote-confirmation.js';
|
|
10
|
+
// Manifest-driven verb grammar indexes (SSOT projected from apps/web +
|
|
11
|
+
// public-skill-metadata via the generated manifest). The verb/flag grammar,
|
|
12
|
+
// runner-managed set, and enum sets all come from the manifest so the CLI never
|
|
13
|
+
// hand-maintains a mirror of the Web hosted catalog.
|
|
14
|
+
const MEDIA_VERB_ENDPOINTS = buildVerbTargetIndex('media');
|
|
15
|
+
const RESEARCH_VERB_TARGETS = buildVerbTargetIndex('research');
|
|
16
|
+
const PUBLISH_VERB_OPERATIONS = buildPublishVerbIndex();
|
|
17
|
+
// Publish flattens to operation -> resolved target: the publish OPERATION is both
|
|
18
|
+
// the subcommand and the target (no separate positional), unlike media/research.
|
|
19
|
+
function buildPublishVerbIndex() {
|
|
20
|
+
const index = new Map();
|
|
21
|
+
for (const targets of buildVerbTargetIndex('publish').values()) {
|
|
22
|
+
for (const [operation, resolved] of targets) {
|
|
23
|
+
index.set(operation, resolved);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return index;
|
|
27
|
+
}
|
|
8
28
|
class HostedQuoteConfirmationRequiredError extends Error {
|
|
9
29
|
challenge;
|
|
10
30
|
constructor(message, challenge) {
|
|
@@ -13,12 +33,14 @@ class HostedQuoteConfirmationRequiredError extends Error {
|
|
|
13
33
|
this.name = 'HostedQuoteConfirmationRequiredError';
|
|
14
34
|
}
|
|
15
35
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
36
|
+
class HostedProductRequestError extends Error {
|
|
37
|
+
productError;
|
|
38
|
+
constructor(productError) {
|
|
39
|
+
super(formatHostedProductErrorMessage(productError));
|
|
40
|
+
this.productError = productError;
|
|
41
|
+
this.name = 'HostedProductRequestError';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
22
44
|
export async function runHostedDomainCommand(domain, args) {
|
|
23
45
|
const [subcommand, ...rest] = args;
|
|
24
46
|
if (domain === 'research') {
|
|
@@ -28,8 +50,8 @@ export async function runHostedDomainCommand(domain, args) {
|
|
|
28
50
|
if (subcommand === 'collect') {
|
|
29
51
|
return runResearchCollect(rest);
|
|
30
52
|
}
|
|
31
|
-
if (subcommand === '
|
|
32
|
-
return
|
|
53
|
+
if (subcommand === 'scrape') {
|
|
54
|
+
return runResearchScrape(rest);
|
|
33
55
|
}
|
|
34
56
|
printResearchHelp();
|
|
35
57
|
return subcommand === undefined || isHelp(subcommand) ? 0 : 1;
|
|
@@ -37,35 +59,557 @@ export async function runHostedDomainCommand(domain, args) {
|
|
|
37
59
|
if (subcommand === 'schema') {
|
|
38
60
|
return runHostedSchema(domain, rest);
|
|
39
61
|
}
|
|
40
|
-
|
|
41
|
-
|
|
62
|
+
// Poll a pending async media-generation run by handle. This is a hand-coded
|
|
63
|
+
// branch (not a manifest verb) because a status poll has no endpointKey/field
|
|
64
|
+
// contract to project — exactly like the `research collect --run-handle`
|
|
65
|
+
// polling branch. It must be checked before the manifest verb dispatch.
|
|
66
|
+
if (domain === 'media' && subcommand === 'poll') {
|
|
67
|
+
return runMediaPoll(rest);
|
|
68
|
+
}
|
|
69
|
+
if (domain === 'media' && subcommand && MEDIA_VERB_ENDPOINTS.has(subcommand)) {
|
|
70
|
+
return runMediaVerb(subcommand, rest);
|
|
71
|
+
}
|
|
72
|
+
// publish: the OPERATION is the subcommand (no separate target positional).
|
|
73
|
+
if (domain === 'publish' &&
|
|
74
|
+
subcommand &&
|
|
75
|
+
PUBLISH_VERB_OPERATIONS.has(subcommand)) {
|
|
76
|
+
return runPublishOperation(subcommand, rest);
|
|
42
77
|
}
|
|
43
|
-
|
|
78
|
+
printDomainVerbHelp(domain);
|
|
44
79
|
return subcommand === undefined || isHelp(subcommand) ? 0 : 1;
|
|
45
80
|
}
|
|
46
|
-
|
|
81
|
+
// Manifest-driven verb grammar: `postplus media <verb> <endpointKey> ...`. The
|
|
82
|
+
// endpoint's executionSurface decides the input shape — a flags surface maps
|
|
83
|
+
// scalar intent/default fields to flags, a request-json surface reads the nested
|
|
84
|
+
// envelope from `--request <file>`. Either way runner-managed fields (billing
|
|
85
|
+
// dimensions, ids, tokens) are derived/minted by the runner, never agent-supplied.
|
|
86
|
+
async function runMediaVerb(verb, args) {
|
|
87
|
+
const targets = MEDIA_VERB_ENDPOINTS.get(verb);
|
|
88
|
+
if (!targets) {
|
|
89
|
+
throw new Error(`Unknown media verb ${verb}.`);
|
|
90
|
+
}
|
|
91
|
+
const [targetKey, ...rest] = args;
|
|
92
|
+
if (!targetKey || targetKey.startsWith('--')) {
|
|
93
|
+
throw new Error(`postplus media ${verb} requires a target key. Run \`postplus media schema --json\` to list targets.`);
|
|
94
|
+
}
|
|
95
|
+
const resolved = targets.get(targetKey);
|
|
96
|
+
if (!resolved) {
|
|
97
|
+
throw new Error(`Unknown ${verb} target ${targetKey}. Valid: ${[...targets.keys()].join(', ')}.`);
|
|
98
|
+
}
|
|
99
|
+
// `postplus media <verb> <endpoint> --help`: render the endpoint's field-level
|
|
100
|
+
// contract (intent / default / runner-managed) instead of dispatching a request.
|
|
101
|
+
if (rest.some(isHelp)) {
|
|
102
|
+
printMediaEndpointHelp('media', verb, targetKey, resolved);
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
if (resolved.capability === 'video-analysis') {
|
|
106
|
+
return runVideoAnalysisVerb({
|
|
107
|
+
args: rest,
|
|
108
|
+
modelKey: targetKey,
|
|
109
|
+
resolved,
|
|
110
|
+
verb,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (resolved.surface === 'request-json') {
|
|
114
|
+
return runMediaVerbRequestJson({
|
|
115
|
+
args: rest,
|
|
116
|
+
endpointKey: targetKey,
|
|
117
|
+
resolved,
|
|
118
|
+
verb,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return runMediaVerbFlags({
|
|
122
|
+
args: rest,
|
|
123
|
+
endpointKey: targetKey,
|
|
124
|
+
resolved,
|
|
125
|
+
verb,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
// Flags surface (e.g. audio-transcription): scalar intent/default fields map to
|
|
129
|
+
// flags; runner-managed fields have no flag so the agent cannot pass them.
|
|
130
|
+
async function runMediaVerbFlags(args) {
|
|
131
|
+
const { endpointKey, resolved, verb } = args;
|
|
132
|
+
const endpoint = requireResolvedEndpoint(resolved, verb, endpointKey);
|
|
133
|
+
const fields = endpoint.fields;
|
|
134
|
+
const flagToField = new Map();
|
|
135
|
+
const booleanKeys = new Set(['json']);
|
|
136
|
+
const arrayKeys = new Set();
|
|
137
|
+
for (const field of fields) {
|
|
138
|
+
if (!field.flag) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const key = field.flag.replace(/^--/u, '');
|
|
142
|
+
flagToField.set(key, field);
|
|
143
|
+
if (field.type === 'boolean') {
|
|
144
|
+
booleanKeys.add(key);
|
|
145
|
+
}
|
|
146
|
+
if (field.repeatable) {
|
|
147
|
+
arrayKeys.add(key);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const flags = parseFlags(args.args, booleanKeys, arrayKeys);
|
|
151
|
+
const outputPath = flags.values.get('output') ?? null;
|
|
152
|
+
const controlKeys = new Set([
|
|
153
|
+
'hosted-operation-id',
|
|
154
|
+
'json',
|
|
155
|
+
'output',
|
|
156
|
+
'quote-confirmation-token',
|
|
157
|
+
'skill',
|
|
158
|
+
]);
|
|
159
|
+
// Reject unknown flags. This is how runner-managed fields (no flag) and typos
|
|
160
|
+
// are caught locally before any hosted call.
|
|
161
|
+
for (const key of [
|
|
162
|
+
...flags.values.keys(),
|
|
163
|
+
...flags.booleans,
|
|
164
|
+
...flags.arrays.keys(),
|
|
165
|
+
]) {
|
|
166
|
+
if (!flagToField.has(key) && !controlKeys.has(key)) {
|
|
167
|
+
throw new Error(`Unknown option for media ${verb}: --${key}.`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const input = buildMediaVerbInput({
|
|
171
|
+
endpointKey,
|
|
172
|
+
fields,
|
|
173
|
+
flags,
|
|
174
|
+
verb,
|
|
175
|
+
});
|
|
176
|
+
return submitMediaGenerationRequest({
|
|
177
|
+
capability: resolved.capability,
|
|
178
|
+
endpointKey,
|
|
179
|
+
errorInputLabel: `media-${verb}-${endpointKey}`,
|
|
180
|
+
input,
|
|
181
|
+
json: flags.booleans.has('json'),
|
|
182
|
+
operationId: flags.values.get('hosted-operation-id') ??
|
|
183
|
+
`postplus-cli:media:${resolved.capability}:request:${randomUUID()}`,
|
|
184
|
+
outputPath,
|
|
185
|
+
quoteConfirmationToken: flags.values.get('quote-confirmation-token'),
|
|
186
|
+
skillName: flags.values.get('skill') ?? resolved.skill,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
// Request-json surface (e.g. seedance-submitter): the nested envelope is supplied
|
|
190
|
+
// via `--request <file>`. capability/endpointKey come from the verb + positional,
|
|
191
|
+
// so the body carries only the media-generation input. Runner-managed fields have
|
|
192
|
+
// no flag and must not appear in the body — the CLI fast-fails if they do.
|
|
193
|
+
async function runMediaVerbRequestJson(args) {
|
|
194
|
+
const { endpointKey, resolved, verb } = args;
|
|
195
|
+
const endpoint = requireResolvedEndpoint(resolved, verb, endpointKey);
|
|
196
|
+
const flags = parseFlags(args.args, new Set(['json']));
|
|
197
|
+
const allowedKeys = new Set([
|
|
198
|
+
'hosted-operation-id',
|
|
199
|
+
'json',
|
|
200
|
+
'output',
|
|
201
|
+
'quote-confirmation-token',
|
|
202
|
+
'request',
|
|
203
|
+
'skill',
|
|
204
|
+
]);
|
|
205
|
+
for (const key of [...flags.values.keys(), ...flags.booleans]) {
|
|
206
|
+
if (!allowedKeys.has(key)) {
|
|
207
|
+
throw new Error(`Unknown option for media ${verb}: --${key}.`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const requestPath = requireFlag(flags, 'request');
|
|
211
|
+
const outputPath = flags.values.get('output') ?? null;
|
|
212
|
+
const raw = await readJsonFile(requestPath);
|
|
213
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
214
|
+
throw new Error(`media ${verb} ${endpointKey} --request must be a JSON object of media-generation input.`);
|
|
215
|
+
}
|
|
216
|
+
const input = raw;
|
|
217
|
+
// Runner-managed fields are minted/derived by the CLI; reject them in the body so
|
|
218
|
+
// the agent cannot smuggle in ids, tokens, or billing dimensions.
|
|
219
|
+
for (const field of endpoint.fields) {
|
|
220
|
+
if (field.class === 'runner-managed' &&
|
|
221
|
+
Object.hasOwn(input, field.name)) {
|
|
222
|
+
throw new Error(`media ${verb} ${endpointKey} input must not include runner-managed field "${field.name}"; the CLI mints or derives it.`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return submitMediaGenerationRequest({
|
|
226
|
+
capability: resolved.capability,
|
|
227
|
+
endpointKey,
|
|
228
|
+
errorInputLabel: requestPath,
|
|
229
|
+
input,
|
|
230
|
+
json: flags.booleans.has('json'),
|
|
231
|
+
operationId: flags.values.get('hosted-operation-id') ??
|
|
232
|
+
`postplus-cli:media:${resolved.capability}:request:${randomUUID()}`,
|
|
233
|
+
outputPath,
|
|
234
|
+
quoteConfirmationToken: flags.values.get('quote-confirmation-token'),
|
|
235
|
+
skillName: flags.values.get('skill') ?? resolved.skill,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
function requireResolvedEndpoint(resolved, verb, endpointKey) {
|
|
239
|
+
if (!resolved.endpoint) {
|
|
240
|
+
throw new Error(`media ${verb} ${endpointKey} resolved to a non-endpoint target; this verb requires a media-generation endpoint.`);
|
|
241
|
+
}
|
|
242
|
+
return resolved.endpoint;
|
|
243
|
+
}
|
|
244
|
+
// video-analysis verb (request-json surface). The agent authors an opaque Gemini
|
|
245
|
+
// request object (contents + generationConfig) in `--request <file>`; capability,
|
|
246
|
+
// operation, and modelKey come from the verb + positional, so the body posts
|
|
247
|
+
// EXACTLY the locked Web contract. There is no field classification and no
|
|
248
|
+
// estimatedUsage — the payload is forwarded verbatim as the Gemini request.
|
|
249
|
+
async function runVideoAnalysisVerb(args) {
|
|
250
|
+
const { modelKey, resolved, verb } = args;
|
|
251
|
+
const flags = parseFlags(args.args, new Set(['json']));
|
|
252
|
+
const allowedKeys = new Set([
|
|
253
|
+
'hosted-operation-id',
|
|
254
|
+
'json',
|
|
255
|
+
'output',
|
|
256
|
+
'quote-confirmation-token',
|
|
257
|
+
'request',
|
|
258
|
+
'skill',
|
|
259
|
+
]);
|
|
260
|
+
for (const key of [...flags.values.keys(), ...flags.booleans]) {
|
|
261
|
+
if (!allowedKeys.has(key)) {
|
|
262
|
+
throw new Error(`Unknown option for media ${verb}: --${key}.`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const requestPath = requireFlag(flags, 'request');
|
|
266
|
+
const outputPath = flags.values.get('output') ?? null;
|
|
267
|
+
const raw = await readJsonFile(requestPath);
|
|
268
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
269
|
+
throw new Error(`media ${verb} ${modelKey} --request must be a JSON object of Gemini request payload.`);
|
|
270
|
+
}
|
|
271
|
+
const payload = raw;
|
|
272
|
+
const body = {
|
|
273
|
+
capability: 'video-analysis',
|
|
274
|
+
operation: 'analyze',
|
|
275
|
+
modelKey,
|
|
276
|
+
payload,
|
|
277
|
+
operationId: flags.values.get('hosted-operation-id') ??
|
|
278
|
+
`postplus-cli:media:video-analysis:analyze:${randomUUID()}`,
|
|
279
|
+
quoteConfirmationToken: flags.values.get('quote-confirmation-token') ?? undefined,
|
|
280
|
+
};
|
|
281
|
+
return runHostedCommand({
|
|
282
|
+
request: () => postHostedJson({
|
|
283
|
+
body,
|
|
284
|
+
pathName: '/api/postplus-cli/hosted/capability',
|
|
285
|
+
skillName: flags.values.get('skill') ?? resolved.skill,
|
|
286
|
+
}),
|
|
287
|
+
errorInputLabel: requestPath,
|
|
288
|
+
json: flags.booleans.has('json'),
|
|
289
|
+
outputPath,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
// `media-file upload`: the generic local-file -> hosted storageReference verb.
|
|
293
|
+
// Released skills ship no scripts, so any skill that must place a local file
|
|
294
|
+
// behind a hosted reference before a hosted request (e.g. video-analysis before
|
|
295
|
+
// `media analyze`) drives it through this verb. It is capability-generic: it has
|
|
296
|
+
// no knowledge of any one skill's request payload, it only mints an opaque
|
|
297
|
+
// storageReference the agent then embeds in its own hosted request. The runner
|
|
298
|
+
// holds no provider keys — it asks the Web boundary for a signed upload target,
|
|
299
|
+
// PUTs the bytes, and returns the storageReference the Web boundary issued.
|
|
300
|
+
const MEDIA_FILE_MIME_BY_EXTENSION = {
|
|
301
|
+
'.gif': 'image/gif',
|
|
302
|
+
'.jpeg': 'image/jpeg',
|
|
303
|
+
'.jpg': 'image/jpeg',
|
|
304
|
+
'.m4a': 'audio/mp4',
|
|
305
|
+
'.m4v': 'video/mp4',
|
|
306
|
+
'.mov': 'video/quicktime',
|
|
307
|
+
'.mp3': 'audio/mpeg',
|
|
308
|
+
'.mp4': 'video/mp4',
|
|
309
|
+
'.png': 'image/png',
|
|
310
|
+
'.wav': 'audio/wav',
|
|
311
|
+
'.webm': 'video/webm',
|
|
312
|
+
'.webp': 'image/webp',
|
|
313
|
+
};
|
|
314
|
+
function inferUploadMimeType(filePath) {
|
|
315
|
+
return (MEDIA_FILE_MIME_BY_EXTENSION[path.extname(filePath).toLowerCase()] ??
|
|
316
|
+
'application/octet-stream');
|
|
317
|
+
}
|
|
318
|
+
export async function runMediaFileCommand(args) {
|
|
319
|
+
const [subcommand, ...rest] = args;
|
|
320
|
+
if (subcommand === 'upload') {
|
|
321
|
+
return runMediaFileUpload(rest);
|
|
322
|
+
}
|
|
323
|
+
printMediaFileHelp();
|
|
324
|
+
return subcommand === undefined || isHelp(subcommand) ? 0 : 1;
|
|
325
|
+
}
|
|
326
|
+
async function runMediaFileUpload(args) {
|
|
47
327
|
const flags = parseFlags(args, new Set(['json']));
|
|
48
|
-
const
|
|
328
|
+
const allowedKeys = new Set([
|
|
329
|
+
'hosted-operation-id',
|
|
330
|
+
'input-file',
|
|
331
|
+
'json',
|
|
332
|
+
'mime',
|
|
333
|
+
'output',
|
|
334
|
+
'quote-confirmation-token',
|
|
335
|
+
'skill',
|
|
336
|
+
]);
|
|
337
|
+
for (const key of [...flags.values.keys(), ...flags.booleans]) {
|
|
338
|
+
if (!allowedKeys.has(key)) {
|
|
339
|
+
throw new Error(`Unknown option for media-file upload: --${key}.`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const inputFile = requireFlag(flags, 'input-file');
|
|
343
|
+
const absolutePath = path.resolve(inputFile);
|
|
344
|
+
const fileStat = await stat(absolutePath);
|
|
345
|
+
if (!fileStat.isFile()) {
|
|
346
|
+
throw new Error(`media-file upload source is not a file: ${absolutePath}`);
|
|
347
|
+
}
|
|
348
|
+
const mimeType = flags.values.get('mime') ?? inferUploadMimeType(absolutePath);
|
|
49
349
|
const outputPath = flags.values.get('output') ?? null;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
350
|
+
const body = {
|
|
351
|
+
capability: 'media-file',
|
|
352
|
+
operation: 'create-upload-url',
|
|
353
|
+
file: {
|
|
354
|
+
mimeType,
|
|
355
|
+
name: path.basename(absolutePath),
|
|
356
|
+
sizeBytes: fileStat.size,
|
|
357
|
+
},
|
|
358
|
+
operationId: flags.values.get('hosted-operation-id') ??
|
|
359
|
+
`postplus-cli:media-file:create-upload-url:${randomUUID()}`,
|
|
360
|
+
quoteConfirmationToken: flags.values.get('quote-confirmation-token') ?? undefined,
|
|
361
|
+
};
|
|
362
|
+
return runHostedCommand({
|
|
363
|
+
request: async () => {
|
|
364
|
+
const payload = await postHostedJson({
|
|
365
|
+
body,
|
|
366
|
+
pathName: '/api/postplus-cli/hosted/capability',
|
|
367
|
+
skillName: flags.values.get('skill') ?? null,
|
|
368
|
+
});
|
|
369
|
+
const output = readHostedUploadOutput(payload);
|
|
370
|
+
const signedUpload = readSignedUpload(output);
|
|
371
|
+
await putHostedMediaBytes(signedUpload, absolutePath);
|
|
372
|
+
return { storageReference: readStorageReferenceValue(output) };
|
|
373
|
+
},
|
|
374
|
+
errorInputLabel: inputFile,
|
|
375
|
+
json: flags.booleans.has('json'),
|
|
376
|
+
outputPath,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
function readHostedUploadOutput(payload) {
|
|
380
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
381
|
+
const output = payload.output;
|
|
382
|
+
if (output && typeof output === 'object' && !Array.isArray(output)) {
|
|
383
|
+
return output;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
throw new Error('Hosted media upload response is missing output.');
|
|
387
|
+
}
|
|
388
|
+
function readSignedUpload(output) {
|
|
389
|
+
const signedUpload = output.signedUpload;
|
|
390
|
+
if (!signedUpload ||
|
|
391
|
+
typeof signedUpload !== 'object' ||
|
|
392
|
+
Array.isArray(signedUpload)) {
|
|
393
|
+
throw new Error('Hosted media upload response is missing signedUpload.');
|
|
394
|
+
}
|
|
395
|
+
const record = signedUpload;
|
|
396
|
+
if (typeof record.url !== 'string' || !record.url.trim()) {
|
|
397
|
+
throw new Error('Hosted media upload signedUpload.url must be a string.');
|
|
398
|
+
}
|
|
399
|
+
if (record.method !== 'PUT') {
|
|
400
|
+
throw new Error(`Unsupported hosted media signed upload method: ${String(record.method)}.`);
|
|
401
|
+
}
|
|
402
|
+
const requiredHeaders = {};
|
|
403
|
+
if (record.requiredHeaders &&
|
|
404
|
+
typeof record.requiredHeaders === 'object' &&
|
|
405
|
+
!Array.isArray(record.requiredHeaders)) {
|
|
406
|
+
for (const [key, value] of Object.entries(record.requiredHeaders)) {
|
|
407
|
+
if (typeof value !== 'string') {
|
|
408
|
+
throw new Error(`Hosted media upload signedUpload.requiredHeaders.${key} must be a string.`);
|
|
409
|
+
}
|
|
410
|
+
requiredHeaders[key] = value;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return { method: record.method, requiredHeaders, url: record.url.trim() };
|
|
414
|
+
}
|
|
415
|
+
function readStorageReferenceValue(output) {
|
|
416
|
+
const storageReference = output.storageReference;
|
|
417
|
+
if (!storageReference ||
|
|
418
|
+
typeof storageReference !== 'object' ||
|
|
419
|
+
Array.isArray(storageReference)) {
|
|
420
|
+
throw new Error('Hosted media upload response is missing storageReference.');
|
|
421
|
+
}
|
|
422
|
+
return storageReference;
|
|
423
|
+
}
|
|
424
|
+
async function putHostedMediaBytes(signedUpload, absolutePath) {
|
|
425
|
+
const response = await fetch(signedUpload.url, {
|
|
426
|
+
body: createReadStream(absolutePath),
|
|
427
|
+
duplex: 'half',
|
|
428
|
+
headers: signedUpload.requiredHeaders,
|
|
429
|
+
method: 'PUT',
|
|
430
|
+
signal: AbortSignal.timeout(120000),
|
|
431
|
+
});
|
|
432
|
+
if (!response.ok) {
|
|
433
|
+
throw new Error(`Hosted media signed upload failed with status ${response.status}.`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
function printMediaFileHelp() {
|
|
437
|
+
process.stdout.write(`PostPlus CLI - media-file commands
|
|
438
|
+
|
|
439
|
+
Usage:
|
|
440
|
+
postplus media-file upload --input-file <path> [--mime <type>] [--skill <skill-id>] [--json] [--output <result.json>]
|
|
441
|
+
`);
|
|
442
|
+
}
|
|
443
|
+
// Shared submit path for both surfaces: wrap the media input, derive billing
|
|
444
|
+
// dimensions from endpointKey + input, and POST to the Web boundary.
|
|
445
|
+
function submitMediaGenerationRequest(params) {
|
|
446
|
+
const body = {
|
|
447
|
+
capability: params.capability,
|
|
448
|
+
endpointKey: params.endpointKey,
|
|
449
|
+
input: params.input,
|
|
450
|
+
operation: 'request',
|
|
451
|
+
operationId: params.operationId,
|
|
452
|
+
quoteConfirmationToken: params.quoteConfirmationToken ?? undefined,
|
|
453
|
+
requestDimensions: buildMediaGenerationRequestDimensions(params.endpointKey, params.input),
|
|
454
|
+
};
|
|
455
|
+
return runHostedCommand({
|
|
456
|
+
request: () => postHostedJson({
|
|
457
|
+
body,
|
|
458
|
+
pathName: '/api/postplus-cli/hosted/capability',
|
|
459
|
+
skillName: params.skillName,
|
|
460
|
+
}),
|
|
461
|
+
errorInputLabel: params.errorInputLabel,
|
|
462
|
+
json: params.json,
|
|
463
|
+
outputPath: params.outputPath,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
// Poll a pending media-generation run: `postplus media poll --handle <run-id>`.
|
|
467
|
+
// A media `create`/`transcribe`/`analyze` submit returns an async run handle
|
|
468
|
+
// (`output.data.id`, also surfaced as `output.data.urls.get`) while the provider
|
|
469
|
+
// job is still processing. This resumes that run by handle against the
|
|
470
|
+
// media-generation `operation: 'status'` boundary. It is read-only and
|
|
471
|
+
// billing-idempotent: the Web boundary finds the run by handle and settlement
|
|
472
|
+
// reuses the submit's operationId, so polling never re-reserves or re-charges.
|
|
473
|
+
// The body carries only the status quadruple; submit-only fields (input,
|
|
474
|
+
// requestDimensions, quoteConfirmationToken) are never sent. Mirrors the
|
|
475
|
+
// `research collect --run-handle` polling branch.
|
|
476
|
+
async function runMediaPoll(args) {
|
|
477
|
+
const flags = parseFlags(args, new Set(['json']));
|
|
478
|
+
const handle = requireFlag(flags, 'handle');
|
|
479
|
+
const outputPath = flags.values.get('output') ?? null;
|
|
480
|
+
return runHostedCommand({
|
|
481
|
+
request: () => postHostedJson({
|
|
482
|
+
body: {
|
|
483
|
+
capability: 'media-generation',
|
|
484
|
+
handle,
|
|
485
|
+
operation: 'status',
|
|
486
|
+
operationId: `postplus-cli:media:media-generation:status:${randomUUID()}`,
|
|
487
|
+
},
|
|
488
|
+
pathName: '/api/postplus-cli/hosted/capability',
|
|
54
489
|
skillName: null,
|
|
490
|
+
}),
|
|
491
|
+
errorInputLabel: 'media-poll-handle',
|
|
492
|
+
json: flags.booleans.has('json'),
|
|
493
|
+
outputPath,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
function buildMediaVerbInput(input) {
|
|
497
|
+
const record = {};
|
|
498
|
+
for (const field of input.fields) {
|
|
499
|
+
if (field.class === 'runner-managed' || !field.flag) {
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const key = field.flag.replace(/^--/u, '');
|
|
503
|
+
if (field.repeatable) {
|
|
504
|
+
const list = input.flags.arrays.get(key) ?? [];
|
|
505
|
+
if (list.length === 0) {
|
|
506
|
+
if (field.required) {
|
|
507
|
+
throw new Error(`Missing required option --${key} for media ${input.verb} ${input.endpointKey}.`);
|
|
508
|
+
}
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
record[field.name] = list;
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (field.type === 'boolean') {
|
|
515
|
+
if (input.flags.booleans.has(key)) {
|
|
516
|
+
record[field.name] = true;
|
|
517
|
+
}
|
|
518
|
+
else if (typeof field.default === 'boolean') {
|
|
519
|
+
record[field.name] = field.default;
|
|
520
|
+
}
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
const raw = input.flags.values.get(key);
|
|
524
|
+
if (raw === undefined) {
|
|
525
|
+
if (field.class === 'default' && field.default !== undefined) {
|
|
526
|
+
record[field.name] = field.default;
|
|
527
|
+
}
|
|
528
|
+
else if (field.required) {
|
|
529
|
+
throw new Error(`Missing required option --${key} for media ${input.verb} ${input.endpointKey}.`);
|
|
530
|
+
}
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (field.enumValues && !field.enumValues.includes(raw)) {
|
|
534
|
+
throw new Error(`--${key} must be one of ${field.enumValues.join(', ')}.`);
|
|
535
|
+
}
|
|
536
|
+
if (field.type === 'number') {
|
|
537
|
+
const parsed = Number(raw);
|
|
538
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
539
|
+
throw new Error(`--${key} must be a positive number.`);
|
|
540
|
+
}
|
|
541
|
+
record[field.name] = parsed;
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
record[field.name] = raw;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return record;
|
|
548
|
+
}
|
|
549
|
+
// Manifest-driven hosted-collection verb (request-json surface). The polling path
|
|
550
|
+
// (`--run-handle`) resumes a pending run unchanged. The launch path resolves the
|
|
551
|
+
// positional `<collectionKey>` against the research verb index for verb `collect`,
|
|
552
|
+
// reads the collection input object directly from `--request <file>` (NOT a
|
|
553
|
+
// schemaVersion envelope), and posts to /hosted/collection. The resolved entry
|
|
554
|
+
// gives the default skillName (overridable by `--skill`); the actorId stays
|
|
555
|
+
// internal and is never placed on the public body.
|
|
556
|
+
async function runResearchCollect(args) {
|
|
557
|
+
const [first, ...rest] = args;
|
|
558
|
+
// Polling path: `research collect --run-handle <h>`. No positional collectionKey.
|
|
559
|
+
if (!first || first.startsWith('--')) {
|
|
560
|
+
const flags = parseFlags(args, new Set(['json']));
|
|
561
|
+
const runHandle = requireFlag(flags, 'run-handle');
|
|
562
|
+
const outputPath = flags.values.get('output') ?? null;
|
|
563
|
+
return runHostedCommand({
|
|
564
|
+
request: () => postHostedJson({
|
|
565
|
+
body: { runHandle },
|
|
566
|
+
pathName: '/api/postplus-cli/hosted/collection',
|
|
567
|
+
skillName: null,
|
|
568
|
+
}),
|
|
569
|
+
errorInputLabel: 'research-collect-run-handle',
|
|
570
|
+
json: flags.booleans.has('json'),
|
|
571
|
+
outputPath,
|
|
55
572
|
});
|
|
56
|
-
|
|
573
|
+
}
|
|
574
|
+
const verb = 'collect';
|
|
575
|
+
const collectionKey = first;
|
|
576
|
+
const targets = RESEARCH_VERB_TARGETS.get(verb);
|
|
577
|
+
const resolved = targets?.get(collectionKey);
|
|
578
|
+
if (!resolved) {
|
|
579
|
+
const valid = targets ? [...targets.keys()].join(', ') : '';
|
|
580
|
+
throw new Error(`Unknown research collect collection ${collectionKey}. Valid: ${valid}.`);
|
|
581
|
+
}
|
|
582
|
+
// `postplus research collect <collection-key> --help`: opaque-input contract.
|
|
583
|
+
if (rest.some(isHelp)) {
|
|
584
|
+
printOpaqueTargetHelp('research', verb, collectionKey, resolved);
|
|
57
585
|
return 0;
|
|
58
586
|
}
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
587
|
+
const flags = parseFlags(rest, new Set(['json']));
|
|
588
|
+
const allowedKeys = new Set([
|
|
589
|
+
'hosted-operation-id',
|
|
590
|
+
'json',
|
|
591
|
+
'max-charge-usd',
|
|
592
|
+
'output',
|
|
593
|
+
'quote-confirmation-token',
|
|
594
|
+
'request',
|
|
595
|
+
'skill',
|
|
596
|
+
]);
|
|
597
|
+
for (const key of [...flags.values.keys(), ...flags.booleans]) {
|
|
598
|
+
if (!allowedKeys.has(key)) {
|
|
599
|
+
throw new Error(`Unknown option for research ${verb}: --${key}.`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
const requestPath = requireFlag(flags, 'request');
|
|
603
|
+
const outputPath = flags.values.get('output') ?? null;
|
|
604
|
+
const raw = await readJsonFile(requestPath);
|
|
605
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
606
|
+
throw new Error(`research ${verb} ${collectionKey} --request must be a JSON object of collection input.`);
|
|
607
|
+
}
|
|
608
|
+
const input = raw;
|
|
609
|
+
const skillName = flags.values.get('skill') ?? resolved.skill;
|
|
63
610
|
const operationId = flags.values.get('hosted-operation-id') ??
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
`postplus-cli:research:${collectionKey}:${randomUUID()}`;
|
|
67
|
-
const quoteConfirmationToken = flags.values.get('quote-confirmation-token') ??
|
|
68
|
-
normalizeString(envelope.quoteConfirmationToken);
|
|
611
|
+
`postplus-cli:research:collect:${collectionKey}:${randomUUID()}`;
|
|
612
|
+
const quoteConfirmationToken = flags.values.get('quote-confirmation-token');
|
|
69
613
|
// Optional per-request cost ceiling (USD) overriding the hosted default.
|
|
70
614
|
const maxChargeFlag = flags.values.get('max-charge-usd');
|
|
71
615
|
let maxTotalChargeUsd;
|
|
@@ -76,23 +620,165 @@ async function runResearchCollect(args) {
|
|
|
76
620
|
}
|
|
77
621
|
maxTotalChargeUsd = parsed;
|
|
78
622
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
623
|
+
return runHostedCommand({
|
|
624
|
+
request: () => postHostedJson({
|
|
625
|
+
body: {
|
|
626
|
+
collectionKey,
|
|
627
|
+
input,
|
|
628
|
+
operationId,
|
|
629
|
+
quoteConfirmationToken: quoteConfirmationToken ?? undefined,
|
|
630
|
+
skillName,
|
|
631
|
+
maxTotalChargeUsd,
|
|
632
|
+
},
|
|
633
|
+
pathName: '/api/postplus-cli/hosted/collection',
|
|
85
634
|
skillName,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
skillName,
|
|
90
|
-
}).catch((error) => buildHostedCommandError(error, {
|
|
91
|
-
inputPath,
|
|
635
|
+
}),
|
|
636
|
+
errorInputLabel: requestPath,
|
|
637
|
+
json: flags.booleans.has('json'),
|
|
92
638
|
outputPath,
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
// Manifest-driven public-content-collection verb (request-json surface). Resolves
|
|
642
|
+
// the positional `<sourceKey>` against the research verb index for verb `scrape`,
|
|
643
|
+
// reads the scrape input directly from `--request <file>` as a JSON ARRAY of input
|
|
644
|
+
// records (one per public URL/query), and posts to /hosted/capability with
|
|
645
|
+
// capability `public-content-collection` / operation `scrape`. The resolved entry
|
|
646
|
+
// gives the default skillName (overridable by `--skill`); the datasetId stays
|
|
647
|
+
// internal and is never placed on the public body.
|
|
648
|
+
async function runResearchScrape(args) {
|
|
649
|
+
const [first, ...rest] = args;
|
|
650
|
+
const verb = 'scrape';
|
|
651
|
+
const targets = RESEARCH_VERB_TARGETS.get(verb);
|
|
652
|
+
if (!first || first.startsWith('--')) {
|
|
653
|
+
const valid = targets ? [...targets.keys()].join(', ') : '';
|
|
654
|
+
throw new Error(`postplus research ${verb} requires a source key. Valid: ${valid}.`);
|
|
655
|
+
}
|
|
656
|
+
const sourceKey = first;
|
|
657
|
+
const resolved = targets?.get(sourceKey);
|
|
658
|
+
if (!resolved) {
|
|
659
|
+
const valid = targets ? [...targets.keys()].join(', ') : '';
|
|
660
|
+
throw new Error(`Unknown research scrape source ${sourceKey}. Valid: ${valid}.`);
|
|
661
|
+
}
|
|
662
|
+
// `postplus research scrape <source-key> --help`: opaque-array-input contract.
|
|
663
|
+
if (rest.some(isHelp)) {
|
|
664
|
+
printOpaqueTargetHelp('research', verb, sourceKey, resolved);
|
|
665
|
+
return 0;
|
|
666
|
+
}
|
|
667
|
+
const flags = parseFlags(rest, new Set(['json']));
|
|
668
|
+
const allowedKeys = new Set([
|
|
669
|
+
'hosted-operation-id',
|
|
670
|
+
'json',
|
|
671
|
+
'max-charge-usd',
|
|
672
|
+
'output',
|
|
673
|
+
'quote-confirmation-token',
|
|
674
|
+
'request',
|
|
675
|
+
'skill',
|
|
676
|
+
]);
|
|
677
|
+
for (const key of [...flags.values.keys(), ...flags.booleans]) {
|
|
678
|
+
if (!allowedKeys.has(key)) {
|
|
679
|
+
throw new Error(`Unknown option for research ${verb}: --${key}.`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
const requestPath = requireFlag(flags, 'request');
|
|
683
|
+
const outputPath = flags.values.get('output') ?? null;
|
|
684
|
+
const raw = await readJsonFile(requestPath);
|
|
685
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
686
|
+
throw new Error(`research ${verb} ${sourceKey} --request must be a non-empty JSON array of scrape input records.`);
|
|
687
|
+
}
|
|
688
|
+
const input = raw;
|
|
689
|
+
const skillName = flags.values.get('skill') ?? resolved.skill;
|
|
690
|
+
const operationId = flags.values.get('hosted-operation-id') ??
|
|
691
|
+
`postplus-cli:research:scrape:${sourceKey}:${randomUUID()}`;
|
|
692
|
+
const quoteConfirmationToken = flags.values.get('quote-confirmation-token');
|
|
693
|
+
// Optional per-request cost ceiling (USD) overriding the hosted default.
|
|
694
|
+
const maxChargeFlag = flags.values.get('max-charge-usd');
|
|
695
|
+
let maxTotalChargeUsd;
|
|
696
|
+
if (maxChargeFlag !== undefined) {
|
|
697
|
+
const parsed = Number(maxChargeFlag);
|
|
698
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
699
|
+
throw new Error('--max-charge-usd must be a positive number of USD.');
|
|
700
|
+
}
|
|
701
|
+
maxTotalChargeUsd = parsed;
|
|
702
|
+
}
|
|
703
|
+
// The Web /hosted/capability scrape contract is a strict object: skillName is
|
|
704
|
+
// carried as the compatibility header (postHostedJson), never on the public body.
|
|
705
|
+
return runHostedCommand({
|
|
706
|
+
request: () => postHostedJson({
|
|
707
|
+
body: {
|
|
708
|
+
capability: 'public-content-collection',
|
|
709
|
+
operation: 'scrape',
|
|
710
|
+
sourceKey,
|
|
711
|
+
input,
|
|
712
|
+
operationId,
|
|
713
|
+
quoteConfirmationToken: quoteConfirmationToken ?? undefined,
|
|
714
|
+
maxTotalChargeUsd,
|
|
715
|
+
},
|
|
716
|
+
pathName: '/api/postplus-cli/hosted/capability',
|
|
717
|
+
skillName,
|
|
718
|
+
}),
|
|
719
|
+
errorInputLabel: requestPath,
|
|
720
|
+
json: flags.booleans.has('json'),
|
|
721
|
+
outputPath,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
// Manifest-driven publish operation (request-json surface). The OPERATION is the
|
|
725
|
+
// subcommand and the target: `postplus publish <operation> --request <file>`. The
|
|
726
|
+
// publishing input object is read directly from `--request <file>` and posted to
|
|
727
|
+
// /hosted/capability with capability `social-publishing` / the resolved operation.
|
|
728
|
+
// Side-effecting operations surface the Web quote-confirmation challenge; the
|
|
729
|
+
// shared runHostedCommand handles the challenge -> retry-with-token path. There is
|
|
730
|
+
// no requestDimensions/approval/execute — those were private-runtime concepts.
|
|
731
|
+
async function runPublishOperation(operation, args) {
|
|
732
|
+
const resolved = PUBLISH_VERB_OPERATIONS.get(operation);
|
|
733
|
+
if (!resolved) {
|
|
734
|
+
throw new Error(`Unknown publish operation ${operation}. Valid: ${[...PUBLISH_VERB_OPERATIONS.keys()].join(', ')}.`);
|
|
735
|
+
}
|
|
736
|
+
// `postplus publish <operation> --help`: opaque-input contract.
|
|
737
|
+
if (args.some(isHelp)) {
|
|
738
|
+
printOpaqueTargetHelp('publish', operation, operation, resolved);
|
|
739
|
+
return 0;
|
|
740
|
+
}
|
|
741
|
+
const flags = parseFlags(args, new Set(['json']));
|
|
742
|
+
const allowedKeys = new Set([
|
|
743
|
+
'hosted-operation-id',
|
|
744
|
+
'json',
|
|
745
|
+
'output',
|
|
746
|
+
'quote-confirmation-token',
|
|
747
|
+
'request',
|
|
748
|
+
'skill',
|
|
749
|
+
]);
|
|
750
|
+
for (const key of [...flags.values.keys(), ...flags.booleans]) {
|
|
751
|
+
if (!allowedKeys.has(key)) {
|
|
752
|
+
throw new Error(`Unknown option for publish ${operation}: --${key}.`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
const requestPath = requireFlag(flags, 'request');
|
|
756
|
+
const outputPath = flags.values.get('output') ?? null;
|
|
757
|
+
const raw = await readJsonFile(requestPath);
|
|
758
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
759
|
+
throw new Error(`publish ${operation} --request must be a JSON object of publishing input.`);
|
|
760
|
+
}
|
|
761
|
+
const input = raw;
|
|
762
|
+
const skillName = flags.values.get('skill') ?? resolved.skill;
|
|
763
|
+
const operationId = flags.values.get('hosted-operation-id') ??
|
|
764
|
+
`postplus-cli:publish:social-publishing:request:${randomUUID()}`;
|
|
765
|
+
const quoteConfirmationToken = flags.values.get('quote-confirmation-token');
|
|
766
|
+
return runHostedCommand({
|
|
767
|
+
request: () => postHostedJson({
|
|
768
|
+
body: {
|
|
769
|
+
capability: 'social-publishing',
|
|
770
|
+
operation,
|
|
771
|
+
input,
|
|
772
|
+
operationId,
|
|
773
|
+
quoteConfirmationToken: quoteConfirmationToken ?? undefined,
|
|
774
|
+
},
|
|
775
|
+
pathName: '/api/postplus-cli/hosted/capability',
|
|
776
|
+
skillName,
|
|
777
|
+
}),
|
|
778
|
+
errorInputLabel: requestPath,
|
|
779
|
+
json: flags.booleans.has('json'),
|
|
780
|
+
outputPath,
|
|
781
|
+
});
|
|
96
782
|
}
|
|
97
783
|
async function runHostedSchema(domain, args) {
|
|
98
784
|
const flags = parseFlags(args, new Set(['json']));
|
|
@@ -113,65 +799,6 @@ async function runHostedSchema(domain, args) {
|
|
|
113
799
|
}));
|
|
114
800
|
return 0;
|
|
115
801
|
}
|
|
116
|
-
async function runHostedCapability(domain, args) {
|
|
117
|
-
const flags = parseFlags(args, new Set(['json']));
|
|
118
|
-
const requestPath = requireFlag(flags, 'request');
|
|
119
|
-
const outputPath = flags.values.get('output') ?? null;
|
|
120
|
-
const request = await readJsonFile(requestPath);
|
|
121
|
-
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
|
122
|
-
throw new Error(`Hosted ${domain} capability request must be a JSON object.`);
|
|
123
|
-
}
|
|
124
|
-
const record = request;
|
|
125
|
-
const capability = requireDomainCapability(record, domain);
|
|
126
|
-
const operation = requireRecordString(record, 'operation');
|
|
127
|
-
const operationId = flags.values.get('hosted-operation-id') ??
|
|
128
|
-
normalizeString(record.operationId) ??
|
|
129
|
-
`postplus-cli:${domain}:${capability}:${operation}:${randomUUID()}`;
|
|
130
|
-
const quoteConfirmationToken = flags.values.get('quote-confirmation-token') ??
|
|
131
|
-
normalizeString(record.quoteConfirmationToken);
|
|
132
|
-
const publicRecord = { ...record };
|
|
133
|
-
delete publicRecord.skillName;
|
|
134
|
-
const derivedFields = buildDerivedHostedCapabilityFields({
|
|
135
|
-
capability,
|
|
136
|
-
domain,
|
|
137
|
-
operation,
|
|
138
|
-
record,
|
|
139
|
-
});
|
|
140
|
-
const body = {
|
|
141
|
-
...publicRecord,
|
|
142
|
-
...derivedFields,
|
|
143
|
-
capability,
|
|
144
|
-
operation,
|
|
145
|
-
operationId,
|
|
146
|
-
quoteConfirmationToken: quoteConfirmationToken ?? undefined,
|
|
147
|
-
};
|
|
148
|
-
const skillName = flags.values.get('skill') ?? normalizeString(record.skillName);
|
|
149
|
-
const payload = await postHostedJson({
|
|
150
|
-
body,
|
|
151
|
-
pathName: '/api/postplus-cli/hosted/capability',
|
|
152
|
-
skillName,
|
|
153
|
-
}).catch((error) => buildHostedCommandError(error, {
|
|
154
|
-
inputPath: requestPath,
|
|
155
|
-
outputPath,
|
|
156
|
-
}));
|
|
157
|
-
await writeResult(payload, outputPath, flags.booleans.has('json'));
|
|
158
|
-
return 0;
|
|
159
|
-
}
|
|
160
|
-
function buildDerivedHostedCapabilityFields(input) {
|
|
161
|
-
if (input.domain !== 'media' ||
|
|
162
|
-
input.capability !== 'media-generation' ||
|
|
163
|
-
input.operation !== 'request') {
|
|
164
|
-
return {};
|
|
165
|
-
}
|
|
166
|
-
if (Object.hasOwn(input.record, 'requestDimensions')) {
|
|
167
|
-
throw new Error('Hosted media-generation request must not include requestDimensions. The CLI derives billing dimensions from endpointKey and input.');
|
|
168
|
-
}
|
|
169
|
-
const endpointKey = requireRecordString(input.record, 'endpointKey');
|
|
170
|
-
const mediaInput = requireRecordObject(input.record, 'input');
|
|
171
|
-
return {
|
|
172
|
-
requestDimensions: buildMediaGenerationRequestDimensions(endpointKey, mediaInput),
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
802
|
async function postHostedJson(input) {
|
|
176
803
|
let auth = await resolveFreshRemoteAuth();
|
|
177
804
|
let response = await postJson({
|
|
@@ -193,36 +820,61 @@ async function postHostedJson(input) {
|
|
|
193
820
|
}
|
|
194
821
|
const payload = await readJsonResponse(response);
|
|
195
822
|
if (!response.ok) {
|
|
823
|
+
const productError = readHostedProductError(payload);
|
|
196
824
|
const challenge = readLargeCreditQuoteConfirmationChallenge(payload);
|
|
197
825
|
if (challenge) {
|
|
198
|
-
throw new HostedQuoteConfirmationRequiredError(
|
|
826
|
+
throw new HostedQuoteConfirmationRequiredError(productError.message, challenge);
|
|
199
827
|
}
|
|
200
828
|
const compatibilityError = formatPostPlusCompatibilityError(payload);
|
|
201
829
|
if (compatibilityError) {
|
|
202
830
|
throw new Error(compatibilityError);
|
|
203
831
|
}
|
|
204
|
-
throw new
|
|
832
|
+
throw new HostedProductRequestError(productError);
|
|
205
833
|
}
|
|
206
834
|
return payload;
|
|
207
835
|
}
|
|
208
|
-
|
|
209
|
-
|
|
836
|
+
// Single exit path for every hosted command: success writes the result and
|
|
837
|
+
// returns 0; a quote challenge writes the challenge file and rethrows actionable
|
|
838
|
+
// guidance; a structured product error writes the full error envelope to the
|
|
839
|
+
// result JSON and surfaces code/layer/operationId on the terminal, exiting 1.
|
|
840
|
+
async function runHostedCommand(input) {
|
|
841
|
+
let payload;
|
|
842
|
+
try {
|
|
843
|
+
payload = await input.request();
|
|
844
|
+
}
|
|
845
|
+
catch (error) {
|
|
846
|
+
if (error instanceof HostedQuoteConfirmationRequiredError) {
|
|
847
|
+
const challengePath = await writeQuoteConfirmationChallenge(error, {
|
|
848
|
+
errorInputLabel: input.errorInputLabel,
|
|
849
|
+
outputPath: input.outputPath,
|
|
850
|
+
});
|
|
851
|
+
throw new Error([
|
|
852
|
+
error.message,
|
|
853
|
+
`Quote confirmation challenge: ${challengePath}`,
|
|
854
|
+
`Confirm: postplus quote confirm --json --challenge-file "${challengePath}"`,
|
|
855
|
+
'Then rerun the hosted command with --quote-confirmation-token <token>.',
|
|
856
|
+
].join('\n'));
|
|
857
|
+
}
|
|
858
|
+
if (error instanceof HostedProductRequestError) {
|
|
859
|
+
await writeResult({ error: error.productError }, input.outputPath, input.json);
|
|
860
|
+
process.stderr.write(`${error.message}\n`);
|
|
861
|
+
return 1;
|
|
862
|
+
}
|
|
210
863
|
throw error;
|
|
211
864
|
}
|
|
865
|
+
await writeResult(payload, input.outputPath, input.json);
|
|
866
|
+
return 0;
|
|
867
|
+
}
|
|
868
|
+
async function writeQuoteConfirmationChallenge(error, input) {
|
|
212
869
|
const challengePath = path.resolve(input.outputPath
|
|
213
870
|
? `${input.outputPath}.quote-confirmation.json`
|
|
214
|
-
: `${input.
|
|
871
|
+
: `${input.errorInputLabel}.quote-confirmation.json`);
|
|
215
872
|
await mkdir(path.dirname(challengePath), { recursive: true });
|
|
216
873
|
await writeFile(challengePath, `${JSON.stringify(error.challenge, null, 2)}\n`, {
|
|
217
874
|
encoding: 'utf8',
|
|
218
875
|
mode: 0o600,
|
|
219
876
|
});
|
|
220
|
-
|
|
221
|
-
error.message,
|
|
222
|
-
`Quote confirmation challenge: ${challengePath}`,
|
|
223
|
-
`Confirm: postplus quote confirm --json --challenge-file "${challengePath}"`,
|
|
224
|
-
'Then rerun the hosted command with --quote-confirmation-token <token>.',
|
|
225
|
-
].join('\n'));
|
|
877
|
+
return challengePath;
|
|
226
878
|
}
|
|
227
879
|
async function postJson(input) {
|
|
228
880
|
const headers = await buildPostPlusClientCompatibilityHeaders({
|
|
@@ -252,17 +904,31 @@ async function readJsonResponse(response) {
|
|
|
252
904
|
throw new Error('PostPlus Cloud returned invalid JSON.');
|
|
253
905
|
}
|
|
254
906
|
}
|
|
255
|
-
function
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
907
|
+
function readHostedProductError(payload) {
|
|
908
|
+
const record = payload && typeof payload === 'object' && !Array.isArray(payload)
|
|
909
|
+
? payload
|
|
910
|
+
: {};
|
|
911
|
+
return {
|
|
912
|
+
message: normalizeString(record.error) ??
|
|
913
|
+
normalizeString(record.message) ??
|
|
914
|
+
'PostPlus hosted capability request failed.',
|
|
915
|
+
code: normalizeString(record.code) ?? normalizeString(record.productErrorCode),
|
|
916
|
+
layer: normalizeString(record.layer),
|
|
917
|
+
operationId: normalizeString(record.operationId),
|
|
918
|
+
userMessageRule: normalizeString(record.userMessageRule),
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
// Terminal message that keeps the stable code, owning layer, and operation id
|
|
922
|
+
// visible next to the human-readable message so a failed run is locatable.
|
|
923
|
+
function formatHostedProductErrorMessage(productError) {
|
|
924
|
+
const locator = [
|
|
925
|
+
productError.code ? `code=${productError.code}` : null,
|
|
926
|
+
productError.layer ? `layer=${productError.layer}` : null,
|
|
927
|
+
productError.operationId ? `operationId=${productError.operationId}` : null,
|
|
928
|
+
].filter((part) => part !== null);
|
|
929
|
+
return locator.length > 0
|
|
930
|
+
? `${productError.message} (${locator.join(' ')})`
|
|
931
|
+
: productError.message;
|
|
266
932
|
}
|
|
267
933
|
async function readJsonFile(filePath) {
|
|
268
934
|
try {
|
|
@@ -274,16 +940,6 @@ async function readJsonFile(filePath) {
|
|
|
274
940
|
: `Failed to read JSON file ${filePath}.`);
|
|
275
941
|
}
|
|
276
942
|
}
|
|
277
|
-
function readHostedEnvelope(value, filePath) {
|
|
278
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
279
|
-
throw new Error(`${filePath} must be a schemaVersion 1 hosted envelope.`);
|
|
280
|
-
}
|
|
281
|
-
const envelope = value;
|
|
282
|
-
if (envelope.schemaVersion !== 1 || !Object.hasOwn(envelope, 'input')) {
|
|
283
|
-
throw new Error(`${filePath} must be a schemaVersion 1 hosted envelope.`);
|
|
284
|
-
}
|
|
285
|
-
return envelope;
|
|
286
|
-
}
|
|
287
943
|
async function writeResult(payload, outputPath, forceStdout) {
|
|
288
944
|
const text = `${JSON.stringify(payload, null, 2)}\n`;
|
|
289
945
|
if (!outputPath || forceStdout) {
|
|
@@ -294,9 +950,10 @@ async function writeResult(payload, outputPath, forceStdout) {
|
|
|
294
950
|
await writeFile(outputPath, text);
|
|
295
951
|
}
|
|
296
952
|
}
|
|
297
|
-
function parseFlags(args, booleanFlags) {
|
|
953
|
+
function parseFlags(args, booleanFlags, arrayFlags = new Set()) {
|
|
298
954
|
const values = new Map();
|
|
299
955
|
const booleans = new Set();
|
|
956
|
+
const arrays = new Map();
|
|
300
957
|
for (let index = 0; index < args.length; index += 1) {
|
|
301
958
|
const arg = args[index];
|
|
302
959
|
if (!arg.startsWith('--')) {
|
|
@@ -311,10 +968,17 @@ function parseFlags(args, booleanFlags) {
|
|
|
311
968
|
if (!value || value.startsWith('--')) {
|
|
312
969
|
throw new Error(`Missing value for --${key}.`);
|
|
313
970
|
}
|
|
314
|
-
|
|
971
|
+
if (arrayFlags.has(key)) {
|
|
972
|
+
const list = arrays.get(key) ?? [];
|
|
973
|
+
list.push(value);
|
|
974
|
+
arrays.set(key, list);
|
|
975
|
+
}
|
|
976
|
+
else {
|
|
977
|
+
values.set(key, value);
|
|
978
|
+
}
|
|
315
979
|
index += 1;
|
|
316
980
|
}
|
|
317
|
-
return { booleans, values };
|
|
981
|
+
return { arrays, booleans, values };
|
|
318
982
|
}
|
|
319
983
|
function requireFlag(flags, key) {
|
|
320
984
|
const value = flags.values.get(key);
|
|
@@ -323,28 +987,6 @@ function requireFlag(flags, key) {
|
|
|
323
987
|
}
|
|
324
988
|
return value;
|
|
325
989
|
}
|
|
326
|
-
function requireDomainCapability(record, domain) {
|
|
327
|
-
const capability = requireRecordString(record, 'capability');
|
|
328
|
-
const allowed = HOSTED_DOMAIN_CAPABILITIES[domain];
|
|
329
|
-
if (!allowed.has(capability)) {
|
|
330
|
-
throw new Error(`Hosted ${domain} capability request uses unsupported capability ${capability}.`);
|
|
331
|
-
}
|
|
332
|
-
return capability;
|
|
333
|
-
}
|
|
334
|
-
function requireRecordString(record, key) {
|
|
335
|
-
const value = normalizeString(record[key]);
|
|
336
|
-
if (!value) {
|
|
337
|
-
throw new Error(`Hosted capability request must include string ${key}.`);
|
|
338
|
-
}
|
|
339
|
-
return value;
|
|
340
|
-
}
|
|
341
|
-
function requireRecordObject(record, key) {
|
|
342
|
-
const value = record[key];
|
|
343
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
344
|
-
throw new Error(`Hosted capability request must include object ${key}.`);
|
|
345
|
-
}
|
|
346
|
-
return value;
|
|
347
|
-
}
|
|
348
990
|
function normalizeString(value) {
|
|
349
991
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
350
992
|
}
|
|
@@ -356,17 +998,137 @@ function printResearchHelp() {
|
|
|
356
998
|
|
|
357
999
|
Usage:
|
|
358
1000
|
postplus research schema [--collection-key <key>] [--json]
|
|
359
|
-
postplus research collect
|
|
1001
|
+
postplus research collect <collection-key> --request <input.json> [--skill <skill-id>] [--max-charge-usd <usd>] [--output <result.json>]
|
|
360
1002
|
postplus research collect --run-handle <runHandle> [--output <result.json>]
|
|
361
|
-
postplus research
|
|
1003
|
+
postplus research scrape <source-key> --request <input-array.json> [--skill <skill-id>] [--max-charge-usd <usd>] [--output <result.json>]
|
|
362
1004
|
`);
|
|
363
1005
|
}
|
|
364
|
-
function
|
|
1006
|
+
function printDomainVerbHelp(domain) {
|
|
1007
|
+
const verbUsage = domain === 'media'
|
|
1008
|
+
? [...MEDIA_VERB_ENDPOINTS.keys()]
|
|
1009
|
+
.map((verb) => ` postplus media ${verb} <endpoint-key> --<intent/default flags> [--json] [--output <result.json>]\n`)
|
|
1010
|
+
.join('') +
|
|
1011
|
+
' postplus media poll --handle <run-id> [--json] [--output <result.json>]\n'
|
|
1012
|
+
: ' postplus publish <operation> --request <input.json> [--json] [--output <result.json>]\n';
|
|
365
1013
|
process.stdout.write(`PostPlus CLI - ${domain} commands
|
|
366
1014
|
|
|
367
1015
|
Usage:
|
|
368
|
-
postplus ${domain} schema${domain === 'media' ? ' [--endpoint <endpoint-key>]' : ''} [--json]
|
|
369
|
-
|
|
1016
|
+
${verbUsage} postplus ${domain} schema${domain === 'media' ? ' [--endpoint <endpoint-key>]' : ''} [--json]
|
|
1017
|
+
`);
|
|
1018
|
+
}
|
|
1019
|
+
// Per-endpoint `--help` for a media-generation endpoint (and the video-analysis
|
|
1020
|
+
// model). Renders the endpoint's field-level contract grouped into the envelope's
|
|
1021
|
+
// three classes — intent (you write it), default (manifest-defaulted; write only
|
|
1022
|
+
// to deviate), runner-managed (minted by the CLI; never an input) — using the
|
|
1023
|
+
// manifest as the SSOT for flags, enum sets, ranges, and defaults.
|
|
1024
|
+
function printMediaEndpointHelp(domain, verb, targetKey, resolved) {
|
|
1025
|
+
// video-analysis: opaque Gemini payload, no field classification to render.
|
|
1026
|
+
if (resolved.capability === 'video-analysis') {
|
|
1027
|
+
process.stdout.write(`PostPlus CLI - ${domain} ${verb} ${targetKey}
|
|
1028
|
+
|
|
1029
|
+
Surface: request-json (opaque Gemini request payload)
|
|
1030
|
+
Usage:
|
|
1031
|
+
postplus ${domain} ${verb} ${targetKey} --request <input.json> [--json] [--output <result.json>]
|
|
1032
|
+
|
|
1033
|
+
--request <file> A JSON object authored verbatim as the Gemini request
|
|
1034
|
+
(contents + optional generationConfig) under "payload".
|
|
1035
|
+
Runner-managed (minted by the CLI; never in the body): operationId, quoteConfirmationToken
|
|
1036
|
+
`);
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
if (!resolved.endpoint) {
|
|
1040
|
+
throw new Error(`media ${verb} ${targetKey} resolved to a non-endpoint target.`);
|
|
1041
|
+
}
|
|
1042
|
+
const fields = resolved.endpoint.fields;
|
|
1043
|
+
const isFlagsSurface = resolved.surface === 'flags';
|
|
1044
|
+
const intent = fields.filter((field) => field.class === 'intent');
|
|
1045
|
+
const defaulted = fields.filter((field) => field.class === 'default');
|
|
1046
|
+
const managed = fields.filter((field) => field.class === 'runner-managed');
|
|
1047
|
+
const lines = [
|
|
1048
|
+
`PostPlus CLI - ${domain} ${verb} ${targetKey}`,
|
|
1049
|
+
'',
|
|
1050
|
+
` Surface: ${resolved.surface}`,
|
|
1051
|
+
' Usage:',
|
|
1052
|
+
isFlagsSurface
|
|
1053
|
+
? ` postplus ${domain} ${verb} ${targetKey} ${formatFlagsUsage(fields)} [--json] [--output <result.json>]`
|
|
1054
|
+
: ` postplus ${domain} ${verb} ${targetKey} --request <input.json> [--json] [--output <result.json>]`,
|
|
1055
|
+
'',
|
|
1056
|
+
];
|
|
1057
|
+
appendFieldGroup(lines, 'Intent (you must / may write):', intent, isFlagsSurface);
|
|
1058
|
+
appendFieldGroup(lines, 'Default (manifest-defaulted; write only to deviate):', defaulted, isFlagsSurface);
|
|
1059
|
+
if (managed.length > 0) {
|
|
1060
|
+
lines.push(' Runner-managed (minted by the CLI; never an input):');
|
|
1061
|
+
for (const field of managed) {
|
|
1062
|
+
const derived = field.derivedFrom
|
|
1063
|
+
? ` (derived from ${field.derivedFrom})`
|
|
1064
|
+
: '';
|
|
1065
|
+
lines.push(` ${field.name}${derived}`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
1069
|
+
}
|
|
1070
|
+
function formatFlagsUsage(fields) {
|
|
1071
|
+
const parts = [];
|
|
1072
|
+
for (const field of fields) {
|
|
1073
|
+
if (field.class === 'runner-managed' || !field.flag) {
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
const token = `${field.flag} <${field.name}>`;
|
|
1077
|
+
parts.push(field.required ? token : `[${token}]`);
|
|
1078
|
+
}
|
|
1079
|
+
return parts.join(' ');
|
|
1080
|
+
}
|
|
1081
|
+
function appendFieldGroup(lines, title, fields, isFlagsSurface) {
|
|
1082
|
+
if (fields.length === 0) {
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
lines.push(` ${title}`);
|
|
1086
|
+
for (const field of fields) {
|
|
1087
|
+
const label = isFlagsSurface && field.flag ? field.flag : `(json) ${field.name}`;
|
|
1088
|
+
lines.push(` ${label}${formatFieldDetail(field)}`);
|
|
1089
|
+
}
|
|
1090
|
+
lines.push('');
|
|
1091
|
+
}
|
|
1092
|
+
// Field detail: type, required/optional, enum set or numeric range, default, and
|
|
1093
|
+
// repeatable arity — all read from the manifest contract.
|
|
1094
|
+
function formatFieldDetail(field) {
|
|
1095
|
+
const detail = [
|
|
1096
|
+
field.repeatable ? `${field.type}[]` : field.type,
|
|
1097
|
+
field.required ? 'required' : 'optional',
|
|
1098
|
+
];
|
|
1099
|
+
if (field.enumValues && field.enumValues.length > 0) {
|
|
1100
|
+
detail.push(`one of {${field.enumValues.join(', ')}}`);
|
|
1101
|
+
}
|
|
1102
|
+
else if (field.min !== undefined || field.max !== undefined) {
|
|
1103
|
+
detail.push(`range ${field.min ?? '-'}..${field.max ?? '-'}`);
|
|
1104
|
+
}
|
|
1105
|
+
if (field.default !== undefined) {
|
|
1106
|
+
detail.push(`default ${String(field.default)}`);
|
|
1107
|
+
}
|
|
1108
|
+
return ` [${detail.join('; ')}]`;
|
|
1109
|
+
}
|
|
1110
|
+
// Per-target `--help` for capabilities whose request body is an opaque JSON object
|
|
1111
|
+
// (research collect/scrape, publish): there is no field classification to render,
|
|
1112
|
+
// so the help states the input shape and the runner-managed protocol fields.
|
|
1113
|
+
function printOpaqueTargetHelp(domain, verb, targetKey, resolved) {
|
|
1114
|
+
const inputShape = resolved.capability === 'public-content-collection'
|
|
1115
|
+
? 'a non-empty JSON array of provider-shaped scrape records'
|
|
1116
|
+
: 'a provider-shaped JSON object of input';
|
|
1117
|
+
// publish's operation is both the verb and the target, so the header/usage show
|
|
1118
|
+
// it once; research shows `<verb> <target>`.
|
|
1119
|
+
const header = domain === 'publish' ? `publish ${targetKey}` : `research ${verb} ${targetKey}`;
|
|
1120
|
+
const usage = domain === 'publish'
|
|
1121
|
+
? ` postplus publish ${targetKey} --request <input.json> [--json] [--output <result.json>]`
|
|
1122
|
+
: ` postplus research ${verb} ${targetKey} --request <input.json> [--skill <skill-id>] [--max-charge-usd <usd>] [--json] [--output <result.json>]`;
|
|
1123
|
+
process.stdout.write(`PostPlus CLI - ${header}
|
|
1124
|
+
|
|
1125
|
+
Surface: request-json (opaque input authored by the agent)
|
|
1126
|
+
Capability: ${resolved.capability}
|
|
1127
|
+
Usage:
|
|
1128
|
+
${usage}
|
|
1129
|
+
|
|
1130
|
+
--request <file> ${inputShape}.
|
|
1131
|
+
Runner-managed (minted by the CLI; never in the body): operationId, quoteConfirmationToken
|
|
370
1132
|
`);
|
|
371
1133
|
}
|
|
372
1134
|
function writeJson(value) {
|