@pdpp/cli 0.1.0-beta.7 → 0.1.0-beta.8
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/README.md +42 -1
- package/package.json +1 -1
- package/src/index.js +51 -0
- package/src/owner-agent/command.js +368 -0
- package/src/owner-agent/control.js +138 -0
- package/src/owner-agent/credential-store.js +126 -0
- package/src/owner-agent/device-flow.js +145 -0
- package/src/owner-agent/discovery.js +233 -0
- package/src/owner-agent/errors.js +13 -0
- package/src/owner-agent/lifecycle.js +126 -0
- package/src/owner-agent/setup.js +378 -0
- package/src/read/commands.js +250 -0
- package/src/ref/auth.js +179 -0
- package/src/ref/commands/call.js +168 -0
- package/src/ref/commands/connectors.js +44 -4
- package/src/ref/commands/event-subscriptions.js +190 -0
- package/src/ref/commands/grant.js +3 -1
- package/src/ref/commands/run.js +3 -1
- package/src/ref/commands/trace.js +3 -1
- package/src/ref/output.js +44 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { ConnectError, normalizeProviderUrl, readStoredCredential } from '../connect/flow.js';
|
|
2
|
+
import { parseArgs, requirePositional } from '../ref/args.js';
|
|
3
|
+
import { PdppHttpError, PdppUsageError } from '../ref/errors.js';
|
|
4
|
+
import { resolveFormat, writeData, writeEnvelopeWarnings } from '../ref/output.js';
|
|
5
|
+
|
|
6
|
+
const COMMANDS = new Set(['schema', 'streams', 'query-records', 'fetch', 'search', 'aggregate']);
|
|
7
|
+
|
|
8
|
+
export function readHelp(binName = 'pdpp') {
|
|
9
|
+
return `Grant-scoped reads (uses pdpp connect/token cache, never owner credentials):
|
|
10
|
+
${binName} read schema <provider-url> [--view compact] [--stream <name>] [--connection-id <cin>] [--cache-root <dir>] [--format json|table]
|
|
11
|
+
${binName} read streams <provider-url> [--connection-id <cin>] [--cache-root <dir>] [--format json|table]
|
|
12
|
+
${binName} read query-records <provider-url> <stream> [--connection-id <cin>] [--limit <n>] [--cursor <cursor>] [--fields a,b] [--sort <spec>] [--count none|estimated|exact] [--filter-json <json>] [--format json|jsonl|table]
|
|
13
|
+
${binName} read fetch <provider-url> <stream> <record-id> [--connection-id <cin>] [--fields a,b] [--format json|table]
|
|
14
|
+
${binName} read search <provider-url> <query> [--connection-id <cin>] [--streams a,b] [--mode lexical|semantic|hybrid] [--limit <n>] [--format json|jsonl|table]
|
|
15
|
+
${binName} read aggregate <provider-url> <stream> --metric <metric> [--field <field>] [--connection-id <cin>] [--group-by <field> | --group-by-time <field> --granularity <unit>] [--limit <n>] [--format json|table]`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function runRead(argv, io = {}, fetchImpl = globalThis.fetch) {
|
|
19
|
+
const out = io.stdout || process.stdout;
|
|
20
|
+
const err = io.stderr || process.stderr;
|
|
21
|
+
const [command, ...rest] = argv;
|
|
22
|
+
|
|
23
|
+
if (!command || command === '--help' || command === '-h' || command === 'help') {
|
|
24
|
+
out.write(`${readHelp()}\n`);
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!COMMANDS.has(command)) {
|
|
29
|
+
throw new PdppUsageError(`Unknown read command: ${command}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { flags, positionals } = parseArgs(rest);
|
|
33
|
+
const providerUrl = requirePositional(positionals, 0, 'provider-url');
|
|
34
|
+
let credential;
|
|
35
|
+
let normalizedProviderUrl;
|
|
36
|
+
try {
|
|
37
|
+
const stored = await readStoredCredential(providerUrl, { cacheRoot: flags['cache-root'] });
|
|
38
|
+
credential = stored.credential;
|
|
39
|
+
normalizedProviderUrl = stored.providerUrl;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error instanceof ConnectError) {
|
|
42
|
+
throw new PdppUsageError(error.message);
|
|
43
|
+
}
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const request = buildReadRequest(command, positionals.slice(1), flags, normalizedProviderUrl);
|
|
48
|
+
const body = await fetchReadJson(request, credential.access_token, fetchImpl);
|
|
49
|
+
writeData(projectOutput(body, flags), resolveFormat(flags, 'json', 'json'), out);
|
|
50
|
+
writeEnvelopeWarnings(body, err);
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildReadRequest(command, positionals, flags, providerUrl) {
|
|
55
|
+
const origin = normalizeProviderUrl(providerUrl);
|
|
56
|
+
if (!origin) throw new PdppUsageError(`Invalid provider URL: ${providerUrl}`);
|
|
57
|
+
|
|
58
|
+
if (command === 'schema') {
|
|
59
|
+
return {
|
|
60
|
+
method: 'GET',
|
|
61
|
+
url: buildUrl(origin, '/v1/schema', pickQuery(flags, ['connector-id', 'connection-id', 'stream', 'view'])),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (command === 'streams') {
|
|
66
|
+
return {
|
|
67
|
+
method: 'GET',
|
|
68
|
+
url: buildUrl(origin, '/v1/streams', pickQuery(flags, ['connection-id', 'connector-instance-id'])),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (command === 'query-records') {
|
|
73
|
+
const stream = requirePositional(positionals, 0, 'stream');
|
|
74
|
+
const query = {
|
|
75
|
+
...pickQuery(flags, [
|
|
76
|
+
'connection-id',
|
|
77
|
+
'connector-instance-id',
|
|
78
|
+
'cursor',
|
|
79
|
+
'limit',
|
|
80
|
+
'order',
|
|
81
|
+
'sort',
|
|
82
|
+
'count',
|
|
83
|
+
'changes-since',
|
|
84
|
+
]),
|
|
85
|
+
...csvQuery(flags, 'fields'),
|
|
86
|
+
...jsonFilterQuery(flags),
|
|
87
|
+
};
|
|
88
|
+
return { method: 'GET', url: buildUrl(origin, `/v1/streams/${encodeURIComponent(stream)}/records`, query) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (command === 'fetch') {
|
|
92
|
+
const stream = requirePositional(positionals, 0, 'stream');
|
|
93
|
+
const recordId = requirePositional(positionals, 1, 'record-id');
|
|
94
|
+
const query = {
|
|
95
|
+
...pickQuery(flags, ['connection-id', 'connector-instance-id']),
|
|
96
|
+
...csvQuery(flags, 'fields'),
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
method: 'GET',
|
|
100
|
+
url: buildUrl(
|
|
101
|
+
origin,
|
|
102
|
+
`/v1/streams/${encodeURIComponent(stream)}/records/${encodeURIComponent(recordId)}`,
|
|
103
|
+
query,
|
|
104
|
+
),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (command === 'search') {
|
|
109
|
+
const queryText = requirePositional(positionals, 0, 'query');
|
|
110
|
+
const mode = flags.mode ? String(flags.mode) : undefined;
|
|
111
|
+
const path = mode === 'semantic' ? '/v1/search/semantic' : mode === 'hybrid' ? '/v1/search/hybrid' : '/v1/search';
|
|
112
|
+
const query = {
|
|
113
|
+
q: queryText,
|
|
114
|
+
...pickQuery(flags, ['connection-id', 'connector-instance-id', 'cursor', 'limit']),
|
|
115
|
+
...csvQuery(flags, 'streams'),
|
|
116
|
+
};
|
|
117
|
+
return { method: 'GET', url: buildUrl(origin, path, query) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (command === 'aggregate') {
|
|
121
|
+
const stream = requirePositional(positionals, 0, 'stream');
|
|
122
|
+
if (!flags.metric) throw new PdppUsageError('Missing required flag: --metric');
|
|
123
|
+
if (flags['group-by'] && flags['group-by-time']) {
|
|
124
|
+
throw new PdppUsageError('Use only one of --group-by or --group-by-time.');
|
|
125
|
+
}
|
|
126
|
+
const query = pickQuery(flags, [
|
|
127
|
+
'connection-id',
|
|
128
|
+
'connector-instance-id',
|
|
129
|
+
'field',
|
|
130
|
+
'granularity',
|
|
131
|
+
'limit',
|
|
132
|
+
'metric',
|
|
133
|
+
'time-zone',
|
|
134
|
+
]);
|
|
135
|
+
if (flags['group-by']) query.group_by = flags['group-by'];
|
|
136
|
+
if (flags['group-by-time']) query.group_by_time = flags['group-by-time'];
|
|
137
|
+
return { method: 'GET', url: buildUrl(origin, `/v1/streams/${encodeURIComponent(stream)}/aggregate`, query) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
throw new PdppUsageError(`Unsupported read command: ${command}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function fetchReadJson(request, token, fetchImpl) {
|
|
144
|
+
let resp;
|
|
145
|
+
try {
|
|
146
|
+
resp = await fetchImpl(request.url, {
|
|
147
|
+
method: request.method,
|
|
148
|
+
headers: {
|
|
149
|
+
Accept: 'application/json',
|
|
150
|
+
Authorization: `Bearer ${token}`,
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
} catch (error) {
|
|
154
|
+
throw new PdppUsageError(`Network request failed: ${error.message}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const text = typeof resp.text === 'function' ? await resp.text() : '';
|
|
158
|
+
let parsed = null;
|
|
159
|
+
if (text) {
|
|
160
|
+
try {
|
|
161
|
+
parsed = JSON.parse(text);
|
|
162
|
+
} catch {
|
|
163
|
+
parsed = text;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (resp.status >= 400) {
|
|
168
|
+
const message =
|
|
169
|
+
parsed?.error_description ||
|
|
170
|
+
parsed?.error?.message ||
|
|
171
|
+
parsed?.message ||
|
|
172
|
+
`HTTP ${resp.status} ${resp.statusText || ''}`.trim();
|
|
173
|
+
throw new PdppHttpError(String(message), resp.status, parsed, {
|
|
174
|
+
request_id: resp.headers?.get?.('x-request-id') ?? null,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return parsed;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function buildUrl(origin, path, query = {}) {
|
|
182
|
+
const url = new URL(path, `${origin}/`);
|
|
183
|
+
for (const [key, value] of Object.entries(query)) {
|
|
184
|
+
appendQuery(url, key, value);
|
|
185
|
+
}
|
|
186
|
+
return url.toString();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function appendQuery(url, key, value) {
|
|
190
|
+
if (value === undefined || value === null || value === '') return;
|
|
191
|
+
if (Array.isArray(value)) {
|
|
192
|
+
for (const entry of value) appendQuery(url, key, entry);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
url.searchParams.append(key, String(value));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function pickQuery(flags, names) {
|
|
199
|
+
const query = {};
|
|
200
|
+
for (const name of names) {
|
|
201
|
+
const value = flags[name];
|
|
202
|
+
if (value === undefined || value === true) continue;
|
|
203
|
+
query[name.replaceAll('-', '_')] = value;
|
|
204
|
+
}
|
|
205
|
+
return query;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function csvQuery(flags, name) {
|
|
209
|
+
const raw = flags[name];
|
|
210
|
+
if (typeof raw !== 'string' || raw.trim() === '') return {};
|
|
211
|
+
return { [name]: raw.split(',').map((entry) => entry.trim()).filter(Boolean) };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function jsonFilterQuery(flags) {
|
|
215
|
+
if (flags.filter !== undefined && flags['filter-json'] !== undefined) {
|
|
216
|
+
throw new PdppUsageError('Use only one of --filter or --filter-json.');
|
|
217
|
+
}
|
|
218
|
+
if (typeof flags.filter === 'string') {
|
|
219
|
+
return { filter: flags.filter };
|
|
220
|
+
}
|
|
221
|
+
if (flags['filter-json'] === undefined) return {};
|
|
222
|
+
|
|
223
|
+
let parsed;
|
|
224
|
+
try {
|
|
225
|
+
parsed = JSON.parse(flags['filter-json']);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
throw new PdppUsageError(`--filter-json must be valid JSON: ${error.message}`);
|
|
228
|
+
}
|
|
229
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
230
|
+
throw new PdppUsageError('--filter-json must be a JSON object.');
|
|
231
|
+
}
|
|
232
|
+
const query = {};
|
|
233
|
+
for (const [field, value] of Object.entries(parsed)) {
|
|
234
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
235
|
+
for (const [op, opValue] of Object.entries(value)) {
|
|
236
|
+
query[`filter[${field}][${op}]`] = opValue;
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
query[`filter[${field}]`] = value;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return query;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function projectOutput(body, flags) {
|
|
246
|
+
if (!flags.data) return body;
|
|
247
|
+
if (body && typeof body === 'object' && Array.isArray(body.data)) return body.data;
|
|
248
|
+
if (body && typeof body === 'object' && Array.isArray(body.records)) return body.records;
|
|
249
|
+
return body;
|
|
250
|
+
}
|
package/src/ref/auth.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Owner-action auth resolution for `pdpp ref call`.
|
|
2
|
+
//
|
|
3
|
+
// The reference server exposes two owner surfaces with two different auth
|
|
4
|
+
// modes — the single fact that owners keep rediscovering:
|
|
5
|
+
//
|
|
6
|
+
// /_ref/* operator/diagnostics control plane → owner SESSION COOKIE
|
|
7
|
+
// /v1/owner/* owner control machine API → owner BEARER token
|
|
8
|
+
//
|
|
9
|
+
// Every mutating /_ref/* route is guarded by `requireOwnerSession` alone; none
|
|
10
|
+
// require a CSRF token, because the server exempts any POST whose Content-Type
|
|
11
|
+
// is exactly `application/json` (a JSON body can't be forged into a cross-origin
|
|
12
|
+
// browser POST without a CORS preflight). So a JSON owner-cookie POST to /_ref/*
|
|
13
|
+
// needs no `_csrf` handling at all. This module encodes that model and refuses
|
|
14
|
+
// the mismatched pairing (cookie→/v1/owner or bearer→/_ref), which would
|
|
15
|
+
// otherwise surface as a confusing 401/404 — the "401 = wrong auth, 404 = wrong
|
|
16
|
+
// path" trap from the live route map.
|
|
17
|
+
|
|
18
|
+
import { PdppUsageError } from './errors.js';
|
|
19
|
+
import { ownerSessionHeaders } from './fetch.js';
|
|
20
|
+
|
|
21
|
+
export const AUTH_COOKIE = 'cookie';
|
|
22
|
+
export const AUTH_BEARER = 'bearer';
|
|
23
|
+
|
|
24
|
+
// Infer the auth mode a path expects from its prefix. Returns 'cookie',
|
|
25
|
+
// 'bearer', or null when the prefix is not an owner surface we recognize.
|
|
26
|
+
export function inferAuthMode(path) {
|
|
27
|
+
const p = normalizePath(path);
|
|
28
|
+
if (p.startsWith('/v1/owner/') || p === '/v1/owner') {
|
|
29
|
+
return AUTH_BEARER;
|
|
30
|
+
}
|
|
31
|
+
if (p.startsWith('/_ref/') || p === '/_ref') {
|
|
32
|
+
return AUTH_COOKIE;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizePath(path) {
|
|
38
|
+
if (typeof path !== 'string' || !path) {
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
// Strip a query/hash and a leading origin if the caller passed a full URL.
|
|
42
|
+
let p = path;
|
|
43
|
+
try {
|
|
44
|
+
if (/^https?:\/\//i.test(p)) {
|
|
45
|
+
p = new URL(p).pathname;
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// fall through; treat as a path
|
|
49
|
+
}
|
|
50
|
+
const q = p.indexOf('?');
|
|
51
|
+
if (q !== -1) p = p.slice(0, q);
|
|
52
|
+
const h = p.indexOf('#');
|
|
53
|
+
if (h !== -1) p = p.slice(0, h);
|
|
54
|
+
if (!p.startsWith('/')) p = `/${p}`;
|
|
55
|
+
return p;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Resolve the effective auth mode for a call: an explicit `--auth` override if
|
|
59
|
+
// present and valid, otherwise the path-inferred mode. Throws a usage error
|
|
60
|
+
// when an override conflicts with the path's surface, or when neither an
|
|
61
|
+
// override nor a recognized prefix is available.
|
|
62
|
+
export function resolveAuthMode(path, override) {
|
|
63
|
+
const inferred = inferAuthMode(path);
|
|
64
|
+
|
|
65
|
+
if (override !== undefined && override !== null && override !== '') {
|
|
66
|
+
const chosen = String(override).toLowerCase();
|
|
67
|
+
if (chosen !== AUTH_COOKIE && chosen !== AUTH_BEARER) {
|
|
68
|
+
throw new PdppUsageError(`Invalid --auth value: ${override}. Use "cookie" or "bearer".`);
|
|
69
|
+
}
|
|
70
|
+
if (inferred && inferred !== chosen) {
|
|
71
|
+
throw new PdppUsageError(mismatchMessage(normalizePath(path), inferred, chosen));
|
|
72
|
+
}
|
|
73
|
+
return chosen;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!inferred) {
|
|
77
|
+
throw new PdppUsageError(
|
|
78
|
+
`Cannot infer owner auth mode for path "${path}". ` +
|
|
79
|
+
'Owner routes are /_ref/* (cookie) or /v1/owner/* (bearer). ' +
|
|
80
|
+
'Pass --auth cookie|bearer to call a non-standard path explicitly.'
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return inferred;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function mismatchMessage(path, inferred, chosen) {
|
|
87
|
+
const surface = inferred === AUTH_COOKIE ? '/_ref/*' : '/v1/owner/*';
|
|
88
|
+
const correct = inferred === AUTH_COOKIE ? 'cookie' : 'bearer';
|
|
89
|
+
return (
|
|
90
|
+
`Auth mismatch: ${path} is a ${surface} route and uses ${correct} auth, ` +
|
|
91
|
+
`but --auth ${chosen} was given. ` +
|
|
92
|
+
'/_ref/* uses the owner session cookie; /v1/owner/* uses the owner bearer. ' +
|
|
93
|
+
'Pointing the wrong auth at a route returns a confusing 401/404. ' +
|
|
94
|
+
`Drop --auth (it is inferred) or use --auth ${correct}.`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Build the request headers for the chosen auth mode. For cookie auth, resolves
|
|
99
|
+
// the owner session (flag > env > 0600 cache) and never echoes it. For bearer
|
|
100
|
+
// auth, reads the owner token from --owner-token-stdin or PDPP_OWNER_TOKEN and
|
|
101
|
+
// never accepts it on argv. Throws a usage error when the required secret is
|
|
102
|
+
// absent. The returned object includes only the auth header; content-type is
|
|
103
|
+
// added by the caller for bodies.
|
|
104
|
+
export async function buildAuthHeaders({ mode, referenceUrl, flags, io, env = process.env }) {
|
|
105
|
+
if (mode === AUTH_COOKIE) {
|
|
106
|
+
const headers = ownerSessionHeaders({
|
|
107
|
+
ownerSession: flags['owner-session'] || '',
|
|
108
|
+
referenceUrl,
|
|
109
|
+
cacheRoot: flags['cache-root'],
|
|
110
|
+
});
|
|
111
|
+
if (!headers.Cookie) {
|
|
112
|
+
throw new PdppUsageError(
|
|
113
|
+
'No owner session available. Run `pdpp ref login <reference-url>` first, ' +
|
|
114
|
+
'pass --owner-session <cookie>, or set PDPP_OWNER_SESSION_COOKIE.'
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return headers;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (mode === AUTH_BEARER) {
|
|
121
|
+
const token = await resolveOwnerToken(flags, io, env);
|
|
122
|
+
if (!token) {
|
|
123
|
+
throw new PdppUsageError(
|
|
124
|
+
'No owner bearer available for a /v1/owner/* call. ' +
|
|
125
|
+
'Pipe it via `--owner-token-stdin` or set PDPP_OWNER_TOKEN. ' +
|
|
126
|
+
'The token is never accepted on the command line.'
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return { Authorization: `Bearer ${token}` };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
throw new PdppUsageError(`Unknown auth mode: ${mode}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function resolveOwnerToken(flags, io, env) {
|
|
136
|
+
if (flags['owner-token-stdin']) {
|
|
137
|
+
return readFirstLine((io && io.stdin) || process.stdin);
|
|
138
|
+
}
|
|
139
|
+
const fromEnv = env.PDPP_OWNER_TOKEN;
|
|
140
|
+
if (typeof fromEnv === 'string' && fromEnv.length > 0) {
|
|
141
|
+
return fromEnv.trim();
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readFirstLine(stream) {
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
if (!stream || typeof stream.on !== 'function') {
|
|
149
|
+
resolve('');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
let buf = '';
|
|
153
|
+
stream.setEncoding?.('utf8');
|
|
154
|
+
const onData = (chunk) => {
|
|
155
|
+
buf += chunk;
|
|
156
|
+
const nl = buf.indexOf('\n');
|
|
157
|
+
if (nl !== -1) {
|
|
158
|
+
cleanup();
|
|
159
|
+
resolve(buf.slice(0, nl).replace(/\r$/, ''));
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const onEnd = () => {
|
|
163
|
+
cleanup();
|
|
164
|
+
resolve(buf.replace(/\r?\n$/, ''));
|
|
165
|
+
};
|
|
166
|
+
const onError = (e) => {
|
|
167
|
+
cleanup();
|
|
168
|
+
reject(e);
|
|
169
|
+
};
|
|
170
|
+
function cleanup() {
|
|
171
|
+
stream.off?.('data', onData);
|
|
172
|
+
stream.off?.('end', onEnd);
|
|
173
|
+
stream.off?.('error', onError);
|
|
174
|
+
}
|
|
175
|
+
stream.on('data', onData);
|
|
176
|
+
stream.on('end', onEnd);
|
|
177
|
+
stream.on('error', onError);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// `pdpp ref call` — a generic owner-authenticated HTTP caller for the long tail
|
|
2
|
+
// of owner-only reference routes (dataset reconcile, run cancel, schedule
|
|
3
|
+
// pause/resume, and any other /_ref/* or /v1/owner/* route).
|
|
4
|
+
//
|
|
5
|
+
// It exists so owner lanes stop rediscovering the auth model on every new
|
|
6
|
+
// action. The auth mode is inferred from the path prefix and can be overridden:
|
|
7
|
+
//
|
|
8
|
+
// /_ref/* → owner session cookie (cached, --owner-session, or env)
|
|
9
|
+
// /v1/owner/* → owner bearer (--owner-token-stdin or PDPP_OWNER_TOKEN)
|
|
10
|
+
//
|
|
11
|
+
// Bodies are always sent as application/json, which the reference server treats
|
|
12
|
+
// as CSRF-exempt — so there is no `_csrf` handling anywhere. Secrets are never
|
|
13
|
+
// printed: only the response body (stdout) and a `METHOD path → status` line
|
|
14
|
+
// (stderr) are emitted.
|
|
15
|
+
//
|
|
16
|
+
// Usage:
|
|
17
|
+
// pdpp ref call <method> <path> [--as-url <url>]
|
|
18
|
+
// [--data <json> | --data-stdin]
|
|
19
|
+
// [--auth cookie|bearer]
|
|
20
|
+
// [--owner-session <cookie>] [--owner-token-stdin]
|
|
21
|
+
// [--cache-root <dir>] [--format json|table]
|
|
22
|
+
// [--status-only]
|
|
23
|
+
|
|
24
|
+
import { parseArgs, requirePositional } from '../args.js';
|
|
25
|
+
import { resolveAuthMode, buildAuthHeaders } from '../auth.js';
|
|
26
|
+
import { PdppUsageError, PdppHttpError } from '../errors.js';
|
|
27
|
+
import { resolveReferenceUrl } from '../fetch.js';
|
|
28
|
+
import { resolveFormat, writeData, writeEnvelopeWarnings } from '../output.js';
|
|
29
|
+
|
|
30
|
+
const METHODS_WITH_BODY = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
31
|
+
const KNOWN_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']);
|
|
32
|
+
|
|
33
|
+
export async function runRefCall(argv, io = {}, fetchImpl = globalThis.fetch) {
|
|
34
|
+
const out = io.stdout || process.stdout;
|
|
35
|
+
const err = io.stderr || process.stderr;
|
|
36
|
+
|
|
37
|
+
const { flags, positionals } = parseArgs(argv);
|
|
38
|
+
const method = requirePositional(positionals, 0, 'method').toUpperCase();
|
|
39
|
+
if (!KNOWN_METHODS.has(method)) {
|
|
40
|
+
throw new PdppUsageError(
|
|
41
|
+
`Unsupported method: ${method}. Use one of ${[...KNOWN_METHODS].join(', ')}.`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const path = requirePositional(positionals, 1, 'path');
|
|
45
|
+
|
|
46
|
+
const referenceUrl = resolveReferenceUrl(flags);
|
|
47
|
+
const mode = resolveAuthMode(path, flags.auth);
|
|
48
|
+
const authHeaders = await buildAuthHeaders({ mode, referenceUrl, flags, io });
|
|
49
|
+
|
|
50
|
+
const body = await resolveBody(flags, io, method);
|
|
51
|
+
|
|
52
|
+
const headers = { Accept: 'application/json', ...authHeaders };
|
|
53
|
+
const init = { method, headers, redirect: 'manual' };
|
|
54
|
+
if (body !== undefined) {
|
|
55
|
+
headers['Content-Type'] = 'application/json';
|
|
56
|
+
init.body = body;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const url = `${referenceUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
|
60
|
+
|
|
61
|
+
let resp;
|
|
62
|
+
try {
|
|
63
|
+
resp = await fetchImpl(url, init);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
throw new PdppUsageError(`Network request to ${path} failed: ${e.message}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Always surface the status line on stderr so machine-readable stdout stays
|
|
69
|
+
// clean. The URL is intentionally the path only — never the cookie/bearer.
|
|
70
|
+
err.write(`${method} ${path} → ${resp.status} ${resp.statusText || ''}`.trimEnd() + '\n');
|
|
71
|
+
|
|
72
|
+
if (flags['status-only']) {
|
|
73
|
+
return statusExitCode(resp.status);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const text = await readBody(resp);
|
|
77
|
+
let parsed = null;
|
|
78
|
+
if (text) {
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(text);
|
|
81
|
+
} catch {
|
|
82
|
+
parsed = text;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (resp.status >= 400) {
|
|
87
|
+
const message =
|
|
88
|
+
(parsed && typeof parsed === 'object' && (parsed.error_description || parsed.error?.message || parsed.message)) ||
|
|
89
|
+
`HTTP ${resp.status} ${resp.statusText || ''}`.trim();
|
|
90
|
+
throw new PdppHttpError(String(message), resp.status, parsed);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (parsed !== null && parsed !== '') {
|
|
94
|
+
const format = resolveFormat(flags, 'json', 'json');
|
|
95
|
+
writeData(parsed, format, out);
|
|
96
|
+
if (parsed && typeof parsed === 'object') {
|
|
97
|
+
writeEnvelopeWarnings(parsed, err);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function resolveBody(flags, io, method) {
|
|
104
|
+
const hasInline = typeof flags.data === 'string';
|
|
105
|
+
const hasStdin = Boolean(flags['data-stdin']);
|
|
106
|
+
if (hasInline && hasStdin) {
|
|
107
|
+
throw new PdppUsageError('Use only one of --data or --data-stdin, not both.');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let raw;
|
|
111
|
+
if (hasInline) {
|
|
112
|
+
raw = flags.data;
|
|
113
|
+
} else if (hasStdin) {
|
|
114
|
+
raw = await readAll((io && io.stdin) || process.stdin);
|
|
115
|
+
} else {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const trimmed = String(raw).trim();
|
|
120
|
+
if (!trimmed) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Validate that the body is JSON before sending; the server only parses
|
|
125
|
+
// application/json, and sending malformed JSON would surface as an opaque
|
|
126
|
+
// 400. Re-serialize so we send canonical JSON with the right content-type.
|
|
127
|
+
let value;
|
|
128
|
+
try {
|
|
129
|
+
value = JSON.parse(trimmed);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
throw new PdppUsageError(`--data must be valid JSON: ${e.message}`);
|
|
132
|
+
}
|
|
133
|
+
if (!METHODS_WITH_BODY.has(method)) {
|
|
134
|
+
throw new PdppUsageError(`A request body is not valid for ${method}.`);
|
|
135
|
+
}
|
|
136
|
+
return JSON.stringify(value);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function statusExitCode(status) {
|
|
140
|
+
if (status >= 200 && status < 400) return 0;
|
|
141
|
+
if (status === 401) return 3;
|
|
142
|
+
if (status === 403) return 4;
|
|
143
|
+
if (status === 404) return 5;
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function readBody(resp) {
|
|
148
|
+
if (typeof resp.text === 'function') {
|
|
149
|
+
return await resp.text();
|
|
150
|
+
}
|
|
151
|
+
return '';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readAll(stream) {
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
if (!stream || typeof stream.on !== 'function') {
|
|
157
|
+
resolve('');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
let buf = '';
|
|
161
|
+
stream.setEncoding?.('utf8');
|
|
162
|
+
stream.on('data', (chunk) => {
|
|
163
|
+
buf += chunk;
|
|
164
|
+
});
|
|
165
|
+
stream.on('end', () => resolve(buf));
|
|
166
|
+
stream.on('error', reject);
|
|
167
|
+
});
|
|
168
|
+
}
|