@postplus/cli 0.1.39 → 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 +889 -165
- package/build/hosted-manifest-index.js +106 -0
- package/build/hosted-request-schemas.js +171 -322
- package/build/index.js +9 -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,600 @@ export async function runHostedDomainCommand(domain, args) {
|
|
|
37
59
|
if (subcommand === 'schema') {
|
|
38
60
|
return runHostedSchema(domain, rest);
|
|
39
61
|
}
|
|
40
|
-
if (
|
|
41
|
-
return
|
|
62
|
+
if (domain === 'media' && subcommand && MEDIA_VERB_ENDPOINTS.has(subcommand)) {
|
|
63
|
+
return runMediaVerb(subcommand, rest);
|
|
42
64
|
}
|
|
43
|
-
|
|
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);
|
|
70
|
+
}
|
|
71
|
+
printDomainVerbHelp(domain);
|
|
44
72
|
return subcommand === undefined || isHelp(subcommand) ? 0 : 1;
|
|
45
73
|
}
|
|
46
|
-
|
|
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) {
|
|
47
320
|
const flags = parseFlags(args, new Set(['json']));
|
|
48
|
-
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);
|
|
49
342
|
const outputPath = flags.values.get('output') ?? null;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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,
|
|
55
535
|
});
|
|
56
|
-
|
|
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);
|
|
548
|
+
return 0;
|
|
549
|
+
}
|
|
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;
|
|
573
|
+
const operationId = flags.values.get('hosted-operation-id') ??
|
|
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',
|
|
597
|
+
skillName,
|
|
598
|
+
}),
|
|
599
|
+
errorInputLabel: requestPath,
|
|
600
|
+
json: flags.booleans.has('json'),
|
|
601
|
+
outputPath,
|
|
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);
|
|
57
628
|
return 0;
|
|
58
629
|
}
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
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;
|
|
63
653
|
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);
|
|
654
|
+
`postplus-cli:research:scrape:${sourceKey}:${randomUUID()}`;
|
|
655
|
+
const quoteConfirmationToken = flags.values.get('quote-confirmation-token');
|
|
69
656
|
// Optional per-request cost ceiling (USD) overriding the hosted default.
|
|
70
657
|
const maxChargeFlag = flags.values.get('max-charge-usd');
|
|
71
658
|
let maxTotalChargeUsd;
|
|
@@ -76,23 +663,85 @@ async function runResearchCollect(args) {
|
|
|
76
663
|
}
|
|
77
664
|
maxTotalChargeUsd = parsed;
|
|
78
665
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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',
|
|
85
680
|
skillName,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
skillName,
|
|
90
|
-
}).catch((error) => buildHostedCommandError(error, {
|
|
91
|
-
inputPath,
|
|
681
|
+
}),
|
|
682
|
+
errorInputLabel: requestPath,
|
|
683
|
+
json: flags.booleans.has('json'),
|
|
92
684
|
outputPath,
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
});
|
|
96
745
|
}
|
|
97
746
|
async function runHostedSchema(domain, args) {
|
|
98
747
|
const flags = parseFlags(args, new Set(['json']));
|
|
@@ -113,65 +762,6 @@ async function runHostedSchema(domain, args) {
|
|
|
113
762
|
}));
|
|
114
763
|
return 0;
|
|
115
764
|
}
|
|
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
765
|
async function postHostedJson(input) {
|
|
176
766
|
let auth = await resolveFreshRemoteAuth();
|
|
177
767
|
let response = await postJson({
|
|
@@ -193,36 +783,61 @@ async function postHostedJson(input) {
|
|
|
193
783
|
}
|
|
194
784
|
const payload = await readJsonResponse(response);
|
|
195
785
|
if (!response.ok) {
|
|
786
|
+
const productError = readHostedProductError(payload);
|
|
196
787
|
const challenge = readLargeCreditQuoteConfirmationChallenge(payload);
|
|
197
788
|
if (challenge) {
|
|
198
|
-
throw new HostedQuoteConfirmationRequiredError(
|
|
789
|
+
throw new HostedQuoteConfirmationRequiredError(productError.message, challenge);
|
|
199
790
|
}
|
|
200
791
|
const compatibilityError = formatPostPlusCompatibilityError(payload);
|
|
201
792
|
if (compatibilityError) {
|
|
202
793
|
throw new Error(compatibilityError);
|
|
203
794
|
}
|
|
204
|
-
throw new
|
|
795
|
+
throw new HostedProductRequestError(productError);
|
|
205
796
|
}
|
|
206
797
|
return payload;
|
|
207
798
|
}
|
|
208
|
-
|
|
209
|
-
|
|
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
|
+
}
|
|
210
826
|
throw error;
|
|
211
827
|
}
|
|
828
|
+
await writeResult(payload, input.outputPath, input.json);
|
|
829
|
+
return 0;
|
|
830
|
+
}
|
|
831
|
+
async function writeQuoteConfirmationChallenge(error, input) {
|
|
212
832
|
const challengePath = path.resolve(input.outputPath
|
|
213
833
|
? `${input.outputPath}.quote-confirmation.json`
|
|
214
|
-
: `${input.
|
|
834
|
+
: `${input.errorInputLabel}.quote-confirmation.json`);
|
|
215
835
|
await mkdir(path.dirname(challengePath), { recursive: true });
|
|
216
836
|
await writeFile(challengePath, `${JSON.stringify(error.challenge, null, 2)}\n`, {
|
|
217
837
|
encoding: 'utf8',
|
|
218
838
|
mode: 0o600,
|
|
219
839
|
});
|
|
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'));
|
|
840
|
+
return challengePath;
|
|
226
841
|
}
|
|
227
842
|
async function postJson(input) {
|
|
228
843
|
const headers = await buildPostPlusClientCompatibilityHeaders({
|
|
@@ -252,17 +867,31 @@ async function readJsonResponse(response) {
|
|
|
252
867
|
throw new Error('PostPlus Cloud returned invalid JSON.');
|
|
253
868
|
}
|
|
254
869
|
}
|
|
255
|
-
function
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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;
|
|
266
895
|
}
|
|
267
896
|
async function readJsonFile(filePath) {
|
|
268
897
|
try {
|
|
@@ -274,16 +903,6 @@ async function readJsonFile(filePath) {
|
|
|
274
903
|
: `Failed to read JSON file ${filePath}.`);
|
|
275
904
|
}
|
|
276
905
|
}
|
|
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
906
|
async function writeResult(payload, outputPath, forceStdout) {
|
|
288
907
|
const text = `${JSON.stringify(payload, null, 2)}\n`;
|
|
289
908
|
if (!outputPath || forceStdout) {
|
|
@@ -294,9 +913,10 @@ async function writeResult(payload, outputPath, forceStdout) {
|
|
|
294
913
|
await writeFile(outputPath, text);
|
|
295
914
|
}
|
|
296
915
|
}
|
|
297
|
-
function parseFlags(args, booleanFlags) {
|
|
916
|
+
function parseFlags(args, booleanFlags, arrayFlags = new Set()) {
|
|
298
917
|
const values = new Map();
|
|
299
918
|
const booleans = new Set();
|
|
919
|
+
const arrays = new Map();
|
|
300
920
|
for (let index = 0; index < args.length; index += 1) {
|
|
301
921
|
const arg = args[index];
|
|
302
922
|
if (!arg.startsWith('--')) {
|
|
@@ -311,10 +931,17 @@ function parseFlags(args, booleanFlags) {
|
|
|
311
931
|
if (!value || value.startsWith('--')) {
|
|
312
932
|
throw new Error(`Missing value for --${key}.`);
|
|
313
933
|
}
|
|
314
|
-
|
|
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
|
+
}
|
|
315
942
|
index += 1;
|
|
316
943
|
}
|
|
317
|
-
return { booleans, values };
|
|
944
|
+
return { arrays, booleans, values };
|
|
318
945
|
}
|
|
319
946
|
function requireFlag(flags, key) {
|
|
320
947
|
const value = flags.values.get(key);
|
|
@@ -323,28 +950,6 @@ function requireFlag(flags, key) {
|
|
|
323
950
|
}
|
|
324
951
|
return value;
|
|
325
952
|
}
|
|
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
953
|
function normalizeString(value) {
|
|
349
954
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
350
955
|
}
|
|
@@ -356,17 +961,136 @@ function printResearchHelp() {
|
|
|
356
961
|
|
|
357
962
|
Usage:
|
|
358
963
|
postplus research schema [--collection-key <key>] [--json]
|
|
359
|
-
postplus research collect
|
|
964
|
+
postplus research collect <collection-key> --request <input.json> [--skill <skill-id>] [--max-charge-usd <usd>] [--output <result.json>]
|
|
360
965
|
postplus research collect --run-handle <runHandle> [--output <result.json>]
|
|
361
|
-
postplus research
|
|
966
|
+
postplus research scrape <source-key> --request <input-array.json> [--skill <skill-id>] [--max-charge-usd <usd>] [--output <result.json>]
|
|
362
967
|
`);
|
|
363
968
|
}
|
|
364
|
-
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';
|
|
365
975
|
process.stdout.write(`PostPlus CLI - ${domain} commands
|
|
366
976
|
|
|
367
977
|
Usage:
|
|
368
|
-
postplus ${domain} schema${domain === 'media' ? ' [--endpoint <endpoint-key>]' : ''} [--json]
|
|
369
|
-
|
|
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
|
|
370
1094
|
`);
|
|
371
1095
|
}
|
|
372
1096
|
function writeJson(value) {
|