@labelbox/horizon-cli 0.0.0-stage → 0.0.1
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 +133 -2
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +39 -0
- package/dist/compute-session.d.ts +135 -0
- package/dist/compute-session.js +373 -0
- package/dist/default-base-url.generated.d.ts +5 -0
- package/dist/default-base-url.generated.js +5 -0
- package/dist/dispatch.d.ts +40 -0
- package/dist/dispatch.js +265 -0
- package/dist/embed.d.ts +39 -0
- package/dist/embed.js +51 -0
- package/dist/git-host.d.ts +16 -0
- package/dist/git-host.js +184 -0
- package/dist/json-operation-callability.d.ts +31 -0
- package/dist/json-operation-callability.js +57 -0
- package/dist/manifest.d.ts +531 -0
- package/dist/manifest.js +558 -0
- package/dist/permissions.d.ts +46 -0
- package/dist/permissions.js +106 -0
- package/dist/program.d.ts +129 -0
- package/dist/program.js +985 -0
- package/dist/request-timeout.d.ts +8 -0
- package/dist/request-timeout.js +33 -0
- package/dist/resolve.d.ts +53 -0
- package/dist/resolve.js +111 -0
- package/dist/run.d.ts +44 -0
- package/dist/run.js +81 -0
- package/dist/skills.d.ts +73 -0
- package/dist/skills.js +235 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +22 -0
- package/package.json +61 -4
package/dist/dispatch.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { openAsBlob } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { baseMediaType, responseBodyKind } from './json-operation-callability.js';
|
|
4
|
+
import { isMultipartRequestOperation, } from './manifest.js';
|
|
5
|
+
import { requestBudget } from './request-timeout.js';
|
|
6
|
+
// Every header the API trusts as caller identity or scope belongs here, not
|
|
7
|
+
// just the ones a manifest happens to expose today: the denylist exists so a
|
|
8
|
+
// future header parameter cannot become a flag that a caller pointed at a
|
|
9
|
+
// standalone stack could set to impersonate or self-escalate.
|
|
10
|
+
const RESERVED_OPERATION_HEADERS = new Set([
|
|
11
|
+
'accept',
|
|
12
|
+
'authorization',
|
|
13
|
+
'content-length',
|
|
14
|
+
'content-type',
|
|
15
|
+
'cookie',
|
|
16
|
+
'host',
|
|
17
|
+
'x-api-key',
|
|
18
|
+
'x-environment-external-id',
|
|
19
|
+
'x-labelbox-rl-data-principal-id',
|
|
20
|
+
'x-lb-auth-token',
|
|
21
|
+
'x-organization-id',
|
|
22
|
+
'x-organization-external-id',
|
|
23
|
+
'x-permissions',
|
|
24
|
+
'x-problem-external-id',
|
|
25
|
+
'x-user-external-id',
|
|
26
|
+
'x-user-id',
|
|
27
|
+
]);
|
|
28
|
+
// Bound every request through response headers, and through the complete body for
|
|
29
|
+
// buffered JSON/text responses. Binary bodies stream after that handoff, so a
|
|
30
|
+
// valid large/slow download is not killed by an unrelated request-start budget.
|
|
31
|
+
const REQUEST_TIMEOUT_MS = 120_000;
|
|
32
|
+
const MULTIPART_SCALAR_TYPES = new Set(['string', 'number', 'integer', 'boolean']);
|
|
33
|
+
async function buildMultipartBody(op, params, allowFileInputs) {
|
|
34
|
+
if (!allowFileInputs) {
|
|
35
|
+
throw new Error('multipart file inputs are unavailable in an embedded CLI');
|
|
36
|
+
}
|
|
37
|
+
const body = new FormData();
|
|
38
|
+
for (const param of op.params) {
|
|
39
|
+
if (param.in !== 'body')
|
|
40
|
+
continue;
|
|
41
|
+
const value = params[param.name];
|
|
42
|
+
if (value === undefined || value === null) {
|
|
43
|
+
if (param.required)
|
|
44
|
+
throw new Error(`missing required multipart field "${param.name}"`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (param.format === 'binary') {
|
|
48
|
+
if (typeof value !== 'string') {
|
|
49
|
+
throw new Error(`multipart file field "${param.name}" must be a local path`);
|
|
50
|
+
}
|
|
51
|
+
let blob;
|
|
52
|
+
try {
|
|
53
|
+
blob = await openAsBlob(value);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new Error(`could not read multipart file for --${param.name.replace(/([a-z0-9])([A-Z])/gu, '$1-$2').toLowerCase()}: ${JSON.stringify(value)}`);
|
|
57
|
+
}
|
|
58
|
+
body.append(param.name, blob, basename(value));
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!MULTIPART_SCALAR_TYPES.has(param.type) || typeof value === 'object') {
|
|
62
|
+
throw new Error(`multipart field "${param.name}" has unsupported type "${param.type}"`);
|
|
63
|
+
}
|
|
64
|
+
body.append(param.name, String(value));
|
|
65
|
+
}
|
|
66
|
+
return body;
|
|
67
|
+
}
|
|
68
|
+
/** Serialize one query param value the way the generated client did. */
|
|
69
|
+
function serializeQueryParam(name, value) {
|
|
70
|
+
if (value === undefined || value === null)
|
|
71
|
+
return [];
|
|
72
|
+
if (Array.isArray(value)) {
|
|
73
|
+
// form + explode: repeat the key per element. A non-scalar element (object/array)
|
|
74
|
+
// is JSON-encoded so it survives rather than becoming `[object Object]` —
|
|
75
|
+
// consistent with the object branch below. No shipped query param is `object[]`
|
|
76
|
+
// today; this keeps the two branches from diverging.
|
|
77
|
+
return value
|
|
78
|
+
.filter((v) => v !== undefined && v !== null)
|
|
79
|
+
.map((v) => {
|
|
80
|
+
const encoded = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
81
|
+
return `${name}=${encodeURIComponent(encoded)}`;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if (typeof value === 'object') {
|
|
85
|
+
// Object-valued query params (the `deepObject` `enrichmentFilters`, a
|
|
86
|
+
// record(string, string[])) go on the wire as a single JSON-encoded string. The
|
|
87
|
+
// backend does NOT bracket-parse (`name[key]=v` lands as a stray key and the
|
|
88
|
+
// param reads as absent — verified against the live API); its schema preprocess
|
|
89
|
+
// JSON.parses the value, and the frontend serializes it identically
|
|
90
|
+
// (apps/horizon/web/src/api/problems.ts). JSON also preserves the nested arrays a
|
|
91
|
+
// bracket + `String()` encoding silently corrupted.
|
|
92
|
+
return [`${name}=${encodeURIComponent(JSON.stringify(value))}`];
|
|
93
|
+
}
|
|
94
|
+
return [`${name}=${encodeURIComponent(String(value))}`];
|
|
95
|
+
}
|
|
96
|
+
/** Build the full request URL: base + path (params substituted) + query string. */
|
|
97
|
+
export function buildUrl(op, params, baseUrl) {
|
|
98
|
+
const path = op.path.replace(/\{([^}]+)\}/gu, (_match, name) => {
|
|
99
|
+
const value = params[name];
|
|
100
|
+
if (value === undefined || value === null) {
|
|
101
|
+
throw new Error(`missing required path parameter "${name}"`);
|
|
102
|
+
}
|
|
103
|
+
return encodeURIComponent(String(value));
|
|
104
|
+
});
|
|
105
|
+
const search = [];
|
|
106
|
+
for (const param of op.params) {
|
|
107
|
+
if (param.in === 'query')
|
|
108
|
+
search.push(...serializeQueryParam(param.name, params[param.name]));
|
|
109
|
+
}
|
|
110
|
+
const base = baseUrl.replace(/\/$/u, '');
|
|
111
|
+
const withPath = `${base}${path.startsWith('/') ? path : `/${path}`}`;
|
|
112
|
+
return search.length > 0 ? `${withPath}?${search.join('&')}` : withPath;
|
|
113
|
+
}
|
|
114
|
+
/** Parse a response without coercing binary bytes through UTF-8 text. */
|
|
115
|
+
async function parseBody(res, declaredMediaTypes) {
|
|
116
|
+
if (res.status === 204 || res.status === 304) {
|
|
117
|
+
await res.body?.cancel().catch(() => undefined);
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
const actualMediaType = res.headers.get('content-type');
|
|
121
|
+
if (actualMediaType !== null &&
|
|
122
|
+
declaredMediaTypes !== undefined &&
|
|
123
|
+
!declaredMediaTypes.some((declaredMediaType) => baseMediaType(declaredMediaType) === baseMediaType(actualMediaType))) {
|
|
124
|
+
throw new Error(`response for HTTP ${res.status} used undeclared Content-Type ${JSON.stringify(actualMediaType)}`);
|
|
125
|
+
}
|
|
126
|
+
if (declaredMediaTypes?.length === 0) {
|
|
127
|
+
await res.body?.cancel().catch(() => undefined);
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
if (actualMediaType === null &&
|
|
131
|
+
declaredMediaTypes !== undefined &&
|
|
132
|
+
declaredMediaTypes.length > 1) {
|
|
133
|
+
throw new Error(`response for HTTP ${res.status} omitted Content-Type; expected one of ${declaredMediaTypes.join(', ')}`);
|
|
134
|
+
}
|
|
135
|
+
const kind = responseBodyKind(actualMediaType ?? declaredMediaTypes?.[0] ?? '');
|
|
136
|
+
if (kind === 'binary') {
|
|
137
|
+
if (res.body === null)
|
|
138
|
+
throw new Error(`response for HTTP ${res.status} omitted its binary body`);
|
|
139
|
+
return res.body;
|
|
140
|
+
}
|
|
141
|
+
const text = await res.text();
|
|
142
|
+
if (text === '')
|
|
143
|
+
return kind === 'json' ? undefined : text;
|
|
144
|
+
if (kind === 'json') {
|
|
145
|
+
try {
|
|
146
|
+
return JSON.parse(text);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return text;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return text;
|
|
153
|
+
}
|
|
154
|
+
function declaredResponseHeaders(response, headers) {
|
|
155
|
+
return Object.fromEntries(response.headers.flatMap((name) => {
|
|
156
|
+
const value = headers.get(name);
|
|
157
|
+
return value === null ? [] : [[name, value]];
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Build and send the request for one operation, returning its status, declared
|
|
162
|
+
* headers, and parsed data without collapsing HTTP semantics. Conditional 304 is
|
|
163
|
+
* successful only when the generated contract declares it. Binary bodies remain
|
|
164
|
+
* bytes; empty non-JSON text remains an empty string. On an undeclared response the
|
|
165
|
+
* parsed error body is thrown (so `formatError` prints the server's JSON detail);
|
|
166
|
+
* `fetch` rejecting for an unreachable server propagates and is mapped to the
|
|
167
|
+
* standard "could not be reached" message by `formatError`.
|
|
168
|
+
*/
|
|
169
|
+
export async function dispatchOperation(op, params, ctx) {
|
|
170
|
+
const url = buildUrl(op, params, ctx.baseUrl);
|
|
171
|
+
const headers = {
|
|
172
|
+
// biome-ignore lint/style/useNamingConvention: HTTP header names are not camelCase.
|
|
173
|
+
Authorization: `Bearer ${ctx.apiKey}`,
|
|
174
|
+
};
|
|
175
|
+
const acceptedMediaTypes = [
|
|
176
|
+
...new Set(op.successResponses.flatMap(({ mediaTypes }) => mediaTypes)),
|
|
177
|
+
];
|
|
178
|
+
if (acceptedMediaTypes.length > 0) {
|
|
179
|
+
headers['Accept'] = acceptedMediaTypes.join(', ');
|
|
180
|
+
}
|
|
181
|
+
// The guard requires org whenever env is present (env-without-org is a 403 with
|
|
182
|
+
// no body), so passing only `--scope-environment-external-id` fails upstream of the
|
|
183
|
+
// operation. Both are forwarded verbatim; the server resolves and authorizes them.
|
|
184
|
+
if (ctx.organizationExternalId !== undefined) {
|
|
185
|
+
headers['X-Organization-External-Id'] = ctx.organizationExternalId;
|
|
186
|
+
}
|
|
187
|
+
if (ctx.environmentExternalId !== undefined) {
|
|
188
|
+
headers['X-Environment-External-Id'] = ctx.environmentExternalId;
|
|
189
|
+
}
|
|
190
|
+
for (const param of op.params) {
|
|
191
|
+
if (param.in !== 'header')
|
|
192
|
+
continue;
|
|
193
|
+
if (RESERVED_OPERATION_HEADERS.has(param.name.toLowerCase())) {
|
|
194
|
+
throw new Error(`operation declares reserved request header "${param.name}"`);
|
|
195
|
+
}
|
|
196
|
+
const value = params[param.name];
|
|
197
|
+
if (value !== undefined && value !== null) {
|
|
198
|
+
// Mirrors the query path: a non-scalar survives as JSON rather than
|
|
199
|
+
// collapsing to "[object Object]".
|
|
200
|
+
headers[param.name] = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
let body;
|
|
204
|
+
if (isMultipartRequestOperation(op)) {
|
|
205
|
+
body = await buildMultipartBody(op, params, ctx.allowFileInputs === true);
|
|
206
|
+
}
|
|
207
|
+
else if (op.bodyKey && params[op.bodyKey] !== undefined) {
|
|
208
|
+
body = JSON.stringify(params[op.bodyKey]);
|
|
209
|
+
headers['Content-Type'] = 'application/json';
|
|
210
|
+
}
|
|
211
|
+
const { requestTimeoutMs: timeoutMs, invocationTimeoutMs } = requestBudget(REQUEST_TIMEOUT_MS);
|
|
212
|
+
const requestController = new AbortController();
|
|
213
|
+
const requestTimeout = setTimeout(() => requestController.abort(), timeoutMs);
|
|
214
|
+
const requestSignal = invocationTimeoutMs === undefined
|
|
215
|
+
? requestController.signal
|
|
216
|
+
: AbortSignal.any([requestController.signal, AbortSignal.timeout(invocationTimeoutMs)]);
|
|
217
|
+
let res;
|
|
218
|
+
try {
|
|
219
|
+
const response = await fetch(url, {
|
|
220
|
+
method: op.httpMethod.toUpperCase(),
|
|
221
|
+
headers,
|
|
222
|
+
// Spread rather than `body` — `RequestInit.body` is not optional-undefined,
|
|
223
|
+
// so passing `undefined` explicitly is a type error under
|
|
224
|
+
// exactOptionalPropertyTypes (and a bodyless GET is the common case).
|
|
225
|
+
...(body === undefined ? {} : { body }),
|
|
226
|
+
signal: requestSignal,
|
|
227
|
+
});
|
|
228
|
+
res = response;
|
|
229
|
+
const declaredResponse = op.successResponses.find(({ status }) => status === response.status);
|
|
230
|
+
if (declaredResponse === undefined) {
|
|
231
|
+
// Horizon errors are one JSON envelope. Reject any other media type before
|
|
232
|
+
// reading it so an upstream binary body cannot be buffered or mistaken for
|
|
233
|
+
// a network failure.
|
|
234
|
+
const parsed = await parseBody(response, ['application/json']);
|
|
235
|
+
// Prefer the server's structured error body (formatError renders it as JSON);
|
|
236
|
+
// fall back to a status-coded Error when there's no usable body. `!== null` (not
|
|
237
|
+
// `!== undefined`) because `typeof null === 'object'` — a literal `null` body
|
|
238
|
+
// would otherwise be thrown and surface as an unactionable `error: null`.
|
|
239
|
+
if (parsed !== null && typeof parsed === 'object')
|
|
240
|
+
throw parsed;
|
|
241
|
+
throw new Error(typeof parsed === 'string' && parsed !== ''
|
|
242
|
+
? `request failed (HTTP ${response.status}): ${parsed}`
|
|
243
|
+
: `request failed — HTTP ${response.status}`);
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
data: await parseBody(response, declaredResponse.mediaTypes),
|
|
247
|
+
status: response.status,
|
|
248
|
+
headers: declaredResponseHeaders(declaredResponse, response.headers),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
// Only this deadline can abort the private controller. Turn that abort into a
|
|
253
|
+
// clear error; other fetch failures (refused/reset) propagate unchanged.
|
|
254
|
+
if (requestSignal.aborted) {
|
|
255
|
+
throw new Error(`request to ${url} timed out after ${timeoutMs / 1000}s`);
|
|
256
|
+
}
|
|
257
|
+
await res?.body?.cancel().catch(() => undefined);
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
// `parseBody` returns a binary body's native stream without consuming it.
|
|
262
|
+
// Detach that stream from the request-start timeout before handing it to stdout.
|
|
263
|
+
clearTimeout(requestTimeout);
|
|
264
|
+
}
|
|
265
|
+
}
|
package/dist/embed.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type DispatchResponse } from './dispatch.js';
|
|
2
|
+
import { type Manifest, type ManifestOperation, parseManifest } from './manifest.js';
|
|
3
|
+
export { isJsonOperationCallable, JSON_REQUEST_MEDIA_TYPE } from './manifest.js';
|
|
4
|
+
import { type GrantedPermissions } from './permissions.js';
|
|
5
|
+
export { parseManifest };
|
|
6
|
+
export type { Manifest, ManifestOperation, GrantedPermissions };
|
|
7
|
+
export type ManifestOperationResult = {
|
|
8
|
+
readonly failed: false;
|
|
9
|
+
readonly value: DispatchResponse;
|
|
10
|
+
} | {
|
|
11
|
+
readonly failed: true;
|
|
12
|
+
readonly error: string;
|
|
13
|
+
};
|
|
14
|
+
export interface ExecuteManifestOperationOptions {
|
|
15
|
+
/** The generated manifest operation to dispatch. */
|
|
16
|
+
operation: ManifestOperation;
|
|
17
|
+
/** SDK-shaped input: path/query fields at top level and JSON under `bodyKey`. */
|
|
18
|
+
arguments: Record<string, unknown>;
|
|
19
|
+
/** The caller's own API key, from the request. Never the server's environment. */
|
|
20
|
+
apiKey: string;
|
|
21
|
+
/** The API to dispatch against. Server configuration, not tool input. */
|
|
22
|
+
baseUrl: string;
|
|
23
|
+
/** The caller's granted permissions, resolved by the embedding server. */
|
|
24
|
+
granted: GrantedPermissions;
|
|
25
|
+
/** Trusted organization scope resolved by the embedding server. */
|
|
26
|
+
organizationExternalId?: string;
|
|
27
|
+
/** Trusted environment scope resolved by the embedding server. */
|
|
28
|
+
environmentExternalId?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Dispatch one generated manifest operation without routing through commander.
|
|
32
|
+
*
|
|
33
|
+
* The operation, API target, credentials, and permissions are separate from the
|
|
34
|
+
* tool arguments so caller input can only populate the operation's path, query,
|
|
35
|
+
* and JSON body. This path has no argv, shell, child-process, or filesystem
|
|
36
|
+
* behavior. Expected invocation failures resolve as a discriminated result; a
|
|
37
|
+
* missing caller API key is an embedding invariant and rejects.
|
|
38
|
+
*/
|
|
39
|
+
export declare function executeManifestOperation(options: ExecuteManifestOperationOptions): Promise<ManifestOperationResult>;
|
package/dist/embed.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { dispatchOperation } from './dispatch.js';
|
|
2
|
+
import { isJsonOperationCallable, parseManifest, } from './manifest.js';
|
|
3
|
+
export { isJsonOperationCallable, JSON_REQUEST_MEDIA_TYPE } from './manifest.js';
|
|
4
|
+
import { missingPermission } from './permissions.js';
|
|
5
|
+
import { formatError } from './program.js';
|
|
6
|
+
// Re-exported so an embedding server can validate and type the manifest it hands
|
|
7
|
+
// in without reaching past this module's entrypoint.
|
|
8
|
+
export { parseManifest };
|
|
9
|
+
/**
|
|
10
|
+
* Dispatch one generated manifest operation without routing through commander.
|
|
11
|
+
*
|
|
12
|
+
* The operation, API target, credentials, and permissions are separate from the
|
|
13
|
+
* tool arguments so caller input can only populate the operation's path, query,
|
|
14
|
+
* and JSON body. This path has no argv, shell, child-process, or filesystem
|
|
15
|
+
* behavior. Expected invocation failures resolve as a discriminated result; a
|
|
16
|
+
* missing caller API key is an embedding invariant and rejects.
|
|
17
|
+
*/
|
|
18
|
+
export async function executeManifestOperation(options) {
|
|
19
|
+
if (options.apiKey === '') {
|
|
20
|
+
throw new Error('cannot run a manifest operation without an API key for the calling user');
|
|
21
|
+
}
|
|
22
|
+
if (!isJsonOperationCallable(options.operation)) {
|
|
23
|
+
return {
|
|
24
|
+
failed: true,
|
|
25
|
+
error: `${options.operation.operationId} has no JSON-compatible request and response representation`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const missing = missingPermission(options.operation, options.granted);
|
|
29
|
+
if (missing !== undefined) {
|
|
30
|
+
return {
|
|
31
|
+
failed: true,
|
|
32
|
+
error: formatError(new Error(`you don't have permission to run this operation (requires \`${missing}\`)`)),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const response = await dispatchOperation(options.operation, options.arguments, {
|
|
37
|
+
apiKey: options.apiKey,
|
|
38
|
+
baseUrl: options.baseUrl,
|
|
39
|
+
...(options.organizationExternalId === undefined
|
|
40
|
+
? {}
|
|
41
|
+
: { organizationExternalId: options.organizationExternalId }),
|
|
42
|
+
...(options.environmentExternalId === undefined
|
|
43
|
+
? {}
|
|
44
|
+
: { environmentExternalId: options.environmentExternalId }),
|
|
45
|
+
});
|
|
46
|
+
return { failed: false, value: response };
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
return { failed: true, error: formatError(err) };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
/** `POST /v1/organizations/:organizationId/git-repo-claims` — mints a per-Aligner
|
|
3
|
+
* repo + push token for `problemId`. The claiming Aligner is the
|
|
4
|
+
* authenticated caller (the API key), never a client-supplied field. */
|
|
5
|
+
export declare function claimGitRepo(args: {
|
|
6
|
+
apiKey: string;
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
organizationId: string;
|
|
9
|
+
problemId: string;
|
|
10
|
+
}): Promise<{
|
|
11
|
+
cloneUrl: string;
|
|
12
|
+
defaultBranch: string;
|
|
13
|
+
pushToken: string;
|
|
14
|
+
}>;
|
|
15
|
+
/** Register the `scaffold` / `submit` commands on `program`. */
|
|
16
|
+
export declare function addGitHostCommands(program: Command, helpGroup: string): void;
|
package/dist/git-host.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { supportUrl } from './manifest.js';
|
|
8
|
+
import { resolveCommandAuth } from './resolve.js';
|
|
9
|
+
// ── bespoke `horizon scaffold` / `horizon submit` group (hand-written, not spec-derived) ──
|
|
10
|
+
//
|
|
11
|
+
// Claims a per-Aligner Forgejo repo for a coding-task problem, clones it locally
|
|
12
|
+
// (`scaffold`), and pushes local changes back to it (`submit`). Deliberately
|
|
13
|
+
// NOT `@SdkRoute`-derived commands: local `git` subprocess invocation and
|
|
14
|
+
// credential injection have no place in the generic manifest-driven dispatch
|
|
15
|
+
// loop, so this group is registered directly on `program`, same as `skills`.
|
|
16
|
+
const GitRepoClaimSchema = z.object({
|
|
17
|
+
cloneUrl: z.string(),
|
|
18
|
+
defaultBranch: z.string(),
|
|
19
|
+
pushToken: z.string(),
|
|
20
|
+
});
|
|
21
|
+
/** Filename (under `.git/`) the claim's identity is stashed in after
|
|
22
|
+
* `scaffold`, so `submit` can re-claim before every push. `submit` MUST
|
|
23
|
+
* re-claim rather than reuse a token stashed at scaffold time: the backend
|
|
24
|
+
* mints one push token per Aligner under a fixed name and deletes any
|
|
25
|
+
* prior token of that name on every claim (`GitHostClient.mintPushToken`),
|
|
26
|
+
* since the Forgejo username derives from the Aligner alone, not the
|
|
27
|
+
* problem. Without re-claiming, scaffolding a second problem would
|
|
28
|
+
* invalidate the first problem's stashed token and its `submit` would
|
|
29
|
+
* 401/403. */
|
|
30
|
+
const CLAIM_FILE = 'horizon-forgejo-claim.json';
|
|
31
|
+
const StashedClaimSchema = z.object({
|
|
32
|
+
organizationId: z.string(),
|
|
33
|
+
problemId: z.string(),
|
|
34
|
+
});
|
|
35
|
+
/** `POST /v1/organizations/:organizationId/git-repo-claims` — mints a per-Aligner
|
|
36
|
+
* repo + push token for `problemId`. The claiming Aligner is the
|
|
37
|
+
* authenticated caller (the API key), never a client-supplied field. */
|
|
38
|
+
export async function claimGitRepo(args) {
|
|
39
|
+
const url = supportUrl(args.baseUrl, `/organizations/${encodeURIComponent(args.organizationId)}/git-repo-claims`);
|
|
40
|
+
const res = await fetch(url, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: {
|
|
43
|
+
// biome-ignore lint/style/useNamingConvention: HTTP header names are not camelCase.
|
|
44
|
+
Authorization: `Bearer ${args.apiKey}`,
|
|
45
|
+
'Content-Type': 'application/json',
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify({ problemId: args.problemId }),
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
const text = await res.text().catch(() => '');
|
|
51
|
+
throw new Error(`could not claim a git repo for problem ${args.problemId} — HTTP ${res.status} ${text.slice(0, 200)}`);
|
|
52
|
+
}
|
|
53
|
+
const parsed = GitRepoClaimSchema.safeParse(await res.json());
|
|
54
|
+
if (!parsed.success) {
|
|
55
|
+
throw new Error(`unexpected response shape from ${url}`);
|
|
56
|
+
}
|
|
57
|
+
return parsed.data;
|
|
58
|
+
}
|
|
59
|
+
/** Writes a `GIT_ASKPASS` helper that answers "Username" prompts with a
|
|
60
|
+
* placeholder and "Password" prompts with `token`, via an env var — never
|
|
61
|
+
* the URL or argv, so the token never lands in shell history or `ps`
|
|
62
|
+
* listings. Returns the env overrides to pass to the `git` subprocess and
|
|
63
|
+
* the script's containing directory, for the caller to clean up.
|
|
64
|
+
*
|
|
65
|
+
* `GIT_ASKPASS` must be a single executable file, not a "command args"
|
|
66
|
+
* string — verified live: git silently never invokes a two-word value like
|
|
67
|
+
* `"<node> <script>"` (no error, no fallback prompt — it just doesn't run),
|
|
68
|
+
* so the script needs a `#!/usr/bin/env node` shebang and the exec bit.
|
|
69
|
+
*
|
|
70
|
+
* The script lives in its own `mkdtempSync` directory (not a fixed,
|
|
71
|
+
* guessable path directly under `tmpdir()`) and is written with the `wx`
|
|
72
|
+
* flag, which refuses to follow or overwrite an existing path — closing
|
|
73
|
+
* the local-attacker-pre-plants-a-symlink hardening gap a fixed name would
|
|
74
|
+
* leave open, even though the script itself carries no secret (the token
|
|
75
|
+
* rides in `HORIZON_FORGEJO_TOKEN`, not the script body). */
|
|
76
|
+
function gitCredentialEnv(token) {
|
|
77
|
+
const dir = mkdtempSync(join(tmpdir(), 'horizon-forgejo-'));
|
|
78
|
+
const scriptPath = join(dir, 'askpass.cjs');
|
|
79
|
+
writeFileSync(scriptPath, '#!/usr/bin/env node\n' +
|
|
80
|
+
"const p = process.argv[2] || '';\n" +
|
|
81
|
+
"process.stdout.write(/username/i.test(p) ? 'x-access-token' : (process.env.HORIZON_FORGEJO_TOKEN || ''));\n", { flag: 'wx' });
|
|
82
|
+
chmodSync(scriptPath, 0o700);
|
|
83
|
+
return {
|
|
84
|
+
env: {
|
|
85
|
+
...process.env,
|
|
86
|
+
// biome-ignore lint/style/useNamingConvention: environment variable names, not ours to rename
|
|
87
|
+
GIT_ASKPASS: scriptPath,
|
|
88
|
+
// biome-ignore lint/style/useNamingConvention: environment variable names, not ours to rename
|
|
89
|
+
HORIZON_FORGEJO_TOKEN: token,
|
|
90
|
+
},
|
|
91
|
+
cleanupDir: dir,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function runGit(args, cwd, token) {
|
|
95
|
+
const { env, cleanupDir } = gitCredentialEnv(token);
|
|
96
|
+
try {
|
|
97
|
+
// `-c credential.helper=` (empty) disables any configured credential
|
|
98
|
+
// helper (e.g. macOS's osxkeychain, on by default with git-for-mac) for
|
|
99
|
+
// just this invocation — found live: without it, a helper transparently
|
|
100
|
+
// caches/replays a *previous* claim's credential for the same host,
|
|
101
|
+
// silently shadowing GIT_ASKPASS and authenticating as the wrong claim
|
|
102
|
+
// (or failing on an expired one) instead of using the fresh token below.
|
|
103
|
+
const result = spawnSync('git', ['-c', 'credential.helper=', ...args], {
|
|
104
|
+
cwd,
|
|
105
|
+
env,
|
|
106
|
+
stdio: 'inherit',
|
|
107
|
+
});
|
|
108
|
+
if (result.status !== 0) {
|
|
109
|
+
throw new Error(`git ${args.join(' ')} failed (exit ${result.status ?? 'unknown'})`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
rmSync(cleanupDir, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function claimFilePath(repoDir) {
|
|
117
|
+
return join(repoDir, '.git', CLAIM_FILE);
|
|
118
|
+
}
|
|
119
|
+
function stashClaim(repoDir, claim) {
|
|
120
|
+
writeFileSync(claimFilePath(repoDir), JSON.stringify(claim), { mode: 0o600 });
|
|
121
|
+
}
|
|
122
|
+
function readStashedClaim(repoDir) {
|
|
123
|
+
const path = claimFilePath(repoDir);
|
|
124
|
+
if (!existsSync(path)) {
|
|
125
|
+
throw new Error(`no claim found at ${path} — run \`horizon scaffold\` in this directory first, or pass --path to point at a scaffolded repo`);
|
|
126
|
+
}
|
|
127
|
+
const parsed = StashedClaimSchema.safeParse(JSON.parse(readFileSync(path, 'utf8')));
|
|
128
|
+
if (!parsed.success) {
|
|
129
|
+
throw new Error(`stashed claim at ${path} has an unexpected shape`);
|
|
130
|
+
}
|
|
131
|
+
return parsed.data;
|
|
132
|
+
}
|
|
133
|
+
/** Register the `scaffold` / `submit` commands on `program`. */
|
|
134
|
+
export function addGitHostCommands(program, helpGroup) {
|
|
135
|
+
program
|
|
136
|
+
.command('scaffold <problemId>')
|
|
137
|
+
.helpGroup(helpGroup)
|
|
138
|
+
.description('Claim a per-Aligner git repo for a coding-task problem and clone it locally')
|
|
139
|
+
.requiredOption('--organization-id <id>', 'Organization the problem belongs to')
|
|
140
|
+
.option('--out <dir>', 'Directory to clone into (default: derived from the repo name)')
|
|
141
|
+
.action(async (problemId, opts) => {
|
|
142
|
+
try {
|
|
143
|
+
const { apiKey, baseUrl } = resolveCommandAuth(program);
|
|
144
|
+
const claim = await claimGitRepo({
|
|
145
|
+
apiKey,
|
|
146
|
+
baseUrl,
|
|
147
|
+
organizationId: opts.organizationId,
|
|
148
|
+
problemId,
|
|
149
|
+
});
|
|
150
|
+
const dir = opts.out ?? basename(claim.cloneUrl).replace(/\.git$/u, '');
|
|
151
|
+
mkdirSync(dir, { recursive: true });
|
|
152
|
+
runGit(['clone', claim.cloneUrl, dir], process.cwd(), claim.pushToken);
|
|
153
|
+
stashClaim(dir, { organizationId: opts.organizationId, problemId });
|
|
154
|
+
process.stdout.write(`Cloned into ${dir} (branch ${claim.defaultBranch}). Ready to work.\n`);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
program
|
|
162
|
+
.command('submit')
|
|
163
|
+
.helpGroup(helpGroup)
|
|
164
|
+
.description('Push local changes in a scaffolded repo back to its claimed remote')
|
|
165
|
+
.option('--path <dir>', 'Path to the scaffolded repo (default: current directory)')
|
|
166
|
+
.action(async (opts) => {
|
|
167
|
+
const dir = opts.path ?? process.cwd();
|
|
168
|
+
try {
|
|
169
|
+
// Re-claims rather than reusing a token stashed at `scaffold` time —
|
|
170
|
+
// see `CLAIM_FILE`'s doc comment: the backend's push token is a
|
|
171
|
+
// single fixed-name credential per Aligner, so scaffolding any other
|
|
172
|
+
// problem since would have invalidated a stashed one.
|
|
173
|
+
const stashed = readStashedClaim(dir);
|
|
174
|
+
const { apiKey, baseUrl } = resolveCommandAuth(program);
|
|
175
|
+
const claim = await claimGitRepo({ apiKey, baseUrl, ...stashed });
|
|
176
|
+
runGit(['push'], dir, claim.pushToken);
|
|
177
|
+
process.stdout.write('Pushed.\n');
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** The manifest fields needed to decide whether a generic JSON caller can invoke an operation. */
|
|
2
|
+
export interface JsonOperationCallabilityInput {
|
|
3
|
+
readonly requestMediaTypes?: readonly string[] | undefined;
|
|
4
|
+
readonly successResponses: readonly {
|
|
5
|
+
readonly mediaTypes: readonly string[];
|
|
6
|
+
}[];
|
|
7
|
+
}
|
|
8
|
+
export declare const JSON_REQUEST_MEDIA_TYPE = "application/json";
|
|
9
|
+
export declare const MULTIPART_REQUEST_MEDIA_TYPE = "multipart/form-data";
|
|
10
|
+
export type ResponseBodyKind = 'json' | 'text' | 'binary';
|
|
11
|
+
export declare function baseMediaType(mediaType: string): string;
|
|
12
|
+
/** How a generic HTTP client must consume a successful response representation. */
|
|
13
|
+
export declare function responseBodyKind(mediaType: string): ResponseBodyKind;
|
|
14
|
+
/** The distinct body transports declared across every generated success response. */
|
|
15
|
+
export declare function successResponseBodyKinds(operation: JsonOperationCallabilityInput): readonly ResponseBodyKind[];
|
|
16
|
+
/** Whether any generated success representation requires a byte-stream transport. */
|
|
17
|
+
export declare function hasBinarySuccessRepresentation(operation: JsonOperationCallabilityInput): boolean;
|
|
18
|
+
/** The first generated success representation when it is not JSON. */
|
|
19
|
+
export declare function nonJsonSuccessMediaType(operation: JsonOperationCallabilityInput): string | undefined;
|
|
20
|
+
/** A bodyless operation has no request representation and remains JSON-callable. */
|
|
21
|
+
export declare function hasJsonRequestRepresentation(operation: JsonOperationCallabilityInput): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Whether a generic JSON caller can invoke this manifest operation without a
|
|
24
|
+
* filesystem or binary response transport.
|
|
25
|
+
*
|
|
26
|
+
* Keep this module dependency-free: DX loads it from source during cold
|
|
27
|
+
* bootstrap, before the CLI package has a dist directory.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isJsonOperationCallable(operation: JsonOperationCallabilityInput): boolean;
|
|
30
|
+
/** Whether the operation's usable non-JSON representation is multipart form data. */
|
|
31
|
+
export declare function isMultipartRequestOperation(operation: JsonOperationCallabilityInput): boolean;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export const JSON_REQUEST_MEDIA_TYPE = 'application/json';
|
|
2
|
+
export const MULTIPART_REQUEST_MEDIA_TYPE = 'multipart/form-data';
|
|
3
|
+
export function baseMediaType(mediaType) {
|
|
4
|
+
return (mediaType.split(';')[0] ?? '').trim().toLowerCase();
|
|
5
|
+
}
|
|
6
|
+
/** How a generic HTTP client must consume a successful response representation. */
|
|
7
|
+
export function responseBodyKind(mediaType) {
|
|
8
|
+
const base = baseMediaType(mediaType);
|
|
9
|
+
if (base === JSON_REQUEST_MEDIA_TYPE || base.endsWith('+json'))
|
|
10
|
+
return 'json';
|
|
11
|
+
if (base === '' ||
|
|
12
|
+
base.startsWith('text/') ||
|
|
13
|
+
base === 'application/xml' ||
|
|
14
|
+
base.endsWith('+xml') ||
|
|
15
|
+
base === 'application/yaml' ||
|
|
16
|
+
base.endsWith('+yaml') ||
|
|
17
|
+
base === 'application/javascript') {
|
|
18
|
+
return 'text';
|
|
19
|
+
}
|
|
20
|
+
return 'binary';
|
|
21
|
+
}
|
|
22
|
+
/** The distinct body transports declared across every generated success response. */
|
|
23
|
+
export function successResponseBodyKinds(operation) {
|
|
24
|
+
return [
|
|
25
|
+
...new Set(operation.successResponses.flatMap(({ mediaTypes }) => mediaTypes.map(responseBodyKind))),
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
/** Whether any generated success representation requires a byte-stream transport. */
|
|
29
|
+
export function hasBinarySuccessRepresentation(operation) {
|
|
30
|
+
return successResponseBodyKinds(operation).includes('binary');
|
|
31
|
+
}
|
|
32
|
+
/** The first generated success representation when it is not JSON. */
|
|
33
|
+
export function nonJsonSuccessMediaType(operation) {
|
|
34
|
+
return operation.successResponses
|
|
35
|
+
.flatMap(({ mediaTypes }) => mediaTypes)
|
|
36
|
+
.find((mediaType) => responseBodyKind(mediaType) !== 'json');
|
|
37
|
+
}
|
|
38
|
+
/** A bodyless operation has no request representation and remains JSON-callable. */
|
|
39
|
+
export function hasJsonRequestRepresentation(operation) {
|
|
40
|
+
return (operation.requestMediaTypes === undefined ||
|
|
41
|
+
operation.requestMediaTypes.includes(JSON_REQUEST_MEDIA_TYPE));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Whether a generic JSON caller can invoke this manifest operation without a
|
|
45
|
+
* filesystem or binary response transport.
|
|
46
|
+
*
|
|
47
|
+
* Keep this module dependency-free: DX loads it from source during cold
|
|
48
|
+
* bootstrap, before the CLI package has a dist directory.
|
|
49
|
+
*/
|
|
50
|
+
export function isJsonOperationCallable(operation) {
|
|
51
|
+
return hasJsonRequestRepresentation(operation) && !hasBinarySuccessRepresentation(operation);
|
|
52
|
+
}
|
|
53
|
+
/** Whether the operation's usable non-JSON representation is multipart form data. */
|
|
54
|
+
export function isMultipartRequestOperation(operation) {
|
|
55
|
+
return (operation.requestMediaTypes?.includes(MULTIPART_REQUEST_MEDIA_TYPE) === true &&
|
|
56
|
+
!operation.requestMediaTypes.includes(JSON_REQUEST_MEDIA_TYPE));
|
|
57
|
+
}
|