@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/manifest.js
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, fchmodSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { requestTimeoutMs } from './request-timeout.js';
|
|
8
|
+
export { DEFAULT_BASE_URL } from './default-base-url.generated.js';
|
|
9
|
+
export { hasJsonRequestRepresentation, isJsonOperationCallable, isMultipartRequestOperation, JSON_REQUEST_MEDIA_TYPE, MULTIPART_REQUEST_MEDIA_TYPE, } from './json-operation-callability.js';
|
|
10
|
+
// The live CLI fetches its entire command surface — operations, request/response
|
|
11
|
+
// shapes, and the docs browse data (resources / recipes / concepts / tutorials) —
|
|
12
|
+
// from `GET /cli/manifest` on whatever server `--base-url` points at, instead of
|
|
13
|
+
// baking a compiled-in `@labelbox/horizon-sdk` reference. This module owns:
|
|
14
|
+
// 1. the manifest's runtime Zod schema + TS types (the validation boundary), and
|
|
15
|
+
// 2. `fetchManifest` — a conditional-fetch cache (ETag / If-None-Match) that is
|
|
16
|
+
// revalidated whenever the server is reachable and remains usable offline.
|
|
17
|
+
// The schema lives here, not in horizon-sdk: the CLI no longer depends on that package
|
|
18
|
+
// (see the plan's standalone-CLI decision). The backend embeds the same manifest at
|
|
19
|
+
// build time (`dx manifest:generate`); this is the consumer mirror.
|
|
20
|
+
/**
|
|
21
|
+
* The platform's API version segment, as it appears in every canonical path.
|
|
22
|
+
*
|
|
23
|
+
* Spec-derived operations get this for free — `buildUrl` joins the manifest's
|
|
24
|
+
* `op.path`, which already carries it. The CLI's few HAND-WRITTEN support calls
|
|
25
|
+
* (`/cli/manifest`, `/me/permissions`, `/skills`) have no manifest entry to read
|
|
26
|
+
* it from, so they must supply it themselves via `supportUrl` below. Omitting it
|
|
27
|
+
* is a 404 against a bare-origin base URL, and `fetchManifest` treats a 404 as
|
|
28
|
+
* fatal (only a *network* error falls back to cache), so the CLI cannot build any
|
|
29
|
+
* command at all — the reason this is a single helper rather than three string
|
|
30
|
+
* literals.
|
|
31
|
+
*/
|
|
32
|
+
const API_VERSION_SEGMENT = 'v1';
|
|
33
|
+
/**
|
|
34
|
+
* Join a hand-written support-endpoint path onto the base URL, under the API
|
|
35
|
+
* version segment. `path` is version-relative and must start with `/`.
|
|
36
|
+
*/
|
|
37
|
+
export function supportUrl(baseUrl, path) {
|
|
38
|
+
return `${baseUrl.replace(/\/$/u, '')}/${API_VERSION_SEGMENT}${path}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The manifest format the engine understands. Bumped only on a *breaking* schema
|
|
42
|
+
* change (a renamed/removed required field, a changed enum) — additive fields are
|
|
43
|
+
* backward-compatible because the schema strips unknown keys rather than rejecting
|
|
44
|
+
* them, so a newer server never breaks an older CLI by adding data. A mismatch is
|
|
45
|
+
* surfaced with an actionable upgrade message at the validation boundary.
|
|
46
|
+
*/
|
|
47
|
+
export const MANIFEST_FORMAT_VERSION = 2;
|
|
48
|
+
// ── ShapeNode — the recursive request/response shape (mirrors horizon-sdk/schema.ts) ──
|
|
49
|
+
//
|
|
50
|
+
// Carried verbatim from the operation structs the backend assembles, so the
|
|
51
|
+
// existing CLI renderers (`renderShapeTree`, `leafShapeHelp`) run on it unchanged.
|
|
52
|
+
// Optionals are `?: T | undefined` so the interface matches what zod infers under
|
|
53
|
+
// `exactOptionalPropertyTypes`, letting the recursion carry its `z.ZodType` type.
|
|
54
|
+
export const JsonSchemaTypeSchema = z.enum([
|
|
55
|
+
'array',
|
|
56
|
+
'boolean',
|
|
57
|
+
'integer',
|
|
58
|
+
'null',
|
|
59
|
+
'number',
|
|
60
|
+
'object',
|
|
61
|
+
'string',
|
|
62
|
+
]);
|
|
63
|
+
const ScalarValueSchema = z.union([z.string(), z.number(), z.boolean()]);
|
|
64
|
+
// Lenient (no `.strict()`): unknown keys are stripped, never rejected — this is
|
|
65
|
+
// what makes an additive backend change safe for an older CLI (see the format-version
|
|
66
|
+
// note above). Recursive fields defer via `z.lazy` to the exported alias below.
|
|
67
|
+
const ShapeNodeObjectSchema = z.object({
|
|
68
|
+
name: z.string().optional(),
|
|
69
|
+
type: z.string(),
|
|
70
|
+
jsonTypes: z.array(JsonSchemaTypeSchema).min(1).optional(),
|
|
71
|
+
required: z.boolean().optional(),
|
|
72
|
+
description: z.string().optional(),
|
|
73
|
+
enum: z.array(z.string()).optional(),
|
|
74
|
+
itemEnum: z.array(z.string()).optional(),
|
|
75
|
+
default: ScalarValueSchema.optional(),
|
|
76
|
+
format: z.string().optional(),
|
|
77
|
+
example: ScalarValueSchema.optional(),
|
|
78
|
+
nullable: z.boolean().optional(),
|
|
79
|
+
minimum: z.number().optional(),
|
|
80
|
+
maximum: z.number().optional(),
|
|
81
|
+
exclusiveMinimum: z.number().optional(),
|
|
82
|
+
exclusiveMaximum: z.number().optional(),
|
|
83
|
+
minLength: z.number().optional(),
|
|
84
|
+
maxLength: z.number().optional(),
|
|
85
|
+
minItems: z.number().optional(),
|
|
86
|
+
maxItems: z.number().optional(),
|
|
87
|
+
maxProperties: z.number().optional(),
|
|
88
|
+
uniqueItems: z.boolean().optional(),
|
|
89
|
+
pattern: z.string().optional(),
|
|
90
|
+
fields: z.array(z.lazy(() => ShapeNodeSchema)).optional(),
|
|
91
|
+
items: z.lazy(() => ShapeNodeSchema).optional(),
|
|
92
|
+
additionalProperties: z
|
|
93
|
+
.union([z.boolean(), z.lazy(() => ShapeNodeSchema)])
|
|
94
|
+
.optional(),
|
|
95
|
+
variants: z.array(z.lazy(() => ShapeNodeSchema)).optional(),
|
|
96
|
+
});
|
|
97
|
+
export const ShapeNodeSchema = ShapeNodeObjectSchema;
|
|
98
|
+
function shapeMetaKeys(values) {
|
|
99
|
+
return values;
|
|
100
|
+
}
|
|
101
|
+
// Canonical render order. The helper requires every ShapeMetaKey exactly once
|
|
102
|
+
// and rejects extra keys, keeping this in sync with ShapeNode and CLI_META_LABELS.
|
|
103
|
+
const SHAPE_META_KEYS = shapeMetaKeys([
|
|
104
|
+
'enum',
|
|
105
|
+
'itemEnum',
|
|
106
|
+
'default',
|
|
107
|
+
'format',
|
|
108
|
+
'example',
|
|
109
|
+
'minimum',
|
|
110
|
+
'exclusiveMinimum',
|
|
111
|
+
'maximum',
|
|
112
|
+
'exclusiveMaximum',
|
|
113
|
+
'minLength',
|
|
114
|
+
'maxLength',
|
|
115
|
+
'minItems',
|
|
116
|
+
'maxItems',
|
|
117
|
+
'maxProperties',
|
|
118
|
+
'uniqueItems',
|
|
119
|
+
'pattern',
|
|
120
|
+
'nullable',
|
|
121
|
+
]);
|
|
122
|
+
/** A node's present metadata as ordered `[key, value]` pairs (drives the CLI tags). */
|
|
123
|
+
export function shapeMetaEntries(node) {
|
|
124
|
+
const entries = [];
|
|
125
|
+
for (const key of SHAPE_META_KEYS) {
|
|
126
|
+
const value = node[key];
|
|
127
|
+
if (value === undefined)
|
|
128
|
+
continue;
|
|
129
|
+
if (key === 'enum' || key === 'itemEnum') {
|
|
130
|
+
if (Array.isArray(value) && value.length > 0)
|
|
131
|
+
entries.push([key, value]);
|
|
132
|
+
}
|
|
133
|
+
else if (key === 'nullable') {
|
|
134
|
+
if (value === true)
|
|
135
|
+
entries.push([key, true]);
|
|
136
|
+
}
|
|
137
|
+
else if (key === 'exclusiveMinimum') {
|
|
138
|
+
entries.push([key, `>${value}`]);
|
|
139
|
+
}
|
|
140
|
+
else if (key === 'exclusiveMaximum') {
|
|
141
|
+
entries.push([key, `<${value}`]);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
entries.push([key, value]);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return entries;
|
|
148
|
+
}
|
|
149
|
+
// ── Operations — the executable command surface ──────────────────────────────
|
|
150
|
+
/** A request param: a named shape node plus its HTTP location (for generic dispatch). */
|
|
151
|
+
export const ManifestParamSchema = ShapeNodeObjectSchema.extend({
|
|
152
|
+
name: z.string(),
|
|
153
|
+
required: z.boolean(),
|
|
154
|
+
in: z.enum(['path', 'query', 'header', 'body']),
|
|
155
|
+
});
|
|
156
|
+
export const RequestBodyProjectionSchema = z.discriminatedUnion('kind', [
|
|
157
|
+
z.object({ kind: z.literal('fields'), shape: ShapeNodeSchema }),
|
|
158
|
+
z.object({ kind: z.literal('whole'), shape: ShapeNodeSchema }),
|
|
159
|
+
]);
|
|
160
|
+
export const SuccessResponseSchema = z.object({
|
|
161
|
+
status: z.union([z.number().int().min(200).max(299), z.literal(304)]),
|
|
162
|
+
mediaTypes: z.array(z.string().min(1)),
|
|
163
|
+
headers: z.array(z.string().min(1)),
|
|
164
|
+
});
|
|
165
|
+
export const InvocationExamplesSchema = z.object({
|
|
166
|
+
typescript: z.string(),
|
|
167
|
+
cli: z.string(),
|
|
168
|
+
curl: z.string(),
|
|
169
|
+
});
|
|
170
|
+
/**
|
|
171
|
+
* One operation, carrying everything the CLI needs to build its command and
|
|
172
|
+
* dispatch generically: the `callPath` (command tree), the `httpMethod` + `path`
|
|
173
|
+
* template + `params[].in` + `bodyKey` (dispatch), and the recursive request/response
|
|
174
|
+
* shapes (`--help`). Mirrors the relevant subset of horizon-sdk's `SdkReferenceEntry`.
|
|
175
|
+
*/
|
|
176
|
+
export const ManifestOperationSchema = z.object({
|
|
177
|
+
operationId: z.string(),
|
|
178
|
+
callPath: z.array(z.string()).min(1),
|
|
179
|
+
summary: z.string(),
|
|
180
|
+
description: z.string().optional(),
|
|
181
|
+
httpMethod: z.enum(['get', 'post', 'put', 'patch', 'delete']),
|
|
182
|
+
path: z.string(),
|
|
183
|
+
bodyKey: z.string().optional(),
|
|
184
|
+
// Additive wire metadata: older manifests omit it; current generators emit it
|
|
185
|
+
// whenever a request body exists so non-CLI projections preserve body optionality.
|
|
186
|
+
requestBodyRequired: z.boolean().optional(),
|
|
187
|
+
// Deterministic, unique OpenAPI request representations. Generic clients use
|
|
188
|
+
// this to distinguish JSON-callable operations from alternate transports.
|
|
189
|
+
requestMediaTypes: z.array(z.string().min(1)).min(1).optional(),
|
|
190
|
+
// Complete body root plus how it was projected into params. Optional for
|
|
191
|
+
// backward compatibility with older servers.
|
|
192
|
+
requestBody: RequestBodyProjectionSchema.optional(),
|
|
193
|
+
// Generated from the same SDK-reference entry as the operation contract.
|
|
194
|
+
// Optional so older cached manifests remain readable.
|
|
195
|
+
examples: InvocationExamplesSchema.optional(),
|
|
196
|
+
// Permissions the caller must hold to run this command (from the backend's
|
|
197
|
+
// `@RequirePermissions`). Absent → the command is never gated. The CLI marks
|
|
198
|
+
// commands the caller can't run and pre-empts them with a clear error, checked
|
|
199
|
+
// against the caller's `/me/permissions` set (see permissions.ts).
|
|
200
|
+
requiredPermissions: z.array(z.string()).optional(),
|
|
201
|
+
// A top-level success-response field the terminal CLI prints in quiet mode.
|
|
202
|
+
cliOutputField: z.string().min(1).optional(),
|
|
203
|
+
// Generated HTTP outcomes are the generic dispatcher's success authority.
|
|
204
|
+
successResponses: z.array(SuccessResponseSchema).min(1),
|
|
205
|
+
params: z.array(ManifestParamSchema),
|
|
206
|
+
response: ShapeNodeSchema.optional(),
|
|
207
|
+
});
|
|
208
|
+
// ── Resources — the Reference browse surface (`horizon resources [<id>]`) ──────────
|
|
209
|
+
const ManifestResourceObjectSchema = z.object({
|
|
210
|
+
name: z.string(),
|
|
211
|
+
fields: z.array(ShapeNodeSchema),
|
|
212
|
+
});
|
|
213
|
+
export const ManifestResourceSchema = z.object({
|
|
214
|
+
id: z.string(),
|
|
215
|
+
title: z.string(),
|
|
216
|
+
parent: z.string().optional(),
|
|
217
|
+
order: z.number(),
|
|
218
|
+
domain: z.string().optional(),
|
|
219
|
+
summary: z.string().optional(),
|
|
220
|
+
description: z.string().optional(),
|
|
221
|
+
object: ManifestResourceObjectSchema.optional(),
|
|
222
|
+
operationIds: z.array(z.string()),
|
|
223
|
+
});
|
|
224
|
+
// ── Recipes — the How-to browse surface (`horizon recipes [<id>]`) ─────────────────
|
|
225
|
+
const RecipeSnippetSchema = z.object({ setup: z.string(), main: z.string() });
|
|
226
|
+
// Steps are read only to map a recipe to the resources it touches (via each SDK
|
|
227
|
+
// step's operationId), so they're modeled loosely — just the fields the CLI uses.
|
|
228
|
+
const ManifestRecipeStepSchema = z.object({ operationId: z.string().optional() });
|
|
229
|
+
// A recipe's place in the relationship graph (mirrors `RecipeRelated` in
|
|
230
|
+
// horizon-sdk's recipes-schema). Modeled loosely here — the CLI only reads these to
|
|
231
|
+
// render the "Related" / "Unblocks" block on `horizon recipes <id>`; the generator
|
|
232
|
+
// is the source of truth that validates them. Without these fields the manifest
|
|
233
|
+
// parse would silently strip `related`, so the CLI would never see the links.
|
|
234
|
+
const ManifestLinkTargetSchema = z.object({
|
|
235
|
+
type: z.enum(['recipe', 'concept', 'tutorial', 'resource']),
|
|
236
|
+
id: z.string(),
|
|
237
|
+
});
|
|
238
|
+
const ManifestRequiresEntrySchema = z.union([
|
|
239
|
+
z.object({ type: z.literal('recipe'), id: z.string() }),
|
|
240
|
+
z.object({
|
|
241
|
+
type: z.literal('state'),
|
|
242
|
+
explanation: z.string(),
|
|
243
|
+
predicate: z.string().optional(),
|
|
244
|
+
via: ManifestLinkTargetSchema.optional(),
|
|
245
|
+
}),
|
|
246
|
+
]);
|
|
247
|
+
export const ManifestRelatedSchema = z.object({
|
|
248
|
+
requires: z.array(ManifestRequiresEntrySchema).optional(),
|
|
249
|
+
variationOf: z.string().optional(),
|
|
250
|
+
learnMore: z.array(ManifestLinkTargetSchema).optional(),
|
|
251
|
+
});
|
|
252
|
+
export const ManifestRecipeSchema = z.object({
|
|
253
|
+
id: z.string(),
|
|
254
|
+
title: z.string(),
|
|
255
|
+
goal: z.string(),
|
|
256
|
+
category: z.string(),
|
|
257
|
+
steps: z.array(ManifestRecipeStepSchema),
|
|
258
|
+
sdk: RecipeSnippetSchema,
|
|
259
|
+
cli: RecipeSnippetSchema,
|
|
260
|
+
curl: RecipeSnippetSchema,
|
|
261
|
+
// The recipe's relationship links (optional — absent for an island recipe).
|
|
262
|
+
related: ManifestRelatedSchema.optional(),
|
|
263
|
+
});
|
|
264
|
+
// ── Concepts — the Explanation browse surface (`horizon explain [<concept>]`) ──────
|
|
265
|
+
export const ManifestConceptSchema = z.object({
|
|
266
|
+
id: z.string(),
|
|
267
|
+
title: z.string(),
|
|
268
|
+
domain: z.string(),
|
|
269
|
+
related: z.array(z.string()),
|
|
270
|
+
// The markdown body, added by the assembler (the generated reference holds only
|
|
271
|
+
// metadata — the frontend fetches the `.md` separately; the CLI bundles it).
|
|
272
|
+
body: z.string(),
|
|
273
|
+
});
|
|
274
|
+
// ── Tutorials — the getting-started docs (`horizon tutorials [<id>]`) ──────────────
|
|
275
|
+
export const ManifestTutorialSchema = z.object({
|
|
276
|
+
id: z.string(),
|
|
277
|
+
title: z.string(),
|
|
278
|
+
// The markdown prose, or `null` for a notebook (listed as a link, never dumped).
|
|
279
|
+
body: z.string().nullable(),
|
|
280
|
+
});
|
|
281
|
+
// ── Guides — authored product documentation exposed to MCP agents ───────────
|
|
282
|
+
export const ManifestGuideSchema = z.object({
|
|
283
|
+
id: z.string(),
|
|
284
|
+
title: z.string(),
|
|
285
|
+
description: z.string(),
|
|
286
|
+
path: z.string(),
|
|
287
|
+
body: z.string(),
|
|
288
|
+
});
|
|
289
|
+
// ── Domains — the product taxonomy that groups the browse surfaces ────────────
|
|
290
|
+
export const ManifestDomainSchema = z.object({
|
|
291
|
+
id: z.string(),
|
|
292
|
+
title: z.string(),
|
|
293
|
+
order: z.number(),
|
|
294
|
+
});
|
|
295
|
+
// ── The envelope ─────────────────────────────────────────────────────────────
|
|
296
|
+
export const ManifestSchema = z
|
|
297
|
+
.object({
|
|
298
|
+
formatVersion: z.number(),
|
|
299
|
+
hash: z.string(),
|
|
300
|
+
operations: z.record(z.string(), ManifestOperationSchema),
|
|
301
|
+
resources: z.record(z.string(), ManifestResourceSchema),
|
|
302
|
+
recipes: z.record(z.string(), ManifestRecipeSchema),
|
|
303
|
+
concepts: z.record(z.string(), ManifestConceptSchema),
|
|
304
|
+
tutorials: z.array(ManifestTutorialSchema),
|
|
305
|
+
// Additive and optional so older cached/server manifests and older CLIs remain
|
|
306
|
+
// mutually compatible. The producer emits it on every fresh generation.
|
|
307
|
+
guides: z.record(z.string(), ManifestGuideSchema).optional(),
|
|
308
|
+
domains: z.array(ManifestDomainSchema),
|
|
309
|
+
})
|
|
310
|
+
.superRefine((manifest, ctx) => {
|
|
311
|
+
for (const [key, operation] of Object.entries(manifest.operations)) {
|
|
312
|
+
if (key === operation.operationId)
|
|
313
|
+
continue;
|
|
314
|
+
ctx.addIssue({
|
|
315
|
+
code: 'custom',
|
|
316
|
+
path: ['operations', key, 'operationId'],
|
|
317
|
+
message: `must match manifest key ${JSON.stringify(key)}`,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
/**
|
|
322
|
+
* Validate a fetched/cached manifest at the I/O boundary and check engine↔manifest
|
|
323
|
+
* format compatibility. A version mismatch is the one case worth an actionable
|
|
324
|
+
* message: the schema itself stays lenient (so additive server changes are safe),
|
|
325
|
+
* and only a deliberate format bump trips this.
|
|
326
|
+
*/
|
|
327
|
+
export function parseManifest(value, source) {
|
|
328
|
+
// Peek at the format version BEFORE the full-shape parse. The version bumps only
|
|
329
|
+
// on a *breaking* schema change (renamed/removed required field, changed enum) —
|
|
330
|
+
// which would also fail `ManifestSchema.safeParse`. Validating shape first would
|
|
331
|
+
// therefore shadow the actionable upgrade hint with a generic zod dump for exactly
|
|
332
|
+
// the case the version field exists to flag.
|
|
333
|
+
const versionPeek = z.object({ formatVersion: z.number() }).safeParse(value);
|
|
334
|
+
if (versionPeek.success && versionPeek.data.formatVersion !== MANIFEST_FORMAT_VERSION) {
|
|
335
|
+
const { formatVersion } = versionPeek.data;
|
|
336
|
+
const direction = formatVersion > MANIFEST_FORMAT_VERSION
|
|
337
|
+
? // Only a reader already running this package can print the hint, so it names
|
|
338
|
+
// the current package and command without carrying legacy install advice.
|
|
339
|
+
'this `horizon` CLI is out of date — upgrade it (`npm install -g @labelbox/horizon-cli`)'
|
|
340
|
+
: 'the server is older than this CLI — point --base-url at an up-to-date server';
|
|
341
|
+
throw new Error(`command-manifest format mismatch: this CLI speaks v${MANIFEST_FORMAT_VERSION}, ` +
|
|
342
|
+
`${source} served v${formatVersion}. ${direction}.`);
|
|
343
|
+
}
|
|
344
|
+
const parsed = ManifestSchema.safeParse(value);
|
|
345
|
+
if (!parsed.success) {
|
|
346
|
+
throw new Error(`the command manifest from ${source} has an unexpected shape: ${parsed.error.message}`);
|
|
347
|
+
}
|
|
348
|
+
return parsed.data;
|
|
349
|
+
}
|
|
350
|
+
// ── Conditional-fetch cache (no TTL, never hand-busted) ──────────────────────
|
|
351
|
+
/** What we persist per base-url: the transport ETag + the last manifest payload. */
|
|
352
|
+
const CacheSchema = z.object({ etag: z.string().optional(), manifest: z.unknown() });
|
|
353
|
+
/**
|
|
354
|
+
* A filesystem-safe, collision-free slug for a base URL, so each server caches
|
|
355
|
+
* independently. The readable part is the sanitized URL (handy when eyeballing the
|
|
356
|
+
* cache dir); a short hash of the *full* URL is appended so two URLs that sanitize
|
|
357
|
+
* to the same string (e.g. `https://x.com:8080` vs `https://x-com-8080`) still get
|
|
358
|
+
* distinct cache files rather than silently sharing — and poisoning — one.
|
|
359
|
+
*/
|
|
360
|
+
export function hostSlug(baseUrl) {
|
|
361
|
+
const readable = baseUrl.replace(/[^a-zA-Z0-9]+/gu, '-').replace(/^-+|-+$/gu, '') || 'default';
|
|
362
|
+
const hash = createHash('sha256').update(baseUrl, 'utf8').digest('hex').slice(0, 8);
|
|
363
|
+
return `${readable}-${hash}`;
|
|
364
|
+
}
|
|
365
|
+
function canonicalCachePathFor(baseUrl) {
|
|
366
|
+
return join(homedir(), '.cache', 'horizon', `${hostSlug(baseUrl)}.json`);
|
|
367
|
+
}
|
|
368
|
+
function readCache(path) {
|
|
369
|
+
let raw;
|
|
370
|
+
try {
|
|
371
|
+
raw = readFileSync(path, 'utf8');
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
let json;
|
|
377
|
+
try {
|
|
378
|
+
json = JSON.parse(raw);
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return undefined; // corrupt cache → treat as cold (will refetch).
|
|
382
|
+
}
|
|
383
|
+
const parsed = CacheSchema.safeParse(json);
|
|
384
|
+
if (!parsed.success)
|
|
385
|
+
return undefined;
|
|
386
|
+
// The stored manifest must still parse for the cache to be usable. A cache whose
|
|
387
|
+
// body is corrupt — or in a format this CLI rejects — must NOT seed `If-None-Match`:
|
|
388
|
+
// a 304 would reuse the bad payload and trap the CLI in a revalidation loop the
|
|
389
|
+
// server can never break (it keeps answering 304, so no fresh 200 body ever
|
|
390
|
+
// arrives). Treating it as cold lets the next fetch pull — and re-cache — a full
|
|
391
|
+
// body, so the CLI self-heals without a manual cache delete.
|
|
392
|
+
try {
|
|
393
|
+
return {
|
|
394
|
+
etag: parsed.data.etag,
|
|
395
|
+
manifest: parseManifest(parsed.data.manifest, 'the cache'),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const CACHE_TEMP_PREFIX = '.horizon-cli-';
|
|
403
|
+
function uniqueCacheTempPath(directory) {
|
|
404
|
+
return join(directory, `${CACHE_TEMP_PREFIX}${process.pid.toString()}-${randomUUID()}.tmp`);
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Create, populate, close, and publish one exclusively owned same-directory temp.
|
|
408
|
+
*
|
|
409
|
+
* Ownership begins immediately after `openSync` succeeds, so every later failure —
|
|
410
|
+
* including a partial write or close failure — triggers best-effort pathname cleanup.
|
|
411
|
+
* Descriptor ownership is cleared before the single close attempt: retrying a numeric
|
|
412
|
+
* descriptor could close an unrelated resource if the operating system reused it.
|
|
413
|
+
* Publication happens only after close succeeds and transfers the temp inode.
|
|
414
|
+
*/
|
|
415
|
+
function publishFromOwnedTemp(path, raw, prepare, publish) {
|
|
416
|
+
let tempPath;
|
|
417
|
+
let descriptor;
|
|
418
|
+
let ownsTemp = false;
|
|
419
|
+
try {
|
|
420
|
+
const directory = dirname(path);
|
|
421
|
+
mkdirSync(directory, { recursive: true });
|
|
422
|
+
tempPath = uniqueCacheTempPath(directory);
|
|
423
|
+
descriptor = openSync(tempPath, 'wx');
|
|
424
|
+
ownsTemp = true;
|
|
425
|
+
const bytes = Buffer.from(raw, 'utf8');
|
|
426
|
+
let offset = 0;
|
|
427
|
+
while (offset < bytes.byteLength) {
|
|
428
|
+
const written = writeSync(descriptor, bytes, offset, bytes.byteLength - offset);
|
|
429
|
+
if (written === 0)
|
|
430
|
+
throw new Error('writing the cache temp made no progress');
|
|
431
|
+
offset += written;
|
|
432
|
+
}
|
|
433
|
+
prepare(descriptor);
|
|
434
|
+
const descriptorToClose = descriptor;
|
|
435
|
+
descriptor = undefined;
|
|
436
|
+
closeSync(descriptorToClose);
|
|
437
|
+
publish(tempPath);
|
|
438
|
+
ownsTemp = false;
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
// Cache persistence is a bandwidth optimization. The caller already has a
|
|
442
|
+
// validated in-memory manifest, so publication failures are non-fatal.
|
|
443
|
+
}
|
|
444
|
+
finally {
|
|
445
|
+
if (descriptor !== undefined) {
|
|
446
|
+
const descriptorToClose = descriptor;
|
|
447
|
+
descriptor = undefined;
|
|
448
|
+
try {
|
|
449
|
+
closeSync(descriptorToClose);
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
// Never retry a numeric descriptor: it may already have been reused.
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (ownsTemp && tempPath !== undefined) {
|
|
456
|
+
try {
|
|
457
|
+
unlinkSync(tempPath);
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
// Pathname cleanup is best-effort; readers inspect only the final path.
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Replace a cache with a validated HTTP 200 response via same-directory rename.
|
|
467
|
+
*
|
|
468
|
+
* Rename deliberately publishes a new inode: an existing destination's POSIX mode
|
|
469
|
+
* bits are copied to that inode, but its ACLs and xattrs are not. The operation
|
|
470
|
+
* requires write/search permission on the parent directory; it never falls back to
|
|
471
|
+
* a partial direct write when temporary-file creation or rename is unavailable.
|
|
472
|
+
*/
|
|
473
|
+
function replaceCacheAtomically(path, raw) {
|
|
474
|
+
publishFromOwnedTemp(path, raw, (descriptor) => {
|
|
475
|
+
// Read the mode immediately before publication. Concurrent HTTP 200 writers all
|
|
476
|
+
// preserve the established mode while rename keeps last-network-writer-wins.
|
|
477
|
+
const destination = statSync(path, { throwIfNoEntry: false });
|
|
478
|
+
if (destination !== undefined)
|
|
479
|
+
fchmodSync(descriptor, destination.mode & 0o7777);
|
|
480
|
+
}, (tempPath) => {
|
|
481
|
+
renameSync(tempPath, path);
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
// Every `horizon` invocation gates on this fetch, so it must be bounded: a server that
|
|
485
|
+
// accepts the connection but never responds (a hung gateway, a stalled captive
|
|
486
|
+
// portal) would otherwise hang the CLI forever. A timeout makes `fetch` reject, so
|
|
487
|
+
// the catch below degrades to the cached manifest (or the clear cold-cache error).
|
|
488
|
+
const MANIFEST_FETCH_TIMEOUT_MS = 30_000;
|
|
489
|
+
function manifestAfterNetworkFailure(url, cached) {
|
|
490
|
+
if (cached) {
|
|
491
|
+
process.stderr.write(`warning: could not reach ${url} — using the cached command manifest.\n`);
|
|
492
|
+
return cached.manifest;
|
|
493
|
+
}
|
|
494
|
+
throw new Error(`could not reach ${url} to fetch the command manifest, and no cached copy exists ` +
|
|
495
|
+
'(check --base-url and your network).');
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Fetch the command manifest, revalidating the cache on every run:
|
|
499
|
+
* - send `If-None-Match` with the cached ETag → `304` means the cache is provably
|
|
500
|
+
* current (use it); `200` means the surface changed (validate + replace cache).
|
|
501
|
+
* - a network error (incl. a timeout) falls back to the cached copy with a warning;
|
|
502
|
+
* with no cache it errors clearly.
|
|
503
|
+
* When the server is reachable, the cache is revalidated and a backend redeploy is
|
|
504
|
+
* picked up automatically. Offline, the last locally validated copy may be older
|
|
505
|
+
* than the server but keeps the CLI usable.
|
|
506
|
+
*/
|
|
507
|
+
export async function fetchManifest(baseUrl, apiKey) {
|
|
508
|
+
const url = supportUrl(baseUrl, '/cli/manifest');
|
|
509
|
+
const canonicalCachePath = canonicalCachePathFor(baseUrl);
|
|
510
|
+
const cached = readCache(canonicalCachePath);
|
|
511
|
+
const timeoutMs = requestTimeoutMs(MANIFEST_FETCH_TIMEOUT_MS);
|
|
512
|
+
let res;
|
|
513
|
+
try {
|
|
514
|
+
res = await fetch(url, {
|
|
515
|
+
headers: {
|
|
516
|
+
// biome-ignore lint/style/useNamingConvention: HTTP header names are not camelCase.
|
|
517
|
+
Authorization: `Bearer ${apiKey}`,
|
|
518
|
+
// `If-None-Match` is a quoted string key, so it needs no naming-convention suppression.
|
|
519
|
+
...(cached?.etag ? { 'If-None-Match': cached.etag } : {}),
|
|
520
|
+
},
|
|
521
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
return manifestAfterNetworkFailure(url, cached);
|
|
526
|
+
}
|
|
527
|
+
if (res.status === 304 && cached) {
|
|
528
|
+
return cached.manifest; // validated when read.
|
|
529
|
+
}
|
|
530
|
+
if (res.status === 401 || res.status === 403) {
|
|
531
|
+
throw new Error(`API key rejected fetching the command manifest (HTTP ${res.status})`);
|
|
532
|
+
}
|
|
533
|
+
if (!res.ok) {
|
|
534
|
+
throw new Error(`could not fetch the command manifest from ${url} — HTTP ${res.status}`);
|
|
535
|
+
}
|
|
536
|
+
// A 200 isn't a guarantee of JSON — a captive portal / reverse-proxy login page
|
|
537
|
+
// returns HTML, a misconfigured gateway an empty body. `res.json()` would throw a
|
|
538
|
+
// raw `SyntaxError` ("Unexpected token '<'…") that reaches the user as an
|
|
539
|
+
// unactionable `error:` line; surface a clear one instead, matching how
|
|
540
|
+
// `parseManifest` and `dispatch.ts` degrade.
|
|
541
|
+
let json;
|
|
542
|
+
try {
|
|
543
|
+
json = await res.json();
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
// Fetch resolves once headers arrive, but the same request can still time out
|
|
547
|
+
// or fail while its body is being consumed. Only JSON syntax failures mean
|
|
548
|
+
// the server returned a malformed manifest; transport/body failures use the
|
|
549
|
+
// same validated-cache fallback as a failure before headers.
|
|
550
|
+
if (!(error instanceof SyntaxError))
|
|
551
|
+
return manifestAfterNetworkFailure(url, cached);
|
|
552
|
+
throw new Error(`the command manifest from ${url} was not valid JSON — check that --base-url ` +
|
|
553
|
+
'points at a Horizon API (not a login page or proxy).');
|
|
554
|
+
}
|
|
555
|
+
const manifest = parseManifest(json, url);
|
|
556
|
+
replaceCacheAtomically(canonicalCachePath, JSON.stringify({ etag: res.headers.get('etag') ?? undefined, manifest: json }));
|
|
557
|
+
return manifest;
|
|
558
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type ManifestOperation } from './manifest.js';
|
|
2
|
+
/**
|
|
3
|
+
* The caller's granted permission entries, or `undefined` when they couldn't be
|
|
4
|
+
* determined (fetch failed, or the server returned an empty set). `undefined`
|
|
5
|
+
* means "unknown" → gate nothing.
|
|
6
|
+
*/
|
|
7
|
+
export type GrantedPermissions = ReadonlySet<string> | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Fetch the caller's granted permissions. Network, response, and decoding errors
|
|
10
|
+
* resolve to `undefined` (fail-open); invalid local timeout/deadline configuration
|
|
11
|
+
* fails before the request. An empty array also resolves to `undefined` — the
|
|
12
|
+
* backend returns `[]` for callers with no computed permissions
|
|
13
|
+
* (local/standalone/S2S), where enforcement is bypassed, so the CLI must not treat
|
|
14
|
+
* that as "deny everything". (An E2E caller with a present-but-empty
|
|
15
|
+
* `X-Permissions` also gets `[]` here; the backend would 403, but the CLI only
|
|
16
|
+
* skips local pre-emption — it does not hide or allow the call.)
|
|
17
|
+
*/
|
|
18
|
+
export declare function fetchPermissions(baseUrl: string, apiKey: string): Promise<GrantedPermissions>;
|
|
19
|
+
/**
|
|
20
|
+
* Whether a granted set satisfies a single required permission. Mirrors
|
|
21
|
+
* `hasPermission` in `@recursion/shared` — the CLI can't depend on that private
|
|
22
|
+
* package, and (like the manifest schema) re-declares the tiny bit it needs.
|
|
23
|
+
* Wildcards: `*` grants everything; `resource:*` grants all actions on a resource.
|
|
24
|
+
*/
|
|
25
|
+
export declare function hasPermission(granted: ReadonlySet<string>, required: string): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* The first required permission the caller lacks, or `undefined` when the command
|
|
28
|
+
* is runnable — because it's ungated (no `requiredPermissions`), the caller holds
|
|
29
|
+
* all of them, or permissions are unknown (fail-open). Drives both the `--help`
|
|
30
|
+
* marker and the pre-emptive error on invocation, so they always agree.
|
|
31
|
+
*/
|
|
32
|
+
export declare function missingPermission(op: ManifestOperation, granted: GrantedPermissions): string | undefined;
|
|
33
|
+
/** Nested resource groups — subcommands the caller can't run (separate help section). */
|
|
34
|
+
export declare const UNAVAILABLE_HELP_GROUP = "Unavailable (missing permission)";
|
|
35
|
+
/** A leaf command's description when the caller lacks a required permission. */
|
|
36
|
+
export declare function gatedSummary(summary: string, missing: string | undefined): string;
|
|
37
|
+
/** The error printed when the caller invokes a command they lack permission for. */
|
|
38
|
+
export declare function permissionDeniedMessage(missing: string): string;
|
|
39
|
+
/** A prominent note appended to a gated command's `--help` output. */
|
|
40
|
+
export declare function permissionHelpNote(missing: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* `missingPermission` for a command that carries its requirement in code rather
|
|
43
|
+
* than in the manifest: the permission back, or `undefined` when the caller holds
|
|
44
|
+
* it or permissions are unknown (fail-open, same as the manifest path).
|
|
45
|
+
*/
|
|
46
|
+
export declare function missingRequiredPermission(granted: GrantedPermissions, required: string): string | undefined;
|