@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.
- package/dist/index.mjs +267 -12
- 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
|
-
|
|
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,
|
|
476
|
-
if (!
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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/
|
|
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",
|