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