@azx-pbc/helix-cli 0.0.0 → 0.1.0
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/LICENSE +21 -0
- package/README.md +180 -7
- package/dist/helix.js +1185 -0
- package/package.json +32 -6
package/dist/helix.js
ADDED
|
@@ -0,0 +1,1185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/args.ts
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
var options = {
|
|
6
|
+
slug: { type: "string" },
|
|
7
|
+
"portal-url": { type: "string" },
|
|
8
|
+
dir: { type: "string" },
|
|
9
|
+
bundle: { type: "string" },
|
|
10
|
+
token: { type: "string" },
|
|
11
|
+
promote: { type: "boolean" },
|
|
12
|
+
"display-name": { type: "string" },
|
|
13
|
+
visibility: { type: "string" },
|
|
14
|
+
help: { type: "boolean" }
|
|
15
|
+
};
|
|
16
|
+
function parseCliArgs(argv) {
|
|
17
|
+
const args = argv[0] === "--" ? argv.slice(1) : argv;
|
|
18
|
+
return parseArgs({ args, allowPositionals: true, options });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/client.ts
|
|
22
|
+
import { z as z18 } from "zod";
|
|
23
|
+
|
|
24
|
+
// ../shared/src/visibility.ts
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
var VisibilitySchema = z.discriminatedUnion("mode", [
|
|
27
|
+
z.object({ mode: z.literal("private") }),
|
|
28
|
+
z.object({ mode: z.literal("group"), groupId: z.string().min(1) }),
|
|
29
|
+
z.object({ mode: z.literal("password") }),
|
|
30
|
+
z.object({ mode: z.literal("public") })
|
|
31
|
+
]);
|
|
32
|
+
var VISIBILITY_MODES = ["private", "group", "password", "public"];
|
|
33
|
+
var VisibilityModeSchema = z.enum(VISIBILITY_MODES);
|
|
34
|
+
|
|
35
|
+
// ../shared/src/app.ts
|
|
36
|
+
import { z as z2 } from "zod";
|
|
37
|
+
var SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
38
|
+
var AppSchema = z2.object({
|
|
39
|
+
id: z2.uuid(),
|
|
40
|
+
slug: z2.string().min(1).max(63).regex(SLUG_PATTERN, "must be a lowercase DNS label (a-z, 0-9, hyphen)"),
|
|
41
|
+
displayName: z2.string().min(1).max(200),
|
|
42
|
+
visibility: VisibilitySchema,
|
|
43
|
+
/** The version currently served; null before the first deploy. */
|
|
44
|
+
currentVersionId: z2.uuid().nullable(),
|
|
45
|
+
/** When set, the app is archived: the edge serves 410 + Clear-Site-Data (§7). */
|
|
46
|
+
archivedAt: z2.iso.datetime().nullable(),
|
|
47
|
+
createdAt: z2.iso.datetime(),
|
|
48
|
+
updatedAt: z2.iso.datetime(),
|
|
49
|
+
/**
|
|
50
|
+
* Where this app is served, computed control-plane-side from the deployment's
|
|
51
|
+
* apps base (`APP_PUBLIC_BASE`) — so clients render a URL instead of
|
|
52
|
+
* templating `<slug>.<domain>` themselves and drifting per deployment. Optional
|
|
53
|
+
* on the wire: the CLI parses this schema, so requiring it would break a newer
|
|
54
|
+
* CLI against an older portal. Clients that lack it fall back to composing the
|
|
55
|
+
* slug onto `appPublicBase` from `GET /api/v1/config`.
|
|
56
|
+
*/
|
|
57
|
+
url: z2.url().optional()
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ../shared/src/version.ts
|
|
61
|
+
import { z as z3 } from "zod";
|
|
62
|
+
var VERSION_STATUSES = ["preview", "live", "archived"];
|
|
63
|
+
var VersionStatusSchema = z3.enum(VERSION_STATUSES);
|
|
64
|
+
var VersionSchema = z3.object({
|
|
65
|
+
id: z3.uuid(),
|
|
66
|
+
appId: z3.uuid(),
|
|
67
|
+
/** Monotonic per app, 1-based. */
|
|
68
|
+
number: z3.int().positive(),
|
|
69
|
+
/** Blob key prefix for this version's assets, e.g. `apps/<appId>/7/`. */
|
|
70
|
+
blobPrefix: z3.string().min(1),
|
|
71
|
+
status: VersionStatusSchema,
|
|
72
|
+
createdAt: z3.iso.datetime()
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ../shared/src/manifest.ts
|
|
76
|
+
import { z as z4 } from "zod";
|
|
77
|
+
var LlmCapabilitySchema = z4.object({
|
|
78
|
+
models: z4.array(z4.string()).default([]),
|
|
79
|
+
/**
|
|
80
|
+
* Per-app daily LLM spend cap in USD; unset ⇒ unbounded. Denominated in
|
|
81
|
+
* dollars (not tokens) so the cap means the same thing across models — the
|
|
82
|
+
* edge prices each call via `@azx-pbc/shared` pricing and enforces a daily +
|
|
83
|
+
* rolling-hour burst window off the frozen `costMicroUsd` ledger column.
|
|
84
|
+
*/
|
|
85
|
+
dollarsPerDay: z4.number().positive().optional()
|
|
86
|
+
});
|
|
87
|
+
var DataCapabilitySchema = z4.object({
|
|
88
|
+
user: z4.boolean().default(false),
|
|
89
|
+
collections: z4.array(z4.string().min(1)).default([]),
|
|
90
|
+
sharedRead: z4.array(z4.string().min(1)).default([]),
|
|
91
|
+
sharedWrite: z4.array(z4.string().min(1)).default([]),
|
|
92
|
+
writesPerDay: z4.int().positive().optional(),
|
|
93
|
+
bytesPerDay: z4.int().positive().optional()
|
|
94
|
+
});
|
|
95
|
+
var FetchConnectionSchema = z4.object({
|
|
96
|
+
origin: z4.url(),
|
|
97
|
+
connection: z4.string().min(1).optional()
|
|
98
|
+
});
|
|
99
|
+
var FetchCapabilitySchema = z4.object({
|
|
100
|
+
/** Opt-in transparent `fetch` shim injected at serve time (fetch-proxy §3.2). */
|
|
101
|
+
shim: z4.boolean().default(false),
|
|
102
|
+
/** Origins reached through the proxy (mode `proxy`); direct stays in `externalOrigins`. */
|
|
103
|
+
origins: z4.array(FetchConnectionSchema).default([]),
|
|
104
|
+
/** Per-app daily proxied-request budget; unset ⇒ unbounded (fetch-proxy §7). */
|
|
105
|
+
requestsPerDay: z4.int().positive().optional()
|
|
106
|
+
});
|
|
107
|
+
var CapabilitiesSchema = z4.object({
|
|
108
|
+
llm: LlmCapabilitySchema.optional(),
|
|
109
|
+
data: DataCapabilitySchema.optional(),
|
|
110
|
+
/** Platform-registered MCP servers this app may reach (§6.1, exposed as REST). */
|
|
111
|
+
mcp: z4.array(z4.string()).default([]),
|
|
112
|
+
/** Extra CSP `connect-src` origins for **direct** browser calls (§4.4). */
|
|
113
|
+
externalOrigins: z4.array(z4.url()).default([]),
|
|
114
|
+
/** Governed outbound HTTP via the fetch-proxy / egress plane (in build, M4.5). */
|
|
115
|
+
fetch: FetchCapabilitySchema.optional()
|
|
116
|
+
});
|
|
117
|
+
var AppManifestSchema = z4.object({
|
|
118
|
+
/** App slug; matches `App.slug`. */
|
|
119
|
+
app: z4.string().min(1),
|
|
120
|
+
visibility: VisibilitySchema,
|
|
121
|
+
capabilities: CapabilitiesSchema.default({ mcp: [], externalOrigins: [] })
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ../shared/src/approval.ts
|
|
125
|
+
import { z as z5 } from "zod";
|
|
126
|
+
|
|
127
|
+
// ../shared/src/pricing.ts
|
|
128
|
+
var MODEL_PRICING = {
|
|
129
|
+
"claude-fable-5": { inputPerMTok: 10, outputPerMTok: 50 },
|
|
130
|
+
"claude-opus-4-8": { inputPerMTok: 5, outputPerMTok: 25 },
|
|
131
|
+
"claude-opus-4-7": { inputPerMTok: 5, outputPerMTok: 25 },
|
|
132
|
+
"claude-opus-4-6": { inputPerMTok: 5, outputPerMTok: 25 },
|
|
133
|
+
"claude-sonnet-4-6": { inputPerMTok: 3, outputPerMTok: 15 },
|
|
134
|
+
"claude-haiku-4-5": { inputPerMTok: 1, outputPerMTok: 5 }
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// ../shared/src/approval.ts
|
|
138
|
+
var RISK_LEVELS = ["low", "med", "high"];
|
|
139
|
+
var RiskSchema = z5.enum(RISK_LEVELS);
|
|
140
|
+
var APPROVAL_STATUSES = [
|
|
141
|
+
"pending",
|
|
142
|
+
"approved",
|
|
143
|
+
"denied",
|
|
144
|
+
"withdrawn",
|
|
145
|
+
"needs_changes"
|
|
146
|
+
];
|
|
147
|
+
var ApprovalStatusSchema = z5.enum(APPROVAL_STATUSES);
|
|
148
|
+
var DeltaSchema = z5.object({
|
|
149
|
+
path: z5.string(),
|
|
150
|
+
from: z5.union([z5.string(), z5.number(), z5.boolean()]).optional(),
|
|
151
|
+
to: z5.union([z5.string(), z5.number(), z5.boolean()]).optional()
|
|
152
|
+
});
|
|
153
|
+
var ApprovalRequestSchema = z5.object({
|
|
154
|
+
id: z5.string(),
|
|
155
|
+
appId: z5.string(),
|
|
156
|
+
/** Joined for the admin queue / banner; not a column. */
|
|
157
|
+
appSlug: z5.string().optional(),
|
|
158
|
+
appDisplayName: z5.string().optional(),
|
|
159
|
+
status: ApprovalStatusSchema,
|
|
160
|
+
risk: RiskSchema,
|
|
161
|
+
deltas: z5.array(DeltaSchema),
|
|
162
|
+
baseSnapshot: z5.unknown(),
|
|
163
|
+
requestedBy: z5.string(),
|
|
164
|
+
reason: z5.string().nullable().optional(),
|
|
165
|
+
decidedBy: z5.string().nullable().optional(),
|
|
166
|
+
decisionNote: z5.string().nullable().optional(),
|
|
167
|
+
createdAt: z5.string(),
|
|
168
|
+
decidedAt: z5.string().nullable().optional()
|
|
169
|
+
});
|
|
170
|
+
var ManifestUpdateResultSchema = z5.object({
|
|
171
|
+
manifest: AppManifestSchema,
|
|
172
|
+
applied: z5.array(DeltaSchema),
|
|
173
|
+
pending: z5.string().nullable()
|
|
174
|
+
});
|
|
175
|
+
var VisibilityUpdateResultSchema = z5.object({
|
|
176
|
+
app: AppSchema,
|
|
177
|
+
applied: z5.array(DeltaSchema),
|
|
178
|
+
pending: z5.string().nullable()
|
|
179
|
+
});
|
|
180
|
+
var ApprovalDecisionRequestSchema = z5.object({
|
|
181
|
+
note: z5.string().max(2e3).optional()
|
|
182
|
+
});
|
|
183
|
+
var CURATED_LLM_MODELS = Object.keys(MODEL_PRICING);
|
|
184
|
+
|
|
185
|
+
// ../shared/src/health.ts
|
|
186
|
+
import { z as z6 } from "zod";
|
|
187
|
+
var HealthStateSchema = z6.enum(["ok", "degraded", "error"]);
|
|
188
|
+
var HealthCheckSchema = z6.object({
|
|
189
|
+
/** Stable identifier an alert rule can key on, e.g. `registry-projection`. */
|
|
190
|
+
name: z6.string(),
|
|
191
|
+
status: HealthStateSchema,
|
|
192
|
+
/** Operator-facing one-liner: what is wrong, and for how long. */
|
|
193
|
+
detail: z6.string().optional(),
|
|
194
|
+
/** Wall-clock instant this check last succeeded. Report-only — never the
|
|
195
|
+
* basis of a staleness decision (see the monotonic note in the edge's
|
|
196
|
+
* registry projection). */
|
|
197
|
+
lastSuccessAt: z6.iso.datetime().optional(),
|
|
198
|
+
/** Numeric facts a dashboard or a log-based metric can key on. */
|
|
199
|
+
metrics: z6.record(z6.string(), z6.number()).optional()
|
|
200
|
+
});
|
|
201
|
+
var HealthStatusSchema = z6.object({
|
|
202
|
+
/** The roll-up: the worst state across `checks` (see `worstHealthState`). */
|
|
203
|
+
status: HealthStateSchema,
|
|
204
|
+
service: z6.string(),
|
|
205
|
+
/** Process uptime in seconds. */
|
|
206
|
+
uptime: z6.number().nonnegative(),
|
|
207
|
+
/** Absent means the service reports liveness only (portal and egress today). */
|
|
208
|
+
checks: z6.array(HealthCheckSchema).optional()
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ../shared/src/api.ts
|
|
212
|
+
import { z as z7 } from "zod";
|
|
213
|
+
var CreateAppRequestSchema = z7.object({
|
|
214
|
+
slug: AppSchema.shape.slug,
|
|
215
|
+
displayName: AppSchema.shape.displayName,
|
|
216
|
+
visibility: VisibilitySchema.default({ mode: "private" }),
|
|
217
|
+
/** Optional per-app capability grant set at create time (architecture §6.3). */
|
|
218
|
+
capabilities: CapabilitiesSchema.optional()
|
|
219
|
+
});
|
|
220
|
+
var SetManifestRequestSchema = z7.object({
|
|
221
|
+
capabilities: CapabilitiesSchema,
|
|
222
|
+
/** Justification carried onto an approval request for any elevated deltas. */
|
|
223
|
+
reason: z7.string().max(2e3).optional()
|
|
224
|
+
});
|
|
225
|
+
var SetVisibilityRequestSchema = z7.object({
|
|
226
|
+
visibility: VisibilitySchema,
|
|
227
|
+
/** Justification carried onto the approval request when going public. */
|
|
228
|
+
reason: z7.string().max(2e3).optional()
|
|
229
|
+
});
|
|
230
|
+
var MIN_PASSWORD_LENGTH = 12;
|
|
231
|
+
var SetPasswordRequestSchema = z7.object({
|
|
232
|
+
password: z7.string().min(MIN_PASSWORD_LENGTH).optional()
|
|
233
|
+
});
|
|
234
|
+
var PasswordCredentialResponseSchema = z7.object({
|
|
235
|
+
/** The shared passphrase, in cleartext, for the owner to copy/share. */
|
|
236
|
+
password: z7.string().min(1),
|
|
237
|
+
/** The app's public URL, prebuilt so the UI can offer a one-click copy. */
|
|
238
|
+
url: z7.url(),
|
|
239
|
+
/** When the current password was set (ISO 8601). */
|
|
240
|
+
setAt: z7.string()
|
|
241
|
+
});
|
|
242
|
+
var CspWarningSchema = z7.object({
|
|
243
|
+
file: z7.string(),
|
|
244
|
+
origin: z7.string(),
|
|
245
|
+
hint: z7.string()
|
|
246
|
+
});
|
|
247
|
+
var OriginGrantRequestSchema = z7.object({
|
|
248
|
+
origin: z7.url(),
|
|
249
|
+
reason: z7.string().max(2e3).optional()
|
|
250
|
+
});
|
|
251
|
+
var CspViolationSchema = z7.object({
|
|
252
|
+
appId: z7.string(),
|
|
253
|
+
appSlug: z7.string().nullable(),
|
|
254
|
+
directive: z7.string(),
|
|
255
|
+
blockedUri: z7.string(),
|
|
256
|
+
count: z7.number().int(),
|
|
257
|
+
lastSeen: z7.string(),
|
|
258
|
+
/**
|
|
259
|
+
* The blocked origin is already permitted by the app's *current* manifest, so
|
|
260
|
+
* this historical report is no longer actionable. Derived at read time
|
|
261
|
+
* (directive-aware — only `connect-src`/`img-src` are widened by an
|
|
262
|
+
* `externalOrigins` grant); the row itself is never deleted.
|
|
263
|
+
*/
|
|
264
|
+
resolved: z7.boolean()
|
|
265
|
+
});
|
|
266
|
+
var CspViolationsPageSchema = z7.object({
|
|
267
|
+
violations: z7.array(CspViolationSchema)
|
|
268
|
+
});
|
|
269
|
+
var UploadVersionResponseSchema = z7.object({
|
|
270
|
+
version: VersionSchema,
|
|
271
|
+
warnings: z7.array(CspWarningSchema)
|
|
272
|
+
});
|
|
273
|
+
var RollbackRequestSchema = z7.object({
|
|
274
|
+
toNumber: z7.int().positive().optional()
|
|
275
|
+
});
|
|
276
|
+
var API_ERROR_CODES = [
|
|
277
|
+
"validation_failed",
|
|
278
|
+
"not_found",
|
|
279
|
+
"slug_taken",
|
|
280
|
+
"bundle_invalid",
|
|
281
|
+
"unauthorized",
|
|
282
|
+
/** Authenticated but not allowed — reserved for v1 RBAC. */
|
|
283
|
+
"forbidden",
|
|
284
|
+
"conflict",
|
|
285
|
+
/** Gateway: requested model is not in the app's manifest allowlist (§6.3). */
|
|
286
|
+
"model_not_allowed",
|
|
287
|
+
/** Gateway: the app's daily token budget is exhausted (§6.1). */
|
|
288
|
+
"quota_exceeded",
|
|
289
|
+
/**
|
|
290
|
+
* Gateway: the anonymous tier's per-IP request budget is exhausted on a
|
|
291
|
+
* `public` app (app-data design §7). Distinct from `quota_exceeded` (per-app
|
|
292
|
+
* daily budget) so the app can tell per-IP throttling apart from running out
|
|
293
|
+
* of its own budget. HTTP 429.
|
|
294
|
+
*/
|
|
295
|
+
"rate_limited",
|
|
296
|
+
/** Gateway: a configured capability is not available on this edge. */
|
|
297
|
+
"capability_unavailable",
|
|
298
|
+
"internal"
|
|
299
|
+
];
|
|
300
|
+
var ApiErrorCodeSchema = z7.enum(API_ERROR_CODES);
|
|
301
|
+
var ApiErrorSchema = z7.object({
|
|
302
|
+
error: z7.object({
|
|
303
|
+
code: ApiErrorCodeSchema,
|
|
304
|
+
message: z7.string(),
|
|
305
|
+
details: z7.unknown().optional()
|
|
306
|
+
})
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// ../shared/src/auth.ts
|
|
310
|
+
import { z as z8 } from "zod";
|
|
311
|
+
var MeResponseSchema = z8.object({
|
|
312
|
+
user: z8.object({
|
|
313
|
+
/** IdP subject (Entra object id) — stable, safe to key app data on. */
|
|
314
|
+
id: z8.string(),
|
|
315
|
+
displayName: z8.string()
|
|
316
|
+
})
|
|
317
|
+
});
|
|
318
|
+
var PortalMeResponseSchema = z8.object({
|
|
319
|
+
sub: z8.string(),
|
|
320
|
+
/** How the actor was established: `oidc` or `dev-token`. */
|
|
321
|
+
via: z8.string(),
|
|
322
|
+
name: z8.string().optional(),
|
|
323
|
+
email: z8.string().optional(),
|
|
324
|
+
/**
|
|
325
|
+
* Whether the actor holds the `platform-admin` role. Computed server-side from
|
|
326
|
+
* the actor's group/role claim — the raw ids never cross to the browser. Drives
|
|
327
|
+
* admin nav + route gating in the SPA (and `helix whoami`).
|
|
328
|
+
*/
|
|
329
|
+
isAdmin: z8.boolean()
|
|
330
|
+
});
|
|
331
|
+
var AuthConfigResponseSchema = z8.object({
|
|
332
|
+
issuer: z8.url(),
|
|
333
|
+
cliClientId: z8.string().min(1),
|
|
334
|
+
/** Public client the portal SPA uses for code+PKCE in the browser. */
|
|
335
|
+
webClientId: z8.string().min(1).optional(),
|
|
336
|
+
/** Expected token audience — part of what the CLI binds cached tokens to. */
|
|
337
|
+
audience: z8.string().min(1).optional(),
|
|
338
|
+
/**
|
|
339
|
+
* Whether this deployment permits `public` (anonymous) apps. Drives the SPA's
|
|
340
|
+
* visibility UI — it hides the public option when false. Absent = forbidden
|
|
341
|
+
* (older portal / dev-token-only where this endpoint 404s — open surfaces are
|
|
342
|
+
* opt-in). Server-side enforcement is independent of this hint (portal routes
|
|
343
|
+
* + edge serving).
|
|
344
|
+
*/
|
|
345
|
+
allowPublicApps: z8.boolean().optional(),
|
|
346
|
+
/** Whether this deployment permits `password` (shared-passphrase) apps. Same shape as {@link allowPublicApps}. */
|
|
347
|
+
allowPasswordApps: z8.boolean().optional()
|
|
348
|
+
});
|
|
349
|
+
function portalApiScope(audience) {
|
|
350
|
+
if (!audience || audience.startsWith("urn:")) return null;
|
|
351
|
+
const appIdUri = audience.startsWith("api://") ? audience : `api://${audience}`;
|
|
352
|
+
return `${appIdUri.replace(/\/+$/, "")}/access`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ../shared/src/deployment.ts
|
|
356
|
+
import { z as z9 } from "zod";
|
|
357
|
+
var DeploymentConfigResponseSchema = z9.object({
|
|
358
|
+
/**
|
|
359
|
+
* Scheme + host + (non-default) port where apps are served, as reachable by
|
|
360
|
+
* this browser (architecture §4.1). The app slug is prepended as a subdomain:
|
|
361
|
+
* `https://<slug>.<host>`.
|
|
362
|
+
*/
|
|
363
|
+
appPublicBase: z9.url(),
|
|
364
|
+
/**
|
|
365
|
+
* Base of the opt-in dev gateway (dev-mode design §3), absent when it is not
|
|
366
|
+
* deployed. Unlike {@link appPublicBase} the slug goes in the *path*, not the
|
|
367
|
+
* host. Absent ⇒ dev mode is unavailable here, so the UI says so instead of
|
|
368
|
+
* printing an unreachable host.
|
|
369
|
+
*/
|
|
370
|
+
devApiBase: z9.url().optional(),
|
|
371
|
+
/**
|
|
372
|
+
* Month-to-date platform spend ceiling (USD) for the admin budget watch line.
|
|
373
|
+
* Display-only — the gateway is the choke point, so the rollup is exact, but
|
|
374
|
+
* nothing enforces this. Absent ⇒ no ceiling shown.
|
|
375
|
+
*/
|
|
376
|
+
platformMonthlyUsdCap: z9.number().positive().optional()
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// ../shared/src/scrypt.ts
|
|
380
|
+
var SCRYPT_PARAMS = {
|
|
381
|
+
/** CPU/memory cost. OWASP floor for scrypt. */
|
|
382
|
+
N: 2 ** 17,
|
|
383
|
+
/** Block size. */
|
|
384
|
+
r: 8,
|
|
385
|
+
/** Parallelization. */
|
|
386
|
+
p: 1,
|
|
387
|
+
/** 192 MiB — headroom above the ~128 MiB working set at these params. */
|
|
388
|
+
maxmem: 192 * 1024 * 1024
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
// ../shared/src/llm.ts
|
|
392
|
+
import { z as z10 } from "zod";
|
|
393
|
+
var LlmMessageSchema = z10.object({
|
|
394
|
+
role: z10.enum(["user", "assistant"]),
|
|
395
|
+
content: z10.string().min(1)
|
|
396
|
+
});
|
|
397
|
+
var LlmUsageSchema = z10.object({
|
|
398
|
+
inputTokens: z10.int().nonnegative(),
|
|
399
|
+
outputTokens: z10.int().nonnegative(),
|
|
400
|
+
cacheReadInputTokens: z10.int().nonnegative().default(0),
|
|
401
|
+
cacheCreationInputTokens: z10.int().nonnegative().default(0)
|
|
402
|
+
});
|
|
403
|
+
var LlmChatRequestSchema = z10.object({
|
|
404
|
+
model: z10.string().min(1),
|
|
405
|
+
messages: z10.array(LlmMessageSchema).min(1),
|
|
406
|
+
/** Optional system prompt; maps to the vendor's system channel. */
|
|
407
|
+
system: z10.string().optional(),
|
|
408
|
+
maxTokens: z10.int().positive().max(128e3).default(1024),
|
|
409
|
+
/** SSE streaming (default) vs a single JSON body. */
|
|
410
|
+
stream: z10.boolean().default(true)
|
|
411
|
+
});
|
|
412
|
+
var LlmChatResponseSchema = z10.object({
|
|
413
|
+
model: z10.string(),
|
|
414
|
+
content: z10.string(),
|
|
415
|
+
stopReason: z10.string(),
|
|
416
|
+
usage: LlmUsageSchema
|
|
417
|
+
});
|
|
418
|
+
var LlmStreamDeltaSchema = z10.object({ text: z10.string() });
|
|
419
|
+
var LlmStreamDoneSchema = z10.object({
|
|
420
|
+
stopReason: z10.string(),
|
|
421
|
+
usage: LlmUsageSchema
|
|
422
|
+
});
|
|
423
|
+
var LlmStreamErrorSchema = z10.object({
|
|
424
|
+
code: z10.string(),
|
|
425
|
+
message: z10.string()
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// ../shared/src/usage.ts
|
|
429
|
+
import { z as z11 } from "zod";
|
|
430
|
+
var GATEWAY_OUTCOMES = ["ok", "error", "refusal", "quota_blocked"];
|
|
431
|
+
var GatewayOutcomeSchema = z11.enum(GATEWAY_OUTCOMES);
|
|
432
|
+
var USAGE_RANGES = ["24h", "7d", "30d"];
|
|
433
|
+
var UsageRangeSchema = z11.enum(USAGE_RANGES);
|
|
434
|
+
var PLATFORM_RANGES = ["7d", "30d", "90d"];
|
|
435
|
+
var PlatformRangeSchema = z11.enum(PLATFORM_RANGES);
|
|
436
|
+
var UsageSeriesPointSchema = z11.object({
|
|
437
|
+
bucket: z11.iso.datetime(),
|
|
438
|
+
costUsd: z11.number().nonnegative(),
|
|
439
|
+
tokens: z11.int().nonnegative(),
|
|
440
|
+
requests: z11.int().nonnegative()
|
|
441
|
+
});
|
|
442
|
+
var UsageSummarySchema = z11.object({
|
|
443
|
+
appId: z11.uuid(),
|
|
444
|
+
/** The rolling range these figures cover. */
|
|
445
|
+
range: UsageRangeSchema,
|
|
446
|
+
requests: z11.int().nonnegative(),
|
|
447
|
+
inputTokens: z11.int().nonnegative(),
|
|
448
|
+
outputTokens: z11.int().nonnegative(),
|
|
449
|
+
/** Cache-aware input token totals (0 until prompt caching is enabled). */
|
|
450
|
+
cacheReadInputTokens: z11.int().nonnegative(),
|
|
451
|
+
cacheCreationInputTokens: z11.int().nonnegative(),
|
|
452
|
+
/** Estimated spend in USD over the window at current rates (./pricing.ts). */
|
|
453
|
+
costUsd: z11.number().nonnegative(),
|
|
454
|
+
/** 95th-percentile upstream latency (ms) over the window; null when no timed calls. */
|
|
455
|
+
latencyP95Ms: z11.number().nonnegative().nullable(),
|
|
456
|
+
/** Fraction of calls in the window whose outcome was not `ok` (0..1). */
|
|
457
|
+
errorRate: z11.number().min(0).max(1),
|
|
458
|
+
/** Count of calls keyed by outcome (`ok` / `error` / `refusal` / `quota_blocked`). */
|
|
459
|
+
byOutcome: z11.record(z11.string(), z11.int().nonnegative()),
|
|
460
|
+
byModel: z11.array(
|
|
461
|
+
z11.object({
|
|
462
|
+
model: z11.string(),
|
|
463
|
+
tokens: z11.int().nonnegative(),
|
|
464
|
+
requests: z11.int().nonnegative(),
|
|
465
|
+
/** Estimated spend in USD for this model over the window. */
|
|
466
|
+
costUsd: z11.number().nonnegative()
|
|
467
|
+
})
|
|
468
|
+
),
|
|
469
|
+
/** Dense, zero-filled buckets across the range, oldest-first, for the trend chart. */
|
|
470
|
+
series: z11.array(UsageSeriesPointSchema),
|
|
471
|
+
/**
|
|
472
|
+
* Today-since-midnight totals, independent of `range` — backs the daily-cap
|
|
473
|
+
* gauge (the budget the edge enforces is per calendar day).
|
|
474
|
+
*/
|
|
475
|
+
today: z11.object({
|
|
476
|
+
tokens: z11.int().nonnegative(),
|
|
477
|
+
costUsd: z11.number().nonnegative()
|
|
478
|
+
})
|
|
479
|
+
});
|
|
480
|
+
var GatewayCallSchema = z11.object({
|
|
481
|
+
id: z11.uuid(),
|
|
482
|
+
appId: z11.uuid(),
|
|
483
|
+
/** App slug at read time; null when the app row no longer exists. */
|
|
484
|
+
slug: z11.string().nullable(),
|
|
485
|
+
userOid: z11.string(),
|
|
486
|
+
capability: z11.string(),
|
|
487
|
+
model: z11.string(),
|
|
488
|
+
inputTokens: z11.int().nonnegative(),
|
|
489
|
+
outputTokens: z11.int().nonnegative(),
|
|
490
|
+
cacheReadInputTokens: z11.int().nonnegative(),
|
|
491
|
+
cacheCreationInputTokens: z11.int().nonnegative(),
|
|
492
|
+
/** Estimated spend in USD for this single call at current rates. */
|
|
493
|
+
costUsd: z11.number().nonnegative(),
|
|
494
|
+
/** Upstream round-trip latency in ms (0 when not measured). */
|
|
495
|
+
durationMs: z11.int().nonnegative(),
|
|
496
|
+
/** Upstream/egress HTTP status — set for `fetch`; null for streamed `llm`. */
|
|
497
|
+
statusCode: z11.int().nullable(),
|
|
498
|
+
/** LLM stop reason; null for non-LLM calls. */
|
|
499
|
+
stopReason: z11.string().nullable(),
|
|
500
|
+
/** Short upstream error string; null on success. */
|
|
501
|
+
errorDetail: z11.string().nullable(),
|
|
502
|
+
outcome: GatewayOutcomeSchema,
|
|
503
|
+
createdAt: z11.iso.datetime()
|
|
504
|
+
});
|
|
505
|
+
var GatewayAuditPageSchema = z11.object({
|
|
506
|
+
rows: z11.array(GatewayCallSchema),
|
|
507
|
+
/** Pass as `?before=` to fetch the next (older) page; absent when exhausted. */
|
|
508
|
+
nextBefore: z11.iso.datetime().optional()
|
|
509
|
+
});
|
|
510
|
+
var PlatformUsageSchema = z11.object({
|
|
511
|
+
/** The rolling range the series + breakdowns cover. */
|
|
512
|
+
range: PlatformRangeSchema,
|
|
513
|
+
/** Dense, zero-filled daily buckets across the range, oldest-first. */
|
|
514
|
+
series: z11.array(UsageSeriesPointSchema),
|
|
515
|
+
/** Per-app rollup over the range, busiest-first. */
|
|
516
|
+
byApp: z11.array(
|
|
517
|
+
z11.object({
|
|
518
|
+
slug: z11.string().nullable(),
|
|
519
|
+
tokens: z11.int().nonnegative(),
|
|
520
|
+
requests: z11.int().nonnegative(),
|
|
521
|
+
/** Estimated spend in USD for this app over the range. */
|
|
522
|
+
costUsd: z11.number().nonnegative()
|
|
523
|
+
})
|
|
524
|
+
),
|
|
525
|
+
/** Month-to-date headline KPIs (independent of `range`). */
|
|
526
|
+
totals: z11.object({
|
|
527
|
+
tokensMTD: z11.int().nonnegative(),
|
|
528
|
+
requestsMTD: z11.int().nonnegative(),
|
|
529
|
+
/** Estimated month-to-date spend in USD across all apps. */
|
|
530
|
+
costMTD: z11.number().nonnegative(),
|
|
531
|
+
/** Distinct `userOid`s seen month-to-date. */
|
|
532
|
+
activeUsers: z11.int().nonnegative()
|
|
533
|
+
}),
|
|
534
|
+
/** Token + cost share by capability over the range (essentially all `llm` in M4). */
|
|
535
|
+
capabilityMix: z11.array(
|
|
536
|
+
z11.object({
|
|
537
|
+
capability: z11.string(),
|
|
538
|
+
tokens: z11.int().nonnegative(),
|
|
539
|
+
/** Estimated spend in USD for this capability over the range. */
|
|
540
|
+
costUsd: z11.number().nonnegative()
|
|
541
|
+
})
|
|
542
|
+
)
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// ../shared/src/data.ts
|
|
546
|
+
import { z as z12 } from "zod";
|
|
547
|
+
var CollectionItemSchema = z12.object({
|
|
548
|
+
id: z12.uuid(),
|
|
549
|
+
collection: z12.string(),
|
|
550
|
+
/** The submitting user, if authenticated; null for anonymous/public visitors. */
|
|
551
|
+
userOid: z12.string().nullable(),
|
|
552
|
+
item: z12.unknown(),
|
|
553
|
+
/** Hashed IP / truncated UA for abuse triage; null if not captured. */
|
|
554
|
+
meta: z12.unknown().nullable(),
|
|
555
|
+
createdAt: z12.iso.datetime()
|
|
556
|
+
});
|
|
557
|
+
var CollectionItemsPageSchema = z12.object({
|
|
558
|
+
rows: z12.array(CollectionItemSchema),
|
|
559
|
+
/** Pass as `?before=` to fetch the next (older) page; absent when exhausted. */
|
|
560
|
+
nextBefore: z12.iso.datetime().optional()
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// ../shared/src/secrets.ts
|
|
564
|
+
import { z as z14 } from "zod";
|
|
565
|
+
|
|
566
|
+
// ../shared/src/env.ts
|
|
567
|
+
import { z as z13 } from "zod";
|
|
568
|
+
var ENVS = ["prod", "dev"];
|
|
569
|
+
var EnvSchema = z13.enum(ENVS);
|
|
570
|
+
|
|
571
|
+
// ../shared/src/secrets.ts
|
|
572
|
+
var InjectionRecipeSchema = z14.discriminatedUnion("kind", [
|
|
573
|
+
/** `Authorization: Bearer <secret>` — the common case. */
|
|
574
|
+
z14.object({ kind: z14.literal("header-bearer") }),
|
|
575
|
+
/** Arbitrary header; `{}` in `template` is replaced with the secret. */
|
|
576
|
+
z14.object({
|
|
577
|
+
kind: z14.literal("header"),
|
|
578
|
+
name: z14.string().min(1),
|
|
579
|
+
template: z14.string().default("{}")
|
|
580
|
+
}),
|
|
581
|
+
/** Query parameter `?<param>=<secret>`. */
|
|
582
|
+
z14.object({ kind: z14.literal("query"), param: z14.string().min(1) })
|
|
583
|
+
]);
|
|
584
|
+
var SECRET_SCOPES = ["app", "global", "platform"];
|
|
585
|
+
var SecretScopeSchema = z14.enum(SECRET_SCOPES);
|
|
586
|
+
var SecretMetadataSchema = z14.object({
|
|
587
|
+
id: z14.string(),
|
|
588
|
+
name: z14.string().min(1),
|
|
589
|
+
scope: SecretScopeSchema,
|
|
590
|
+
/** Partition tier (dev-mode §6): a dev fetch injects only `dev` connection secrets. */
|
|
591
|
+
env: EnvSchema,
|
|
592
|
+
injection: InjectionRecipeSchema,
|
|
593
|
+
createdBy: z14.string(),
|
|
594
|
+
createdAt: z14.string(),
|
|
595
|
+
rotatedAt: z14.string().nullable().optional(),
|
|
596
|
+
lastUsedAt: z14.string().nullable().optional(),
|
|
597
|
+
boundApps: z14.array(z14.string()).default([])
|
|
598
|
+
});
|
|
599
|
+
var SecretCreateRequestSchema = z14.object({
|
|
600
|
+
name: z14.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters, digits, and hyphens"),
|
|
601
|
+
value: z14.string().min(1),
|
|
602
|
+
/** Target tier (dev-mode §6). Defaults `prod`; `dev` configures a dev-tier credential. */
|
|
603
|
+
env: EnvSchema.default("prod"),
|
|
604
|
+
injection: InjectionRecipeSchema.default({ kind: "header-bearer" })
|
|
605
|
+
});
|
|
606
|
+
var SecretRotateRequestSchema = z14.object({ value: z14.string().min(1) });
|
|
607
|
+
var SecretGrantRequestSchema = z14.object({ appSlug: z14.string().min(1) });
|
|
608
|
+
|
|
609
|
+
// ../shared/src/devTokens.ts
|
|
610
|
+
import { z as z15 } from "zod";
|
|
611
|
+
function isValidDevOrigin(input) {
|
|
612
|
+
if (input.includes("*")) return false;
|
|
613
|
+
let url;
|
|
614
|
+
try {
|
|
615
|
+
url = new URL(input);
|
|
616
|
+
} catch {
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
620
|
+
if (url.pathname !== "/" || url.search !== "" || url.hash !== "") return false;
|
|
621
|
+
if (url.username !== "" || url.password !== "") return false;
|
|
622
|
+
return input === url.origin || input === `${url.origin}/`;
|
|
623
|
+
}
|
|
624
|
+
var DevOriginSchema = z15.string().refine(
|
|
625
|
+
isValidDevOrigin,
|
|
626
|
+
"must be an exact origin (scheme://host[:port]) \u2014 no path, query, or wildcard"
|
|
627
|
+
).transform((o) => new URL(o).origin);
|
|
628
|
+
var TtlDaysSchema = z15.number().int().min(1).max(365).optional();
|
|
629
|
+
var DevTokenMintRequestSchema = z15.object({
|
|
630
|
+
origins: z15.array(DevOriginSchema).min(1).max(20),
|
|
631
|
+
ttlDays: TtlDaysSchema
|
|
632
|
+
});
|
|
633
|
+
var DevTokenRotateRequestSchema = z15.object({
|
|
634
|
+
ttlDays: TtlDaysSchema
|
|
635
|
+
});
|
|
636
|
+
var DevTokenMetadataSchema = z15.object({
|
|
637
|
+
id: z15.string(),
|
|
638
|
+
developerOid: z15.string(),
|
|
639
|
+
origins: z15.array(z15.string()),
|
|
640
|
+
expiresAt: z15.string(),
|
|
641
|
+
revokedAt: z15.string().nullable().optional(),
|
|
642
|
+
createdAt: z15.string()
|
|
643
|
+
});
|
|
644
|
+
var DevTokenMintResponseSchema = z15.object({
|
|
645
|
+
token: z15.string(),
|
|
646
|
+
metadata: DevTokenMetadataSchema
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
// ../shared/src/instruction.ts
|
|
650
|
+
import { z as z16 } from "zod";
|
|
651
|
+
var INSTRUCTION_CAPABILITIES = ["fetch", "llm"];
|
|
652
|
+
var InstructionCapabilitySchema = z16.enum(INSTRUCTION_CAPABILITIES);
|
|
653
|
+
var AttestedInstructionSchema = z16.object({
|
|
654
|
+
/** App the call is attributed to (registry app id). */
|
|
655
|
+
appId: z16.string().min(1),
|
|
656
|
+
/** Authenticated user, or the anonymous sentinel on `public` apps. */
|
|
657
|
+
userOid: z16.string().min(1),
|
|
658
|
+
capability: InstructionCapabilitySchema,
|
|
659
|
+
/** The allowlisted origin the edge authorized (scheme + host + port). */
|
|
660
|
+
origin: z16.url(),
|
|
661
|
+
/** Connection (secret) name to inject, if this is a secret-backed call. */
|
|
662
|
+
connection: z16.string().min(1).optional(),
|
|
663
|
+
/** Correlates the edge audit row with the egress call. */
|
|
664
|
+
requestId: z16.string().min(1),
|
|
665
|
+
/**
|
|
666
|
+
* The HTTP method + URL pathname the edge authorized (ADR-0013 step 2, issue #6).
|
|
667
|
+
* Egress refuses a mismatched verb/resource, so a captured instruction can't be
|
|
668
|
+
* redirected to a different request on the same origin (origin is already bound
|
|
669
|
+
* above; the `jti` burn already blocks replay — this closes the residual
|
|
670
|
+
* same-origin-different-request gap). Bound to `pathname` only (not query): the
|
|
671
|
+
* app controls the query, and origin + jti already constrain the call.
|
|
672
|
+
*
|
|
673
|
+
* Optional for rollout safety (like `env`): within one instance edge+egress
|
|
674
|
+
* deploy together and instructions live 30 s, but a rolling restart may briefly
|
|
675
|
+
* verify an old-edge token that lacks these. Only the edge can sign, so absence
|
|
676
|
+
* means "old edge", not tampering — egress asserts ONLY when the claim is
|
|
677
|
+
* present. Make required once a fleet is reliably past deploy.
|
|
678
|
+
*/
|
|
679
|
+
method: z16.string().min(1).optional(),
|
|
680
|
+
path: z16.string().optional(),
|
|
681
|
+
/**
|
|
682
|
+
* Environment tier this call is scoped to (dev-mode design §6). Egress resolves
|
|
683
|
+
* the connection secret within this tier — a `dev` instruction can never reach a
|
|
684
|
+
* `prod` connection secret and vice-versa. Carried by the attested (signed)
|
|
685
|
+
* claim, never an app/request parameter; defaults `prod` so any instruction
|
|
686
|
+
* minted before this field existed verifies as production.
|
|
687
|
+
*/
|
|
688
|
+
env: EnvSchema.default("prod")
|
|
689
|
+
});
|
|
690
|
+
var INSTRUCTION_TTL_SECONDS = 30;
|
|
691
|
+
var INSTRUCTION_BURN_RETENTION_SECONDS = INSTRUCTION_TTL_SECONDS + 15;
|
|
692
|
+
|
|
693
|
+
// ../shared/src/fetch.ts
|
|
694
|
+
import { z as z17 } from "zod";
|
|
695
|
+
var FETCH_ERROR_CODES = [
|
|
696
|
+
"forbidden",
|
|
697
|
+
"rate_limited",
|
|
698
|
+
"bad_target",
|
|
699
|
+
"blocked",
|
|
700
|
+
"too_large",
|
|
701
|
+
"replay",
|
|
702
|
+
"upstream_error"
|
|
703
|
+
];
|
|
704
|
+
var FetchErrorCodeSchema = z17.enum(FETCH_ERROR_CODES);
|
|
705
|
+
var FetchProxyErrorSchema = z17.object({
|
|
706
|
+
code: FetchErrorCodeSchema,
|
|
707
|
+
message: z17.string()
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// src/client.ts
|
|
711
|
+
var CliError = class extends Error {
|
|
712
|
+
code;
|
|
713
|
+
constructor(message, code) {
|
|
714
|
+
super(message);
|
|
715
|
+
this.name = "CliError";
|
|
716
|
+
this.code = code;
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
var VersionListSchema = z18.array(VersionSchema);
|
|
720
|
+
var PortalClient = class {
|
|
721
|
+
#baseUrl;
|
|
722
|
+
#tokenProvider;
|
|
723
|
+
/** `token` may be a static string (dev token) or an async provider (OIDC). */
|
|
724
|
+
constructor(baseUrl, token) {
|
|
725
|
+
this.#baseUrl = baseUrl.replace(/\/+$/, "");
|
|
726
|
+
this.#tokenProvider = typeof token === "string" ? async () => token : token;
|
|
727
|
+
}
|
|
728
|
+
createApp(input) {
|
|
729
|
+
return this.#json(AppSchema, "POST", "/api/v1/apps", { auth: true, body: input });
|
|
730
|
+
}
|
|
731
|
+
/** Public IdP discovery info — how `helix login` finds the issuer. */
|
|
732
|
+
getAuthConfig() {
|
|
733
|
+
return this.#json(AuthConfigResponseSchema, "GET", "/api/v1/auth/config", {});
|
|
734
|
+
}
|
|
735
|
+
/** The authenticated actor, per the portal — powers `helix whoami`. */
|
|
736
|
+
me() {
|
|
737
|
+
return this.#json(PortalMeResponseSchema, "GET", "/api/v1/me", { auth: true });
|
|
738
|
+
}
|
|
739
|
+
listVersions(slug) {
|
|
740
|
+
return this.#json(VersionListSchema, "GET", `/api/v1/apps/${enc(slug)}/versions`, {
|
|
741
|
+
auth: true
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
promote(slug, number) {
|
|
745
|
+
return this.#json(AppSchema, "POST", `/api/v1/apps/${enc(slug)}/versions/${number}/promote`, {
|
|
746
|
+
auth: true
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
rollback(slug, toNumber) {
|
|
750
|
+
return this.#json(AppSchema, "POST", `/api/v1/apps/${enc(slug)}/rollback`, {
|
|
751
|
+
auth: true,
|
|
752
|
+
body: toNumber !== void 0 ? { toNumber } : {}
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
async uploadVersion(slug, zip, filename = "bundle.zip") {
|
|
756
|
+
const form = new FormData();
|
|
757
|
+
form.append("bundle", new Blob([new Uint8Array(zip)]), filename);
|
|
758
|
+
const res = await fetch(this.#url(`/api/v1/apps/${enc(slug)}/versions`), {
|
|
759
|
+
method: "POST",
|
|
760
|
+
headers: await this.#authHeaders(true),
|
|
761
|
+
body: form
|
|
762
|
+
});
|
|
763
|
+
return this.#parse(UploadVersionResponseSchema, res);
|
|
764
|
+
}
|
|
765
|
+
#url(path3) {
|
|
766
|
+
return `${this.#baseUrl}${path3}`;
|
|
767
|
+
}
|
|
768
|
+
async #authHeaders(auth) {
|
|
769
|
+
if (!auth) return {};
|
|
770
|
+
const token = await this.#tokenProvider?.();
|
|
771
|
+
if (!token) {
|
|
772
|
+
throw new CliError("not signed in; run `helix login` (or set HELIX_TOKEN / pass --token)");
|
|
773
|
+
}
|
|
774
|
+
return { authorization: `Bearer ${token}` };
|
|
775
|
+
}
|
|
776
|
+
async #json(schema, method, path3, opts) {
|
|
777
|
+
const headers = await this.#authHeaders(opts.auth ?? false);
|
|
778
|
+
if (opts.body !== void 0) headers["content-type"] = "application/json";
|
|
779
|
+
const res = await fetch(this.#url(path3), {
|
|
780
|
+
method,
|
|
781
|
+
headers,
|
|
782
|
+
body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0
|
|
783
|
+
});
|
|
784
|
+
return this.#parse(schema, res);
|
|
785
|
+
}
|
|
786
|
+
async #parse(schema, res) {
|
|
787
|
+
const text = await res.text();
|
|
788
|
+
const data = text ? JSON.parse(text) : void 0;
|
|
789
|
+
if (!res.ok) {
|
|
790
|
+
const parsed = ApiErrorSchema.safeParse(data);
|
|
791
|
+
if (parsed.success) throw new CliError(parsed.data.error.message, parsed.data.error.code);
|
|
792
|
+
throw new CliError(`request failed (HTTP ${res.status})`);
|
|
793
|
+
}
|
|
794
|
+
return schema.parse(data);
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
function enc(segment) {
|
|
798
|
+
return encodeURIComponent(segment);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// src/commands.ts
|
|
802
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
803
|
+
|
|
804
|
+
// src/zip.ts
|
|
805
|
+
import { ZipArchive } from "archiver";
|
|
806
|
+
function zipDirectory(dir) {
|
|
807
|
+
return new Promise((resolve, reject) => {
|
|
808
|
+
const chunks = [];
|
|
809
|
+
const archive = new ZipArchive({ zlib: { level: 9 } });
|
|
810
|
+
archive.on("data", (c) => chunks.push(c));
|
|
811
|
+
archive.on("error", reject);
|
|
812
|
+
archive.on("end", () => resolve(Buffer.concat(chunks)));
|
|
813
|
+
archive.directory(dir, false);
|
|
814
|
+
void archive.finalize();
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// src/auth/deviceFlow.ts
|
|
819
|
+
import * as oidc from "openid-client";
|
|
820
|
+
var DEVICE_SCOPES = "openid profile email offline_access";
|
|
821
|
+
function deviceScopes(audience) {
|
|
822
|
+
const apiScope = portalApiScope(audience);
|
|
823
|
+
return apiScope ? `${DEVICE_SCOPES} ${apiScope}` : DEVICE_SCOPES;
|
|
824
|
+
}
|
|
825
|
+
async function discover(issuer, clientId) {
|
|
826
|
+
const url = new URL(issuer);
|
|
827
|
+
return oidc.discovery(
|
|
828
|
+
url,
|
|
829
|
+
clientId,
|
|
830
|
+
void 0,
|
|
831
|
+
void 0,
|
|
832
|
+
// The local dev IdP is plain http on localhost; real issuers are https.
|
|
833
|
+
url.protocol === "http:" ? { execute: [oidc.allowInsecureRequests] } : void 0
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
function toStoredTokens(tokens, clientId) {
|
|
837
|
+
if (!tokens.access_token) throw new Error("token response carried no access token");
|
|
838
|
+
return {
|
|
839
|
+
accessToken: tokens.access_token,
|
|
840
|
+
refreshToken: tokens.refresh_token,
|
|
841
|
+
expiresAt: Date.now() + (tokens.expires_in ?? 300) * 1e3,
|
|
842
|
+
clientId
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
async function runDeviceLogin(opts) {
|
|
846
|
+
const config = await discover(opts.issuer, opts.clientId);
|
|
847
|
+
const handle = await oidc.initiateDeviceAuthorization(config, {
|
|
848
|
+
scope: deviceScopes(opts.audience)
|
|
849
|
+
});
|
|
850
|
+
opts.log("To sign in, open this URL in a browser:");
|
|
851
|
+
opts.log(` ${handle.verification_uri_complete ?? handle.verification_uri}`);
|
|
852
|
+
opts.log(`and confirm the code: ${handle.user_code}`);
|
|
853
|
+
opts.log("Waiting for approval\u2026");
|
|
854
|
+
const tokens = await oidc.pollDeviceAuthorizationGrant(config, handle);
|
|
855
|
+
return toStoredTokens(tokens, opts.clientId);
|
|
856
|
+
}
|
|
857
|
+
async function refreshGrant(issuer, clientId, refreshToken) {
|
|
858
|
+
const config = await discover(issuer, clientId);
|
|
859
|
+
const tokens = await oidc.refreshTokenGrant(config, refreshToken);
|
|
860
|
+
const stored = toStoredTokens(tokens, clientId);
|
|
861
|
+
return { ...stored, refreshToken: stored.refreshToken ?? refreshToken };
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// src/auth/tokenStore.ts
|
|
865
|
+
import { mkdir, readFile, rename, writeFile, chmod, rm } from "node:fs/promises";
|
|
866
|
+
import { randomBytes } from "node:crypto";
|
|
867
|
+
import os from "node:os";
|
|
868
|
+
import path from "node:path";
|
|
869
|
+
function portalOrigin(portalUrl) {
|
|
870
|
+
return new URL(portalUrl).origin;
|
|
871
|
+
}
|
|
872
|
+
function defaultTokenPath(env = process.env) {
|
|
873
|
+
const base = env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
874
|
+
return path.join(base, "helix", "tokens.json");
|
|
875
|
+
}
|
|
876
|
+
async function readFileTolerant(file) {
|
|
877
|
+
const empty = { version: 2, byPortal: {} };
|
|
878
|
+
try {
|
|
879
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
880
|
+
if (parsed.version !== 2 || typeof parsed.byPortal !== "object" || !parsed.byPortal) {
|
|
881
|
+
return empty;
|
|
882
|
+
}
|
|
883
|
+
return parsed;
|
|
884
|
+
} catch {
|
|
885
|
+
return empty;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
async function readTokens(key, file = defaultTokenPath()) {
|
|
889
|
+
const data = await readFileTolerant(file);
|
|
890
|
+
const entry = data.byPortal[portalOrigin(key.portalUrl)];
|
|
891
|
+
if (!entry || entry.issuer !== key.issuer) return void 0;
|
|
892
|
+
const tokens = entry.tokens;
|
|
893
|
+
if (!tokens || typeof tokens.accessToken !== "string" || typeof tokens.expiresAt !== "number") {
|
|
894
|
+
return void 0;
|
|
895
|
+
}
|
|
896
|
+
return tokens;
|
|
897
|
+
}
|
|
898
|
+
async function writeFile0600(file, content) {
|
|
899
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 448 });
|
|
900
|
+
const tmp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
|
|
901
|
+
await writeFile(tmp, content, { mode: 384 });
|
|
902
|
+
await rename(tmp, file);
|
|
903
|
+
await chmod(file, 384);
|
|
904
|
+
}
|
|
905
|
+
async function writeTokens(key, tokens, file = defaultTokenPath(), opts = {}) {
|
|
906
|
+
const data = await readFileTolerant(file);
|
|
907
|
+
data.byPortal[portalOrigin(key.portalUrl)] = {
|
|
908
|
+
issuer: key.issuer,
|
|
909
|
+
...opts.audience ? { audience: opts.audience } : {},
|
|
910
|
+
tokens
|
|
911
|
+
};
|
|
912
|
+
await writeFile0600(file, JSON.stringify(data, null, 2));
|
|
913
|
+
}
|
|
914
|
+
async function deleteTokens(portalUrl, file = defaultTokenPath()) {
|
|
915
|
+
const data = await readFileTolerant(file);
|
|
916
|
+
const origin = portalOrigin(portalUrl);
|
|
917
|
+
if (!(origin in data.byPortal)) return false;
|
|
918
|
+
delete data.byPortal[origin];
|
|
919
|
+
if (Object.keys(data.byPortal).length === 0) {
|
|
920
|
+
await rm(file, { force: true });
|
|
921
|
+
} else {
|
|
922
|
+
await writeFile0600(file, JSON.stringify(data, null, 2));
|
|
923
|
+
}
|
|
924
|
+
return true;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// src/commands.ts
|
|
928
|
+
function requireSlug(config) {
|
|
929
|
+
if (!config.slug) {
|
|
930
|
+
throw new CliError("no app slug; set it in helix.json or pass --slug");
|
|
931
|
+
}
|
|
932
|
+
return config.slug;
|
|
933
|
+
}
|
|
934
|
+
function parseVisibility(input) {
|
|
935
|
+
if (!input) return void 0;
|
|
936
|
+
if (input.startsWith("group:")) {
|
|
937
|
+
const groupId = input.slice("group:".length);
|
|
938
|
+
if (!groupId) throw new CliError("group visibility needs an id: group:<id>");
|
|
939
|
+
return { mode: "group", groupId };
|
|
940
|
+
}
|
|
941
|
+
if (input === "private" || input === "password" || input === "public") {
|
|
942
|
+
return { mode: input };
|
|
943
|
+
}
|
|
944
|
+
throw new CliError(`invalid visibility "${input}" (private | group:<id> | password | public)`);
|
|
945
|
+
}
|
|
946
|
+
function printVersion(v) {
|
|
947
|
+
console.log(` version ${v.number} (${v.status}) \u2014 ${v.id}`);
|
|
948
|
+
console.log(` assets: ${v.blobPrefix}`);
|
|
949
|
+
}
|
|
950
|
+
function printApp(app) {
|
|
951
|
+
console.log(` app ${app.slug} \u2014 live version: ${app.currentVersionId ?? "(none)"}`);
|
|
952
|
+
}
|
|
953
|
+
async function createCommand(client, config, opts) {
|
|
954
|
+
const slug = requireSlug(config);
|
|
955
|
+
const app = await client.createApp({
|
|
956
|
+
slug,
|
|
957
|
+
displayName: opts.displayName ?? slug,
|
|
958
|
+
visibility: parseVisibility(opts.visibility)
|
|
959
|
+
});
|
|
960
|
+
console.log(`Created app "${app.slug}".`);
|
|
961
|
+
printApp(app);
|
|
962
|
+
}
|
|
963
|
+
async function deployCommand(client, config, opts) {
|
|
964
|
+
const slug = requireSlug(config);
|
|
965
|
+
const zip = config.bundle ? await readFile2(config.bundle) : await zipDirectory(config.dir);
|
|
966
|
+
console.log(`Uploading bundle to "${slug}"\u2026`);
|
|
967
|
+
const { version, warnings } = await client.uploadVersion(slug, zip);
|
|
968
|
+
console.log(`Uploaded as preview:`);
|
|
969
|
+
printVersion(version);
|
|
970
|
+
if (warnings.length > 0) {
|
|
971
|
+
console.log(`
|
|
972
|
+
${warnings.length} CSP warning(s):`);
|
|
973
|
+
for (const w of warnings) console.log(` - ${w.file}: ${w.hint}`);
|
|
974
|
+
}
|
|
975
|
+
if (opts.promote) {
|
|
976
|
+
const app = await client.promote(slug, version.number);
|
|
977
|
+
console.log(`
|
|
978
|
+
Promoted version ${version.number} to live.`);
|
|
979
|
+
printApp(app);
|
|
980
|
+
} else {
|
|
981
|
+
console.log(`
|
|
982
|
+
Not live yet \u2014 promote with: helix promote ${version.number}`);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
async function versionsCommand(client, config) {
|
|
986
|
+
const slug = requireSlug(config);
|
|
987
|
+
const versions = await client.listVersions(slug);
|
|
988
|
+
if (versions.length === 0) {
|
|
989
|
+
console.log(`No versions for "${slug}".`);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
console.log(`Versions for "${slug}" (newest first):`);
|
|
993
|
+
for (const v of versions) console.log(` ${v.number} ${v.status} ${v.id}`);
|
|
994
|
+
}
|
|
995
|
+
async function promoteCommand(client, config, number) {
|
|
996
|
+
const slug = requireSlug(config);
|
|
997
|
+
const app = await client.promote(slug, number);
|
|
998
|
+
console.log(`Promoted version ${number} to live.`);
|
|
999
|
+
printApp(app);
|
|
1000
|
+
}
|
|
1001
|
+
async function rollbackCommand(client, config, toNumber) {
|
|
1002
|
+
const slug = requireSlug(config);
|
|
1003
|
+
const app = await client.rollback(slug, toNumber);
|
|
1004
|
+
console.log(
|
|
1005
|
+
toNumber ? `Rolled back to version ${toNumber}.` : `Rolled back to previous version.`
|
|
1006
|
+
);
|
|
1007
|
+
printApp(app);
|
|
1008
|
+
}
|
|
1009
|
+
async function loginCommand(client, config) {
|
|
1010
|
+
const { issuer, cliClientId, audience } = await client.getAuthConfig();
|
|
1011
|
+
const tokens = await runDeviceLogin({
|
|
1012
|
+
issuer,
|
|
1013
|
+
clientId: cliClientId,
|
|
1014
|
+
audience,
|
|
1015
|
+
log: console.log
|
|
1016
|
+
});
|
|
1017
|
+
await writeTokens({ portalUrl: config.portalUrl, issuer }, tokens, void 0, { audience });
|
|
1018
|
+
const authed = new PortalClient(config.portalUrl, tokens.accessToken);
|
|
1019
|
+
const me = await authed.me();
|
|
1020
|
+
console.log(`Logged in as ${me.name ?? me.sub} (${me.sub}).`);
|
|
1021
|
+
}
|
|
1022
|
+
async function logoutCommand(config) {
|
|
1023
|
+
const forgot = await deleteTokens(config.portalUrl);
|
|
1024
|
+
console.log(forgot ? "Logged out (local tokens forgotten)." : "Already logged out.");
|
|
1025
|
+
}
|
|
1026
|
+
async function whoamiCommand(client) {
|
|
1027
|
+
const me = await client.me();
|
|
1028
|
+
console.log(`${me.sub} (via ${me.via}${me.name ? `, ${me.name}` : ""})`);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// src/auth/session.ts
|
|
1032
|
+
var REFRESH_MARGIN_MS = 6e4;
|
|
1033
|
+
async function fetchAuthConfig(portalUrl) {
|
|
1034
|
+
const res = await fetch(`${portalUrl.replace(/\/+$/, "")}/api/v1/auth/config`);
|
|
1035
|
+
if (!res.ok) {
|
|
1036
|
+
throw new Error(
|
|
1037
|
+
`portal has no OIDC configured (GET /api/v1/auth/config \u2192 ${res.status}); use HELIX_TOKEN / --token instead`
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
return AuthConfigResponseSchema.parse(await res.json());
|
|
1041
|
+
}
|
|
1042
|
+
var defaultDeps = {
|
|
1043
|
+
getAuthConfig: fetchAuthConfig,
|
|
1044
|
+
refresh: refreshGrant,
|
|
1045
|
+
storePath: defaultTokenPath()
|
|
1046
|
+
};
|
|
1047
|
+
function makeTokenProvider(opts, deps = defaultDeps) {
|
|
1048
|
+
return async function getAccessToken() {
|
|
1049
|
+
if (opts.staticToken) return opts.staticToken;
|
|
1050
|
+
const { issuer, cliClientId } = await deps.getAuthConfig(opts.portalUrl);
|
|
1051
|
+
const key = { portalUrl: opts.portalUrl, issuer };
|
|
1052
|
+
const stored = await readTokens(key, deps.storePath);
|
|
1053
|
+
if (!stored) return void 0;
|
|
1054
|
+
if (stored.expiresAt - REFRESH_MARGIN_MS > Date.now()) {
|
|
1055
|
+
return stored.accessToken;
|
|
1056
|
+
}
|
|
1057
|
+
if (!stored.refreshToken) return void 0;
|
|
1058
|
+
try {
|
|
1059
|
+
const renewed = await deps.refresh(
|
|
1060
|
+
issuer,
|
|
1061
|
+
stored.clientId || cliClientId,
|
|
1062
|
+
stored.refreshToken
|
|
1063
|
+
);
|
|
1064
|
+
await writeTokens(key, renewed, deps.storePath);
|
|
1065
|
+
return renewed.accessToken;
|
|
1066
|
+
} catch {
|
|
1067
|
+
return void 0;
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// src/config.ts
|
|
1073
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
1074
|
+
import path2 from "node:path";
|
|
1075
|
+
var DEFAULT_PORTAL_URL = "http://localhost:3001";
|
|
1076
|
+
var DEFAULT_DIR = "dist";
|
|
1077
|
+
var CONFIG_FILENAMES = ["helix.json", "azx.json"];
|
|
1078
|
+
async function readConfigFile(cwd) {
|
|
1079
|
+
for (const name of CONFIG_FILENAMES) {
|
|
1080
|
+
try {
|
|
1081
|
+
return JSON.parse(await readFile3(path2.join(cwd, name), "utf8"));
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
if (err.code === "ENOENT") continue;
|
|
1084
|
+
throw err;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
return {};
|
|
1088
|
+
}
|
|
1089
|
+
async function resolveConfig(flags, env = process.env, cwd = process.cwd()) {
|
|
1090
|
+
const file = await readConfigFile(cwd);
|
|
1091
|
+
return {
|
|
1092
|
+
portalUrl: flags.portalUrl ?? env.HELIX_PORTAL_URL ?? env.AZX_PORTAL_URL ?? file.portalUrl ?? DEFAULT_PORTAL_URL,
|
|
1093
|
+
token: flags.token ?? env.HELIX_TOKEN ?? env.AZX_TOKEN,
|
|
1094
|
+
slug: flags.slug ?? file.slug,
|
|
1095
|
+
dir: flags.dir ?? file.dir ?? DEFAULT_DIR,
|
|
1096
|
+
bundle: flags.bundle
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// src/bin.ts
|
|
1101
|
+
var USAGE = `helix \u2014 Helix deploy CLI
|
|
1102
|
+
|
|
1103
|
+
Usage:
|
|
1104
|
+
helix login # sign in via the browser (OIDC device flow)
|
|
1105
|
+
helix logout
|
|
1106
|
+
helix whoami
|
|
1107
|
+
helix deploy [--dir <dir>] [--bundle <zip>] [--promote]
|
|
1108
|
+
helix create [--display-name <name>] [--visibility <v>]
|
|
1109
|
+
helix versions
|
|
1110
|
+
helix promote <number>
|
|
1111
|
+
helix rollback [number]
|
|
1112
|
+
|
|
1113
|
+
Common flags: --slug <slug> --portal-url <url> --token <token>
|
|
1114
|
+
Env: HELIX_PORTAL_URL, HELIX_TOKEN (static token \u2014 skips login; CI/scripts).
|
|
1115
|
+
Config file: helix.json { slug, portalUrl, dir }
|
|
1116
|
+
Visibility: private | group:<id> | password | public
|
|
1117
|
+
`;
|
|
1118
|
+
async function main() {
|
|
1119
|
+
const { values, positionals } = parseCliArgs(process.argv.slice(2));
|
|
1120
|
+
const command = positionals[0];
|
|
1121
|
+
if (!command || values.help) {
|
|
1122
|
+
console.log(USAGE);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
const config = await resolveConfig({
|
|
1126
|
+
slug: values.slug,
|
|
1127
|
+
portalUrl: values["portal-url"],
|
|
1128
|
+
dir: values.dir,
|
|
1129
|
+
bundle: values.bundle,
|
|
1130
|
+
token: values.token
|
|
1131
|
+
});
|
|
1132
|
+
const client = new PortalClient(
|
|
1133
|
+
config.portalUrl,
|
|
1134
|
+
makeTokenProvider({ portalUrl: config.portalUrl, staticToken: config.token })
|
|
1135
|
+
);
|
|
1136
|
+
switch (command) {
|
|
1137
|
+
case "login":
|
|
1138
|
+
await loginCommand(client, config);
|
|
1139
|
+
break;
|
|
1140
|
+
case "logout":
|
|
1141
|
+
await logoutCommand(config);
|
|
1142
|
+
break;
|
|
1143
|
+
case "whoami":
|
|
1144
|
+
await whoamiCommand(client);
|
|
1145
|
+
break;
|
|
1146
|
+
case "deploy":
|
|
1147
|
+
await deployCommand(client, config, { promote: values.promote });
|
|
1148
|
+
break;
|
|
1149
|
+
case "create":
|
|
1150
|
+
await createCommand(client, config, {
|
|
1151
|
+
displayName: values["display-name"],
|
|
1152
|
+
visibility: values.visibility
|
|
1153
|
+
});
|
|
1154
|
+
break;
|
|
1155
|
+
case "versions":
|
|
1156
|
+
await versionsCommand(client, config);
|
|
1157
|
+
break;
|
|
1158
|
+
case "promote": {
|
|
1159
|
+
const number = Number(positionals[1]);
|
|
1160
|
+
if (!Number.isInteger(number)) throw new CliError("usage: helix promote <number>");
|
|
1161
|
+
await promoteCommand(client, config, number);
|
|
1162
|
+
break;
|
|
1163
|
+
}
|
|
1164
|
+
case "rollback": {
|
|
1165
|
+
const number = positionals[1] !== void 0 ? Number(positionals[1]) : void 0;
|
|
1166
|
+
if (number !== void 0 && !Number.isInteger(number)) {
|
|
1167
|
+
throw new CliError("usage: helix rollback [number]");
|
|
1168
|
+
}
|
|
1169
|
+
await rollbackCommand(client, config, number);
|
|
1170
|
+
break;
|
|
1171
|
+
}
|
|
1172
|
+
default:
|
|
1173
|
+
throw new CliError(`unknown command "${command}"
|
|
1174
|
+
|
|
1175
|
+
${USAGE}`);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
main().catch((err) => {
|
|
1179
|
+
if (err instanceof CliError) {
|
|
1180
|
+
console.error(`error: ${err.message}${err.code ? ` (${err.code})` : ""}`);
|
|
1181
|
+
} else {
|
|
1182
|
+
console.error(err);
|
|
1183
|
+
}
|
|
1184
|
+
process.exitCode = 1;
|
|
1185
|
+
});
|