@unbound-app/cli 0.7.1 → 0.7.2

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.
Files changed (2) hide show
  1. package/dist/index.mjs +267 -12
  2. package/package.json +2 -6
package/dist/index.mjs CHANGED
@@ -1,21 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { cli, command } from "cleye";
3
3
  import { z } from "zod";
4
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, watch, writeFileSync } from "node:fs";
4
+ import { existsSync, mkdirSync, openAsBlob, readFileSync, readdirSync, rmSync, watch, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
6
6
  import { ControllerClient } from "@unbound-app/debugger-protocol/controller";
7
7
  import { CONTROLLER_ENDPOINT_MARKER, DEFAULT_BRIDGE_PORT } from "@unbound-app/debugger-protocol";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
- import { API_KEY_HEADER, ApiRequestError, CLI_CLIENT_ID, createApiClient } from "@unbound-app/web/client";
11
- import toError from "@unbound-app/utils/to-error";
12
10
  import { glob } from "node:fs/promises";
13
11
  import { spawn, spawnSync } from "node:child_process";
14
- import { addonManifestSchema } from "@unbound-app/contracts/manifest";
15
- import { publishArtifact } from "@unbound-app/web/publish";
16
- import { apiUrlSchema } from "@unbound-app/contracts/api";
17
12
  import { homedir } from "node:os";
18
13
  import { cancel, confirm, intro, isCancel, note, outro, select, text } from "@clack/prompts";
14
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
19
15
  import { deviceAuthorizationClient } from "better-auth/client/plugins";
20
16
  import { createAuthClient } from "better-auth/client";
21
17
  import pc from "picocolors";
@@ -259,6 +255,111 @@ function toMcpTool(server, definition, context) {
259
255
  });
260
256
  }
261
257
  //#endregion
258
+ //#region ../contracts/src/errors.ts
259
+ const apiErrorCodeSchema = z.enum([
260
+ "invalid_request",
261
+ "invalid_manifest",
262
+ "unauthorized",
263
+ "forbidden",
264
+ "not_found",
265
+ "rate_limited",
266
+ "addon_id_taken",
267
+ "not_maintainer",
268
+ "version_exists",
269
+ "pending_update_exists",
270
+ "pending_listing_cap",
271
+ "account_too_new",
272
+ "artifact_too_large",
273
+ "upload_missing",
274
+ "upload_expired",
275
+ "upload_size_mismatch",
276
+ "submission_not_awaiting_upload",
277
+ "submission_not_pending",
278
+ "self_review",
279
+ "artifact_mismatch",
280
+ "last_owner",
281
+ "github_not_linked",
282
+ "github_no_push_access",
283
+ "internal_error"
284
+ ]);
285
+ /** The body of every non-2xx JSON response from `/api/v1/*`. */
286
+ const apiErrorResponseSchema = z.object({ error: z.object({
287
+ code: apiErrorCodeSchema,
288
+ message: z.string()
289
+ }) });
290
+ //#endregion
291
+ //#region ../../apps/web/src/api/client.ts
292
+ /** The header `ubd publish` sends an API key in, for CI publishing. */
293
+ const API_KEY_HEADER = "x-api-key";
294
+ /** A non-2xx response from the API, carrying the contract error code. */
295
+ var ApiRequestError = class extends Error {
296
+ code;
297
+ status;
298
+ constructor(code, status, message) {
299
+ super(message);
300
+ this.code = code;
301
+ this.status = status;
302
+ this.name = "ApiRequestError";
303
+ }
304
+ };
305
+ function buildUrl(baseUrl, path, params, query) {
306
+ const url = new URL(buildPath(path, params), baseUrl);
307
+ for (const [name, value] of Object.entries(query ?? {})) {
308
+ if (value === void 0) continue;
309
+ url.searchParams.set(name, value);
310
+ }
311
+ return url;
312
+ }
313
+ function buildPath(path, params) {
314
+ return path.replaceAll(/\$(\w+)/g, (_, name) => {
315
+ const value = params?.[name];
316
+ if (value === void 0) throw new Error(`Missing path parameter ${name} for ${path}.`);
317
+ return encodeURIComponent(value);
318
+ });
319
+ }
320
+ async function toRequestError(response) {
321
+ const parsed = apiErrorResponseSchema.safeParse(await response.json().catch(() => null));
322
+ if (!parsed.success) return new ApiRequestError("internal_error", response.status, `Unexpected ${response.status} response without an error body.`);
323
+ return new ApiRequestError(parsed.data.error.code, response.status, parsed.data.error.message);
324
+ }
325
+ /**
326
+ * @description Creates a typed client for the Unbound web API. Routes, bodies and responses are
327
+ * checked against the API map exported by `@unbound-app/web/api`; responses are parsed with the
328
+ * route's contract schema and `{ error: { code, message } }` bodies are thrown as
329
+ * {@link ApiRequestError}.
330
+ * @template Api The API map, `Api` from `@unbound-app/web/api`.
331
+ * @param options The API origin and headers sent with every request (e.g. credentials).
332
+ * @returns A `request(route, options)` function.
333
+ */
334
+ function createApiClient(options) {
335
+ return async function request(route, init) {
336
+ const separator = route.indexOf(" ");
337
+ const method = route.slice(0, separator);
338
+ const path = route.slice(separator + 1);
339
+ const headers = new Headers(options.headers);
340
+ for (const [name, value] of new Headers(init.headers)) headers.set(name, value);
341
+ if (init.body !== void 0) headers.set("content-type", "application/json");
342
+ const url = buildUrl(options.baseUrl, path, init.params, init.query);
343
+ const response = await fetch(url, {
344
+ method,
345
+ headers,
346
+ body: init.body === void 0 ? void 0 : JSON.stringify(init.body)
347
+ });
348
+ if (!response.ok) throw await toRequestError(response);
349
+ return init.response.parse(await response.json());
350
+ };
351
+ }
352
+ //#endregion
353
+ //#region ../utils/src/to-error.ts
354
+ /**
355
+ * @description Normalises a thrown value into an `Error`, wrapping anything that is not one.
356
+ * @param error The thrown value.
357
+ * @returns The value itself when it is an `Error`, otherwise an `Error` carrying its string form.
358
+ */
359
+ function toError(error) {
360
+ return error instanceof Error ? error : new Error(String(error));
361
+ }
362
+ //#endregion
262
363
  //#region src/lib/discovery.ts
263
364
  /**
264
365
  * @description Discovers every addon reachable through the config's `addons` globs. A directory is an
@@ -341,6 +442,155 @@ function buildAddon(addon, buildCommand) {
341
442
  });
342
443
  }
343
444
  //#endregion
445
+ //#region ../types/typings/native.ts
446
+ const NATIVE_PLUGIN_CAPABILITIES = [
447
+ "native.objc.classes",
448
+ "native.objc.invoke",
449
+ "native.objc.ivars",
450
+ "native.objc.associations",
451
+ "native.objc.hooks",
452
+ "native.ffi.symbols",
453
+ "native.ffi.call",
454
+ "native.fabric.mount"
455
+ ];
456
+ //#endregion
457
+ //#region ../types/typings/addons.ts
458
+ /** The kinds of addon a manifest can declare. */
459
+ const ADDON_TYPES = [
460
+ "plugin",
461
+ "theme",
462
+ "icon-pack",
463
+ "font"
464
+ ];
465
+ //#endregion
466
+ //#region ../contracts/src/manifest.ts
467
+ /**
468
+ * A strict SemVer 2.0.0 version (the specification's own pattern): no `v` prefix, no leading zeros.
469
+ * Versions become object-storage path segments and are ordered by the server, so loose forms are refused.
470
+ */
471
+ const semverSchema = z.string().max(64).regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/, "Must be a semver version.");
472
+ /**
473
+ * Addon ids become object-storage path segments (`addons/{id}/{version}/...`), so they are limited to
474
+ * path-safe characters: alphanumeric runs joined by single `.`, `_` or `-`.
475
+ */
476
+ const addonIdSchema = z.string().max(100).regex(/^[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$/, "Must be alphanumeric segments joined by \".\", \"_\" or \"-\".");
477
+ const addonAuthorSchema = z.object({
478
+ name: z.string().min(1),
479
+ id: z.templateLiteral([z.int().nonnegative()])
480
+ });
481
+ /**
482
+ * The manifest an author publishes. Unknown keys pass through untouched so the stored manifest is
483
+ * exactly what the author shipped; only the fields the platform relies on are validated.
484
+ */
485
+ const addonManifestSchema = z.looseObject({
486
+ id: addonIdSchema,
487
+ type: z.enum(ADDON_TYPES).optional(),
488
+ name: z.string().min(1),
489
+ description: z.string(),
490
+ authors: z.array(addonAuthorSchema).min(1),
491
+ icon: z.union([z.string(), z.object({ uri: z.string() })]).optional(),
492
+ main: z.string().regex(/^(?:[\w-][\w.-]*\/)*[\w-][\w.-]*$/, "Must be a relative path inside the addon."),
493
+ version: semverSchema,
494
+ capabilities: z.array(z.enum(NATIVE_PLUGIN_CAPABILITIES)).optional(),
495
+ minNativePluginApi: z.string().optional(),
496
+ unbound: z.object({ min: semverSchema.optional() }).optional()
497
+ });
498
+ //#endregion
499
+ //#region ../contracts/src/publish.ts
500
+ /** `listing` claims a new addon id; `update` publishes a new version of an addon the caller maintains. */
501
+ const submissionKindSchema = z.enum(["listing", "update"]);
502
+ const submissionStatusSchema = z.enum([
503
+ "awaiting_upload",
504
+ "pending_review",
505
+ "changes_requested",
506
+ "approved",
507
+ "rejected",
508
+ "superseded",
509
+ "expired",
510
+ "cancelled"
511
+ ]);
512
+ submissionStatusSchema.extract([
513
+ "awaiting_upload",
514
+ "pending_review",
515
+ "changes_requested"
516
+ ]);
517
+ /** A GitHub repository as `owner/name`. */
518
+ const repositorySchema = z.string().regex(/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/, "Must be a GitHub owner/name.");
519
+ /** The statuses a reviewer's decision moves a pending submission to. */
520
+ const reviewDecisionSchema = submissionStatusSchema.extract([
521
+ "approved",
522
+ "rejected",
523
+ "changes_requested"
524
+ ]);
525
+ const submissionReviewSchema = z.object({
526
+ decision: reviewDecisionSchema,
527
+ /** The rejection reason or requested changes; optional only on approval. */
528
+ note: z.string().nullable(),
529
+ reviewedAt: z.iso.datetime()
530
+ });
531
+ const submissionSchema = z.object({
532
+ id: z.string().min(1),
533
+ addonId: addonIdSchema,
534
+ version: z.string(),
535
+ kind: submissionKindSchema,
536
+ status: submissionStatusSchema,
537
+ repository: repositorySchema.nullable(),
538
+ /** No repository was named, so reviewers cannot tie the artifact to source the author controls. */
539
+ unverifiedSource: z.boolean(),
540
+ /** The reviewer's decision, once one was made. */
541
+ review: submissionReviewSchema.nullable()
542
+ });
543
+ z.object({
544
+ manifest: addonManifestSchema,
545
+ bundleSize: z.int().positive(),
546
+ repository: repositorySchema.optional(),
547
+ changelog: z.string().max(1e4).optional()
548
+ });
549
+ /** A presigned upload for the bundle; send exactly `bundleSize` bytes with `headers` before `expiresAt`. */
550
+ const submissionUploadSchema = z.object({
551
+ url: z.url(),
552
+ method: z.literal("PUT"),
553
+ headers: z.record(z.string(), z.string()),
554
+ expiresAt: z.iso.datetime()
555
+ });
556
+ const createSubmissionResponseSchema = z.object({
557
+ submission: submissionSchema,
558
+ upload: submissionUploadSchema
559
+ });
560
+ z.object({});
561
+ const finalizeSubmissionResponseSchema = z.object({ submission: submissionSchema });
562
+ //#endregion
563
+ //#region ../../apps/web/src/api/publish.ts
564
+ /**
565
+ * @description Publishes a built addon for review: opens a submission (the server refuses it, before
566
+ * anything is uploaded, when the bundle is over its size limit or a spam gate applies), uploads the
567
+ * bundle to the presigned URL and finalizes the submission.
568
+ * @param request The typed API client, authenticated as the publisher.
569
+ * @param artifact The manifest, bundle and submission details.
570
+ * @returns The submission, pending review.
571
+ */
572
+ async function publishArtifact(request, { manifest, bundle, repository, changelog }) {
573
+ const { submission, upload } = await request("POST /api/v1/submissions", {
574
+ body: {
575
+ manifest,
576
+ bundleSize: bundle.size,
577
+ repository,
578
+ changelog
579
+ },
580
+ response: createSubmissionResponseSchema
581
+ });
582
+ const uploaded = await fetch(upload.url, {
583
+ method: upload.method,
584
+ headers: upload.headers,
585
+ body: bundle
586
+ });
587
+ if (!uploaded.ok) throw new Error(`Uploading the bundle of submission ${submission.id} failed with ${uploaded.status}: ${await uploaded.text()}`);
588
+ return (await request("POST /api/v1/submissions/$id/finalize", {
589
+ params: { id: submission.id },
590
+ response: finalizeSubmissionResponseSchema
591
+ })).submission;
592
+ }
593
+ //#endregion
344
594
  //#region src/lib/bundle-name.ts
345
595
  /**
346
596
  * @description Names an addon's bundle inside its output directory. A static addon ships the file
@@ -353,6 +603,10 @@ function getBundleName(addon, main) {
353
603
  return addon.static ? main : basename(main).replace(/\.\w+$/, ".js");
354
604
  }
355
605
  //#endregion
606
+ //#region ../contracts/src/api.ts
607
+ /** The origin of the Unbound web API, reduced from any http(s) URL; production when unset. */
608
+ const apiUrlSchema = z.url({ protocol: /^https?$/ }).transform((url) => new URL(url).origin).default("https://unbound.rip");
609
+ //#endregion
356
610
  //#region src/lib/env.ts
357
611
  const schema$1 = z.object({
358
612
  /** The origin of the Unbound web API that `ubd login` and `ubd publish` talk to. */
@@ -461,7 +715,7 @@ function readArtifact(addon) {
461
715
  ...parsed.data,
462
716
  main
463
717
  },
464
- bundle: Bun.file(join(addon.output, main))
718
+ bundlePath: join(addon.output, main)
465
719
  };
466
720
  }
467
721
  /**
@@ -472,8 +726,9 @@ function readArtifact(addon) {
472
726
  * @returns The submission, pending review.
473
727
  */
474
728
  async function publish({ env, addon, repository, changelog }) {
475
- const { manifest, bundle } = readArtifact(addon);
476
- if (!await bundle.exists()) throw new Error(`Built bundle not found at ${bundle.name}. Build ${addon.id} first.`);
729
+ const { manifest, bundlePath } = readArtifact(addon);
730
+ if (!existsSync(bundlePath)) throw new Error(`Built bundle not found at ${bundlePath}. Build ${addon.id} first.`);
731
+ const bundle = await openAsBlob(bundlePath);
477
732
  return publishArtifact(createClient(env), {
478
733
  manifest,
479
734
  bundle,
@@ -973,7 +1228,7 @@ async function login({ env, delay, prompt }) {
973
1228
  baseURL: env.UNBOUND_API_URL,
974
1229
  plugins: [deviceAuthorizationClient()]
975
1230
  });
976
- const code = await auth.device.code({ client_id: CLI_CLIENT_ID });
1231
+ const code = await auth.device.code({ client_id: "ubd" });
977
1232
  if (code.error) throw new Error(`Could not start the login: ${code.error.error_description} (${code.error.error}).`);
978
1233
  prompt({
979
1234
  userCode: code.data.user_code,
@@ -986,7 +1241,7 @@ async function login({ env, delay, prompt }) {
986
1241
  const token = await auth.device.token({
987
1242
  grant_type: DEVICE_CODE_GRANT,
988
1243
  device_code: code.data.device_code,
989
- client_id: CLI_CLIENT_ID
1244
+ client_id: "ubd"
990
1245
  });
991
1246
  if (token.data) {
992
1247
  const path = getCredentialsPath(env);
@@ -1025,7 +1280,7 @@ const loginCommand = {
1025
1280
  ok: true,
1026
1281
  text: `Logged in. Credentials stored in ${await login({
1027
1282
  env,
1028
- delay: Bun.sleep,
1283
+ delay: setTimeout$1,
1029
1284
  prompt: printCode
1030
1285
  })}.`
1031
1286
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unbound-app/cli",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "The `ubd` command line for the Unbound client: evaluate on-device code and serve the debugger as an MCP.",
5
5
  "author": {
6
6
  "name": "Mario P.",
@@ -28,11 +28,7 @@
28
28
  "dependencies": {
29
29
  "@clack/prompts": "^1.7.0",
30
30
  "@modelcontextprotocol/sdk": "^1.29.0",
31
- "@unbound-app/contracts": "0.7.1",
32
- "@unbound-app/debugger-protocol": "0.7.0",
33
- "@unbound-app/types": "0.7.0",
34
- "@unbound-app/utils": "0.7.0",
35
- "@unbound-app/web": "0.7.1",
31
+ "@unbound-app/debugger-protocol": "0.7.2",
36
32
  "better-auth": "1.7.10",
37
33
  "cleye": "^1.3.4",
38
34
  "picocolors": "^1.1.1",