@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.
@@ -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
+ }
@@ -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;
@@ -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
+ }