wirl 0.2.1 → 0.2.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 (3) hide show
  1. package/README.md +1 -1
  2. package/bin.js +1229 -172
  3. package/package.json +1 -1
package/bin.js CHANGED
@@ -15,10 +15,14 @@ var __export = (target, all) => {
15
15
  };
16
16
 
17
17
  // packages/core/src/types.ts
18
- var AppKind;
18
+ var AppRole, AppKind;
19
19
  var init_types = __esm({
20
20
  "packages/core/src/types.ts"() {
21
21
  "use strict";
22
+ AppRole = {
23
+ VIEWER: "viewer",
24
+ EDITOR: "editor"
25
+ };
22
26
  AppKind = {
23
27
  STATIC: "static",
24
28
  SERVICE: "service",
@@ -189,9 +193,33 @@ function validateSlug(input) {
189
193
  function isWellFormedSlug(input) {
190
194
  return typeof input === "string" && input.length >= SLUG_MIN && input.length <= SLUG_MAX && SLUG_RE.test(input) && !input.includes(HOST_SEPARATOR);
191
195
  }
192
- function slugify(input) {
193
- const base = input.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, SLUG_MAX).replace(/-$/, "");
194
- return isValidSlug(base) ? base : null;
196
+ function slugBase(input) {
197
+ return input.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, SLUG_MAX).replace(/-$/, "");
198
+ }
199
+ function previewSlugFor(raw) {
200
+ const base = slugBase(raw);
201
+ switch (validateSlug(base).problem) {
202
+ case void 0:
203
+ return { ok: true, slug: base };
204
+ case "reserved":
205
+ return {
206
+ ok: false,
207
+ problem: "reserved",
208
+ message: `The preview name "${base}" is reserved. The reserved preview names are: ${[...RESERVED_SLUGS].sort().join(", ")}. Pick another name.`
209
+ };
210
+ case "too_short":
211
+ return {
212
+ ok: false,
213
+ problem: "too_short",
214
+ message: `That name is too short: it makes a preview name of one character, and a preview name needs at least ${String(SLUG_MIN)}.`
215
+ };
216
+ default:
217
+ return {
218
+ ok: false,
219
+ problem: "no_characters",
220
+ message: "That name has no usable characters: a preview name is made of the letters a to z and the digits 0 to 9 in what you give it, and none are left."
221
+ };
222
+ }
195
223
  }
196
224
  function appLabel(orgSlug, appSlug) {
197
225
  return `${orgSlug}${HOST_SEPARATOR}${appSlug}`;
@@ -384,13 +412,14 @@ async function readSigned(key, token) {
384
412
  }
385
413
  return { ok: true, claims };
386
414
  }
387
- var encoder, decoder2, PREFIX, SKEW_SECONDS;
415
+ var encoder, decoder2, PREFIX, GRANT_TTL_DEFAULT_SECONDS, SKEW_SECONDS;
388
416
  var init_token = __esm({
389
417
  "packages/core/src/token.ts"() {
390
418
  "use strict";
391
419
  encoder = new TextEncoder();
392
420
  decoder2 = new TextDecoder();
393
421
  PREFIX = "w1";
422
+ GRANT_TTL_DEFAULT_SECONDS = 12 * 60;
394
423
  SKEW_SECONDS = 60;
395
424
  }
396
425
  });
@@ -569,10 +598,19 @@ var init_egress = __esm({
569
598
  PinnedFetchError = class extends Error {
570
599
  reason;
571
600
  host;
572
- constructor(reason, host, message) {
601
+ /**
602
+ * Set only on a timeout that ended an answer already under way, whose body
603
+ * the fetch bounds itself (the broker's streams): which bound it was, the
604
+ * time since the headers or the time with nothing moving, and its length in
605
+ * milliseconds. The broker reads it to say what it cut a stream for.
606
+ * Undefined on every other failure.
607
+ */
608
+ streamBound;
609
+ constructor(reason, host, message, streamBound) {
573
610
  super(message);
574
611
  this.reason = reason;
575
612
  this.host = host;
613
+ this.streamBound = streamBound;
576
614
  }
577
615
  };
578
616
  PRIVATE_V4 = /^(0\.|10\.|127\.|169\.254\.|192\.0\.0\.|192\.168\.|198\.1[89]\.|172\.(1[6-9]|2[0-9]|3[01])\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|(22[4-9]|23[0-9]|24[0-9]|25[0-5])\.)/;
@@ -2301,6 +2339,9 @@ var init_migrations = __esm({
2301
2339
  leaseMs: 6e5,
2302
2340
  orgApplies: 2,
2303
2341
  // per org: the limit one tenant's applies meet
2342
+ // Per app, its live database and its previews' together, inside the org's
2343
+ // two, so one app's deployers can never take both (OIM-927).
2344
+ appApplies: 1,
2304
2345
  // Per control-plane process. The lease holds no pooled connection, so this
2305
2346
  // bounds the process's work, not its pool; at `orgApplies` two each,
2306
2347
  // filling it takes eight orgs (T5b-6).
@@ -3299,12 +3340,50 @@ var init_runtime = __esm({
3299
3340
  });
3300
3341
 
3301
3342
  // packages/core/src/broker-stream.ts
3343
+ function mediaTypeOf(headers) {
3344
+ const declared = headers.get("content-type");
3345
+ if (declared === null) return null;
3346
+ return (declared.split(";")[0] ?? "").trim().toLowerCase();
3347
+ }
3302
3348
  function isStreamedAnswer(status, headers) {
3303
3349
  if (status < 200 || status > 299) return false;
3304
- const declared = headers.get("content-type");
3305
- if (declared === null) return false;
3306
- const media = (declared.split(";")[0] ?? "").trim().toLowerCase();
3307
- return STREAMED_MEDIA_TYPES.includes(media);
3350
+ const media = mediaTypeOf(headers);
3351
+ return media !== null && STREAMED_MEDIA_TYPES.includes(media);
3352
+ }
3353
+ function isEventStream(headers) {
3354
+ return mediaTypeOf(headers) === "text/event-stream";
3355
+ }
3356
+ function closesWithEvent(headers) {
3357
+ if (!isEventStream(headers)) return false;
3358
+ const encoding = headers.get("content-encoding");
3359
+ return encoding === null || encoding.trim().toLowerCase() === "identity";
3360
+ }
3361
+ function lengthOf(ms) {
3362
+ const unit = (count, name) => `${count} ${name}${count === 1 ? "" : "s"}`;
3363
+ if (ms % 6e4 === 0) return unit(ms / 6e4, "minute");
3364
+ if (ms % 1e3 === 0) return unit(ms / 1e3, "second");
3365
+ return `${ms} ms`;
3366
+ }
3367
+ function sayCut(cut2) {
3368
+ switch (cut2.cause) {
3369
+ case "size":
3370
+ return `it reached wirl's limit of ${cut2.bytes % MIB === 0 ? `${cut2.bytes / MIB} MiB` : `${cut2.bytes} bytes`} for one answer.`;
3371
+ case "time":
3372
+ return `it was still open ${lengthOf(cut2.ms)} after the vendor began answering, the longest wirl allows.`;
3373
+ case "idle":
3374
+ return `nothing moved on it for ${lengthOf(cut2.ms)}, the longest wirl allows.`;
3375
+ case "held":
3376
+ return `it was still open ${lengthOf(cut2.ms)} after wirl began passing it on, the longest wirl allows.`;
3377
+ }
3378
+ }
3379
+ function streamCutEvent(cut2) {
3380
+ const data = JSON.stringify({ type: "error", error: { type: "api_error", message: `wirl cut this stream: ${sayCut(cut2)}` } });
3381
+ return new TextEncoder().encode(`
3382
+
3383
+ event: error
3384
+ data: ${data}
3385
+
3386
+ `);
3308
3387
  }
3309
3388
  function createStreamSlots(perApp = BROKER_LIMITS.openStreamsPerApp) {
3310
3389
  const held = /* @__PURE__ */ new Map();
@@ -3329,7 +3408,7 @@ function createStreamSlots(perApp = BROKER_LIMITS.openStreamsPerApp) {
3329
3408
  }
3330
3409
  };
3331
3410
  }
3332
- var BROKER_LIMITS, STREAMED_MEDIA_TYPES;
3411
+ var BROKER_LIMITS, STREAMED_MEDIA_TYPES, CUT_EVENT_RESERVE_BYTES, MIB;
3333
3412
  var init_broker_stream = __esm({
3334
3413
  "packages/core/src/broker-stream.ts"() {
3335
3414
  "use strict";
@@ -3346,20 +3425,369 @@ var init_broker_stream = __esm({
3346
3425
  streamIdleMs: 4 * 6e4,
3347
3426
  /** Bytes in any one answer, streamed or read whole. */
3348
3427
  answerBytes: 25 * 1024 * 1024,
3428
+ /**
3429
+ * Bytes in the body of any one call an app makes. The body is read whole
3430
+ * before the call is sent, and before its grant is looked up, so a body
3431
+ * past this never opens a key and never reaches a vendor (OIM-931).
3432
+ * 100 MiB: Cloudflare publishes 100 MB as the request body its Free and
3433
+ * Pro plans take, and a deployed app's call is first held whole by the
3434
+ * egress Worker, in an isolate Cloudflare gives 128 MB of memory. Nothing
3435
+ * else bounds a caller that holds a broker token and reaches the control
3436
+ * plane itself: Railway sets no size on a body, only five minutes to send it.
3437
+ */
3438
+ requestBytes: 100 * 1024 * 1024,
3439
+ /**
3440
+ * Bytes of call bodies one app's calls hold at once, its previews' with
3441
+ * them (OIM-951): counted as each chunk arrives, from a call's first byte
3442
+ * until the broker has answered it (broker-bodies.ts). At least
3443
+ * `requestBytes`, so one call at the cap always fits an app that holds
3444
+ * nothing else; a quarter of `heldBodyBytes`, so four apps of four owners
3445
+ * at their share fill the whole (one owner's apps cannot: `ownerHeldBodyBytes`).
3446
+ */
3447
+ appHeldBodyBytes: 128 * 1024 * 1024,
3448
+ /**
3449
+ * Bytes of call bodies the calls of every app one owner has hold at once,
3450
+ * their previews' with them (OIM-967): the person `apps.owner_user_id`
3451
+ * names, whichever organisations their apps are in. Apps and
3452
+ * organisations cost nothing to create (no cap on either), so a share per
3453
+ * app let one person with four apps fill the whole, and one per
3454
+ * organisation would let them do it with four organisations; a person
3455
+ * costs a sign-in. Two apps at their share, so one app that runs
3456
+ * away still leaves its owner's other apps room, and half of
3457
+ * `heldBodyBytes`, so one owner can never take more than half, and a
3458
+ * second sign-in is what it takes to fill it. At least `requestBytes`, so
3459
+ * one call at the cap always fits an owner whose apps hold nothing else.
3460
+ */
3461
+ ownerHeldBodyBytes: 256 * 1024 * 1024,
3462
+ /**
3463
+ * Bytes of call bodies every app's calls hold at once, in this process.
3464
+ * A call's reader starts at 16 KiB and doubles its buffer as the body
3465
+ * arrives, so the buffers behind this hold at most twice it, 1 GiB, and
3466
+ * 16 KiB a call beside (the ruling in OIM-951).
3467
+ */
3468
+ heldBodyBytes: 512 * 1024 * 1024,
3469
+ /** The `Retry-After` of a call refused for either. */
3470
+ heldBodyRetryAfterSeconds: 10,
3471
+ /**
3472
+ * How long a call's body may send nothing before its read is stopped:
3473
+ * an upload's (OIM-935). A deployed app's body comes from the egress
3474
+ * Worker, which holds it whole before it sends it, and `wirl dev` reads it
3475
+ * whole first, so no ordinary sender ever pauses (fix round 1, R1-F2).
3476
+ */
3477
+ heldBodyStallMs: 3e4,
3478
+ /**
3479
+ * How long a call's body may take to arrive whole: 100 MiB in 120 seconds
3480
+ * is about 7 Mbit/s, which a body between Cloudflare and the control plane
3481
+ * passes many times over, and well under Node's 300 seconds a request,
3482
+ * after which the body would be cut anyway (fix round 1, R1-F2).
3483
+ */
3484
+ heldBodyArrivalMs: 12e4,
3485
+ /**
3486
+ * Bytes of answers read whole that one app's calls hold at once, its
3487
+ * previews' with them (OIM-966): counted as each chunk arrives from the
3488
+ * vendor, from an answer's first byte until wirl's server has taken the
3489
+ * last of it, or the app has gone (broker-bodies.ts). A vendor's event
3490
+ * stream is not counted: it is passed on as the app reads it, and holds
3491
+ * about 285 to 441 KiB at a time, measured end to end through the control
3492
+ * plane's server (`passOn` in broker.ts; OIM-966 fix round 1, R1-F3,
3493
+ * R2-F2). More than two `answerBytes`, so two answers at the cap fit an
3494
+ * app that holds nothing else; a quarter of `heldAnswerBytes`, so four apps
3495
+ * of four owners at their share fill the whole (one owner's apps cannot:
3496
+ * `ownerHeldAnswerBytes`).
3497
+ */
3498
+ appHeldAnswerBytes: 64 * 1024 * 1024,
3499
+ /**
3500
+ * Bytes of answers read whole that the calls of every app one owner has
3501
+ * hold at once, their previews' with them (OIM-967), for the reasons
3502
+ * `ownerHeldBodyBytes` gives: two apps at their share, half of
3503
+ * `heldAnswerBytes`, and more than two `answerBytes` and than the room a
3504
+ * call needs, so an owner whose apps hold nothing else can always send.
3505
+ */
3506
+ ownerHeldAnswerBytes: 128 * 1024 * 1024,
3507
+ /**
3508
+ * Bytes of answers read whole that every app's calls hold at once, in this
3509
+ * process. An answer's reader doubles its buffer as it fills, as a body's
3510
+ * does, and never past `answerBytes`, so while answers arrive the buffers
3511
+ * behind this hold at most twice it, 512 MiB, and 16 KiB an answer beside;
3512
+ * an answer read whole is copied to exactly its size before it is handed
3513
+ * on, so from then its count is its memory (the ruling in OIM-966, and its
3514
+ * fix round 1, R1-F2).
3515
+ */
3516
+ heldAnswerBytes: 256 * 1024 * 1024,
3517
+ /**
3518
+ * The room an app's share of answers, and the whole, must each have left
3519
+ * for a call to be sent at all: one answer at the cap. With less, the call
3520
+ * is refused with a 503 before it leaves wirl, rather than run at its
3521
+ * vendor and its answer cut (OIM-966 fix round 1, R1-F1).
3522
+ */
3523
+ heldAnswerRoomBytes: 25 * 1024 * 1024,
3524
+ /** The `Retry-After` of a call refused for that room, before it was sent. */
3525
+ heldAnswerRetryAfterSeconds: 10,
3526
+ /**
3527
+ * How long an answer read whole may wait for wirl's server to take the
3528
+ * last of it once it is handed back: past this it is cut, and its count
3529
+ * goes back, so no app holds its share, or the whole, by not reading.
3530
+ * Under Cloudflare's measured 125 seconds with nothing moving, which ends
3531
+ * a deployed app's unread answer anyway (OIM-966 fix round 1, R1-F1).
3532
+ */
3533
+ heldAnswerHandOutMs: 12e4,
3349
3534
  /**
3350
3535
  * Streamed answers one app may have open at once. Each holds a vendor
3351
3536
  * socket and an inbound connection on one control-plane instance for up
3352
3537
  * to fourteen minutes, so one app cannot be allowed an unbounded number.
3353
3538
  * Counted in memory, per instance, like the control plane's other limits.
3354
3539
  */
3355
- openStreamsPerApp: 20
3540
+ openStreamsPerApp: 20,
3541
+ /**
3542
+ * How long an event stream wirl cut waits for its closing event to leave
3543
+ * the server before it ends the stream with its error anyway (OIM-837 fix
3544
+ * round 1). The event is on its way when the server's queue of what it has
3545
+ * to write has emptied; a reader that is slow but reading empties a full
3546
+ * kernel buffer, a few hundred kilobytes, well inside this. Past it the
3547
+ * reader is not reading, and the stream ends as it always did; the write
3548
+ * stall that holds a connection longer than this is OIM-968's.
3549
+ */
3550
+ cutEventSentMs: 5e3
3356
3551
  };
3357
3552
  STREAMED_MEDIA_TYPES = Object.freeze(["text/event-stream", "application/x-ndjson"]);
3553
+ CUT_EVENT_RESERVE_BYTES = 512;
3554
+ MIB = 1024 * 1024;
3555
+ }
3556
+ });
3557
+
3558
+ // packages/core/src/refusals.ts
3559
+ function sayAtMostEvery(log, now, everyMs = SAY_EVERY_MS) {
3560
+ let since = 0;
3561
+ let saidAt = Number.NEGATIVE_INFINITY;
3562
+ return (line) => {
3563
+ since += 1;
3564
+ const at2 = now();
3565
+ if (at2 - saidAt < everyMs) return;
3566
+ const text = line(since, at2);
3567
+ since = 0;
3568
+ saidAt = at2;
3569
+ try {
3570
+ log(text);
3571
+ } catch {
3572
+ }
3573
+ };
3574
+ }
3575
+ var SAY_EVERY_MS, MiB, sizeOf;
3576
+ var init_refusals = __esm({
3577
+ "packages/core/src/refusals.ts"() {
3578
+ "use strict";
3579
+ SAY_EVERY_MS = 3e4;
3580
+ MiB = 1024 * 1024;
3581
+ sizeOf = (bytes) => bytes >= MiB ? `${String(Math.floor(bytes / MiB))} MiB` : `${String(Math.ceil(bytes / 1024))} KiB`;
3582
+ }
3583
+ });
3584
+
3585
+ // packages/core/src/wait-clock.ts
3586
+ var systemWaitClock;
3587
+ var init_wait_clock = __esm({
3588
+ "packages/core/src/wait-clock.ts"() {
3589
+ "use strict";
3590
+ systemWaitClock = {
3591
+ wait(ms) {
3592
+ let timer;
3593
+ const done = new Promise((resolve7) => {
3594
+ timer = setTimeout(resolve7, ms);
3595
+ timer.unref?.();
3596
+ });
3597
+ return { done, cancel: () => clearTimeout(timer) };
3598
+ }
3599
+ };
3600
+ }
3601
+ });
3602
+
3603
+ // packages/core/src/broker-bodies.ts
3604
+ function heldBodyRefusal(limits, why) {
3605
+ const again = `Send it again in ${String(limits.heldBodyRetryAfterSeconds)} seconds.`;
3606
+ if (why === "app") {
3607
+ return {
3608
+ error: "app_bodies_full",
3609
+ message: `This call's body would take the bodies this app's calls hold at once past ${sizeOf(limits.appHeldBodyBytes)}, the most one app's calls may hold, so wirl stopped the call there and sent nothing to the vendor. ${again}`
3610
+ };
3611
+ }
3612
+ if (why === "owner") {
3613
+ return {
3614
+ error: "owner_bodies_full",
3615
+ message: `This call's body would take the bodies held at once for the calls of all the apps this app's owner has past ${sizeOf(limits.ownerHeldBodyBytes)}, the most one owner's apps may hold together, so wirl stopped the call there and sent nothing to the vendor. ${again}`
3616
+ };
3617
+ }
3618
+ return {
3619
+ error: "bodies_full",
3620
+ message: `This call's body would take the bodies wirl holds for all apps' calls at once past ${sizeOf(limits.heldBodyBytes)}, the most it holds, so wirl stopped the call there and sent nothing to the vendor. ${again}`
3621
+ };
3622
+ }
3623
+ function heldBodyCut(limits, why) {
3624
+ if (why === "stalled") {
3625
+ return {
3626
+ error: "body_stalled",
3627
+ message: `Nothing of this call's body arrived for ${String(limits.heldBodyStallMs / 1e3)} seconds, so wirl stopped the call there and sent nothing to the vendor. Send it again.`
3628
+ };
3629
+ }
3630
+ return {
3631
+ error: "body_too_slow",
3632
+ message: `This call's body had not all arrived after ${String(limits.heldBodyArrivalMs / 1e3)} seconds, the longest wirl waits for one, so wirl stopped the call there and sent nothing to the vendor. Send it again.`
3633
+ };
3634
+ }
3635
+ function heldAnswerRefusal(limits, why) {
3636
+ const acted = "The vendor may have acted on the call: send it again only if repeating it is safe.";
3637
+ if (why === "app") {
3638
+ return {
3639
+ error: "app_answers_full",
3640
+ message: `The vendor received this call and answered it, but its answer would take the answers this app's calls hold at once past ${sizeOf(limits.appHeldAnswerBytes)}, the most one app's calls may hold, so wirl stopped reading the answer there and passed none of it on. ${acted}`
3641
+ };
3642
+ }
3643
+ if (why === "owner") {
3644
+ return {
3645
+ error: "owner_answers_full",
3646
+ message: `The vendor received this call and answered it, but its answer would take the answers held at once for the calls of all the apps this app's owner has past ${sizeOf(limits.ownerHeldAnswerBytes)}, the most one owner's apps may hold together, so wirl stopped reading the answer there and passed none of it on. ${acted}`
3647
+ };
3648
+ }
3649
+ return {
3650
+ error: "answers_full",
3651
+ message: `The vendor received this call and answered it, but its answer would take the answers wirl holds for all apps' calls at once past ${sizeOf(limits.heldAnswerBytes)}, the most it holds, so wirl stopped reading the answer there and passed none of it on. ${acted}`
3652
+ };
3653
+ }
3654
+ function heldAnswerNoRoom(limits, why) {
3655
+ const again = `Send it again in ${String(limits.heldAnswerRetryAfterSeconds)} seconds.`;
3656
+ if (why === "app") {
3657
+ return {
3658
+ error: "app_no_answer_room",
3659
+ message: `The answers this app's calls hold at once leave less than ${sizeOf(limits.heldAnswerRoomBytes)} of the ${sizeOf(limits.appHeldAnswerBytes)} one app's calls may hold, the room wirl keeps for one more answer, so wirl did not send this call, and nothing of it reached the vendor. ${again}`
3660
+ };
3661
+ }
3662
+ if (why === "owner") {
3663
+ return {
3664
+ error: "owner_no_answer_room",
3665
+ message: `The answers held at once for the calls of all the apps this app's owner has leave less than ${sizeOf(limits.heldAnswerRoomBytes)} of the ${sizeOf(limits.ownerHeldAnswerBytes)} one owner's apps may hold together, the room wirl keeps for one more answer, so wirl did not send this call, and nothing of it reached the vendor. ${again}`
3666
+ };
3667
+ }
3668
+ return {
3669
+ error: "no_answer_room",
3670
+ message: `The answers wirl holds for all apps' calls at once leave less than ${sizeOf(limits.heldAnswerRoomBytes)} of the ${sizeOf(limits.heldAnswerBytes)} it holds, the room it keeps for one more answer, so wirl did not send this call, and nothing of it reached the vendor. ${again}`
3671
+ };
3672
+ }
3673
+ function createHeldBodies(options = {}) {
3674
+ const limits = options.limits ?? {
3675
+ appHeldBodyBytes: BROKER_LIMITS.appHeldBodyBytes,
3676
+ ownerHeldBodyBytes: BROKER_LIMITS.ownerHeldBodyBytes,
3677
+ heldBodyBytes: BROKER_LIMITS.heldBodyBytes,
3678
+ heldBodyRetryAfterSeconds: BROKER_LIMITS.heldBodyRetryAfterSeconds,
3679
+ heldBodyStallMs: BROKER_LIMITS.heldBodyStallMs,
3680
+ heldBodyArrivalMs: BROKER_LIMITS.heldBodyArrivalMs
3681
+ };
3682
+ return {
3683
+ limits,
3684
+ clock: options.clock ?? systemWaitClock,
3685
+ ...countHeld("bodies", limits.appHeldBodyBytes, limits.ownerHeldBodyBytes, limits.heldBodyBytes, options)
3686
+ };
3687
+ }
3688
+ function createHeldAnswers(options = {}) {
3689
+ const limits = options.limits ?? {
3690
+ appHeldAnswerBytes: BROKER_LIMITS.appHeldAnswerBytes,
3691
+ ownerHeldAnswerBytes: BROKER_LIMITS.ownerHeldAnswerBytes,
3692
+ heldAnswerBytes: BROKER_LIMITS.heldAnswerBytes,
3693
+ heldAnswerRoomBytes: BROKER_LIMITS.heldAnswerRoomBytes,
3694
+ heldAnswerRetryAfterSeconds: BROKER_LIMITS.heldAnswerRetryAfterSeconds,
3695
+ heldAnswerHandOutMs: BROKER_LIMITS.heldAnswerHandOutMs
3696
+ };
3697
+ const { short, ...count } = countHeld("answers", limits.appHeldAnswerBytes, limits.ownerHeldAnswerBytes, limits.heldAnswerBytes, options);
3698
+ return {
3699
+ limits,
3700
+ clock: options.clock ?? systemWaitClock,
3701
+ ...count,
3702
+ roomFor: (appId, ownerId) => short(appId, ownerId, limits.heldAnswerRoomBytes)
3703
+ };
3704
+ }
3705
+ function countHeld(what, appBytes, ownerBytes, allBytes, options) {
3706
+ const say = options.log === void 0 ? null : sayAtMostEvery(options.log, options.now ?? Date.now);
3707
+ const byApp = /* @__PURE__ */ new Map();
3708
+ const byOwner = /* @__PURE__ */ new Map();
3709
+ let total = 0;
3710
+ const named = (owner) => typeof owner === "string" && owner.length > 0 ? owner : null;
3711
+ const bump = (table, key, bytes) => {
3712
+ const next = (table.get(key) ?? 0) + bytes;
3713
+ if (next === 0) table.delete(key);
3714
+ else table.set(key, next);
3715
+ };
3716
+ const add = (appId, ownerId, bytes) => {
3717
+ bump(byApp, appId, bytes);
3718
+ if (ownerId !== null) bump(byOwner, ownerId, bytes);
3719
+ total += bytes;
3720
+ };
3721
+ const passes = (appId, ownerId, bytes) => (byApp.get(appId) ?? 0) + bytes > appBytes ? "app" : ownerId !== null && (byOwner.get(ownerId) ?? 0) + bytes > ownerBytes ? "owner" : total + bytes > allBytes ? "all" : null;
3722
+ const refused2 = (why, appId, ownerId) => {
3723
+ say?.((since) => {
3724
+ let most = ["nobody", 0];
3725
+ for (const entry of byApp) if (entry[1] > most[1]) most = entry;
3726
+ const who = why === "owner" && ownerId !== null ? `, owned by ${ownerId} (whose apps hold ${String(byOwner.get(ownerId) ?? 0)} bytes; ${String(byOwner.size)} ${byOwner.size === 1 ? "owner holds" : "owners hold"} any)` : "";
3727
+ return `wirl: refused ${String(since)} ${since === 1 ? "broker call" : "broker calls"} for the ${what} held at once since this line was last written (at most once every 30 seconds), the latest for ${why === "app" ? "its app's share" : why === "owner" ? "its owner's share" : "the whole"}, from ${appId}${who}; ${String(total)} of ${String(allBytes)} bytes held, by ${String(byApp.size)} ${byApp.size === 1 ? "app" : "apps"}, the most by ${most[0]} (${String(most[1])} bytes)`;
3728
+ });
3729
+ };
3730
+ return {
3731
+ open(appId, owner) {
3732
+ const ownerId = named(owner);
3733
+ let counted = 0;
3734
+ let refusal = null;
3735
+ let done = false;
3736
+ return {
3737
+ arrive(bytes) {
3738
+ if (done || !isSize(bytes)) return false;
3739
+ const why = passes(appId, ownerId, bytes);
3740
+ if (why !== null) {
3741
+ refusal = why;
3742
+ refused2(why, appId, ownerId);
3743
+ return false;
3744
+ }
3745
+ if (bytes > 0) add(appId, ownerId, bytes);
3746
+ counted += bytes;
3747
+ return true;
3748
+ },
3749
+ refusal: () => refusal,
3750
+ release() {
3751
+ if (done) return;
3752
+ done = true;
3753
+ if (counted > 0) add(appId, ownerId, -counted);
3754
+ counted = 0;
3755
+ }
3756
+ };
3757
+ },
3758
+ held: () => total,
3759
+ heldBy: (appId) => byApp.get(appId) ?? 0,
3760
+ heldByOwner: (ownerId) => byOwner.get(ownerId) ?? 0,
3761
+ short(appId, owner, bytes) {
3762
+ const ownerId = named(owner);
3763
+ const why = passes(appId, ownerId, bytes);
3764
+ if (why !== null) refused2(why, appId, ownerId);
3765
+ return why;
3766
+ }
3767
+ };
3768
+ }
3769
+ var isSize;
3770
+ var init_broker_bodies = __esm({
3771
+ "packages/core/src/broker-bodies.ts"() {
3772
+ "use strict";
3773
+ init_broker_stream();
3774
+ init_refusals();
3775
+ init_wait_clock();
3776
+ isSize = (bytes) => Number.isSafeInteger(bytes) && bytes >= 0;
3358
3777
  }
3359
3778
  });
3360
3779
 
3361
3780
  // packages/core/src/broker.ts
3362
3781
  async function handleBrokerRequest(request, deps) {
3782
+ const holding = { body: null, answer: null };
3783
+ try {
3784
+ return await brokerCall(request, deps, holding);
3785
+ } finally {
3786
+ holding.body?.release();
3787
+ holding.answer?.release();
3788
+ }
3789
+ }
3790
+ async function brokerCall(request, deps, holding) {
3363
3791
  const url = new URL(request.url);
3364
3792
  const clock = deps.now ? { now: deps.now() } : {};
3365
3793
  const bearer = readBearer(request);
@@ -3404,6 +3832,55 @@ async function handleBrokerRequest(request, deps) {
3404
3832
  origin
3405
3833
  });
3406
3834
  };
3835
+ const hasBody = request.method !== "GET" && request.method !== "HEAD";
3836
+ const bodyLimit = deps.maxRequestBytes ?? BROKER_LIMITS.requestBytes;
3837
+ const bodyDeclared = hasBody ? declaredLength(request.headers) : null;
3838
+ const declaredTooLarge = bodyDeclared !== null && bodyDeclared > bodyLimit;
3839
+ const ownerId = request.method === "HEAD" || declaredTooLarge ? null : await ownerOfApp(deps, appId);
3840
+ let requestBody = null;
3841
+ if (hasBody) {
3842
+ const budget = deps.heldBodies;
3843
+ const held = budget?.open(appId, ownerId) ?? null;
3844
+ holding.body = held;
3845
+ const bodyRead = declaredTooLarge ? { outcome: "too_large" } : await readWithin(
3846
+ request.body,
3847
+ bodyLimit,
3848
+ budget === void 0 || held === null ? void 0 : {
3849
+ arrive: (bytes) => held.arrive(bytes),
3850
+ time: { clock: budget.clock, stallMs: budget.limits.heldBodyStallMs, arrivalMs: budget.limits.heldBodyArrivalMs }
3851
+ }
3852
+ );
3853
+ if (bodyRead.outcome !== "ok") {
3854
+ const named = route.slug === RESOLVE_BY_HOST ? request.headers.get("x-wirl-target") : null;
3855
+ if (route.slug === RESOLVE_BY_HOST) slug = "";
3856
+ if (named) path = egressAuditPath(named);
3857
+ const host2 = named ? safeHost(named) : "";
3858
+ held?.release();
3859
+ if (bodyRead.outcome === "too_large") {
3860
+ await record("body_too_large", 413, host2);
3861
+ return refuse2(
3862
+ 413,
3863
+ "body_too_large",
3864
+ `This call's body is more than ${bodyLimit} bytes, the most a call may send, so wirl sent nothing.`
3865
+ );
3866
+ }
3867
+ if (bodyRead.outcome === "full") {
3868
+ const limits = budget?.limits ?? BROKER_LIMITS;
3869
+ const refusal = heldBodyRefusal(limits, held?.refusal() ?? "all");
3870
+ await record(refusal.error, 503, host2);
3871
+ return refuse2(503, refusal.error, refusal.message, { "retry-after": String(limits.heldBodyRetryAfterSeconds) });
3872
+ }
3873
+ if (bodyRead.outcome === "stalled" || bodyRead.outcome === "slow") {
3874
+ const cut2 = heldBodyCut(budget?.limits ?? BROKER_LIMITS, bodyRead.outcome);
3875
+ await record(cut2.error, 408, host2);
3876
+ return refuse2(408, cut2.error, cut2.message);
3877
+ }
3878
+ if (request.signal.aborted) return callerLeft(record, host2);
3879
+ await record("upstream_error", 502, host2);
3880
+ return refuse2(502, "upstream_error", bodyRead.error instanceof Error ? bodyRead.error.message : String(bodyRead.error));
3881
+ }
3882
+ requestBody = bodyRead.bytes;
3883
+ }
3407
3884
  const connections = await deps.connectionsFor(appId, deploymentId);
3408
3885
  let connection;
3409
3886
  if (route.slug === RESOLVE_BY_HOST) {
@@ -3492,11 +3969,18 @@ async function handleBrokerRequest(request, deps) {
3492
3969
  headers.set(name, value);
3493
3970
  });
3494
3971
  attachCredential(headers, connection);
3972
+ const answers = deps.heldAnswers;
3973
+ if (answers !== void 0 && request.method !== "HEAD") {
3974
+ const short = answers.roomFor(appId, ownerId);
3975
+ if (short !== null) {
3976
+ const noRoom = heldAnswerNoRoom(answers.limits, short);
3977
+ await record(noRoom.error, 503, host);
3978
+ return refuse2(503, noRoom.error, noRoom.message, { "retry-after": String(answers.limits.heldAnswerRetryAfterSeconds) });
3979
+ }
3980
+ }
3495
3981
  const doFetch = deps.fetchImpl ?? fetch;
3496
- const hasBody = request.method !== "GET" && request.method !== "HEAD";
3497
3982
  let upstream;
3498
3983
  try {
3499
- const requestBody = hasBody ? await request.arrayBuffer() : null;
3500
3984
  if (pastFlowDeadline(origin, deps)) return deadlinePassed();
3501
3985
  upstream = await doFetch(decision.ok ? decision.url : target, {
3502
3986
  method: request.method,
@@ -3536,16 +4020,30 @@ async function handleBrokerRequest(request, deps) {
3536
4020
  return refuse2(502, "too_large", `The upstream announced ${declared} bytes, more than the ${limit} a call may return.`);
3537
4021
  }
3538
4022
  if (upstreamBody !== null && isStreamedAnswer(upstream.status, upstream.headers)) {
4023
+ const body2 = holding.body;
4024
+ holding.body = null;
3539
4025
  return streamAnswer(upstream, upstreamBody, {
3540
4026
  appId,
3541
4027
  limit,
3542
4028
  slots: deps.streamSlots ?? null,
3543
4029
  safetyMs: deps.streamSafetyMs ?? BROKER_LIMITS.headersMs + BROKER_LIMITS.streamBodyMs,
4030
+ closing: closesWithEvent(upstream.headers),
4031
+ sent: deps.streamSent ?? null,
4032
+ sentMs: deps.streamSentMs ?? BROKER_LIMITS.cutEventSentMs,
3544
4033
  signal: request.signal,
3545
- record: (outcome, status) => record(outcome, status, safeHost(target))
4034
+ record: (outcome, status) => record(outcome, status, safeHost(target)),
4035
+ held: body2
3546
4036
  });
3547
4037
  }
3548
- const read = await readWithin(upstreamBody, limit);
4038
+ const heldAnswer = answers?.open(appId, ownerId) ?? null;
4039
+ holding.answer = heldAnswer;
4040
+ const read = heldAnswer === null ? await readWithin(upstreamBody, limit) : await readWithin(upstreamBody, limit, { arrive: (bytes) => heldAnswer.arrive(bytes) });
4041
+ if (read.outcome !== "ok") heldAnswer?.release();
4042
+ if (read.outcome === "full") {
4043
+ const refusal = heldAnswerRefusal(answers?.limits ?? BROKER_LIMITS, heldAnswer?.refusal() ?? "all");
4044
+ await record(refusal.error, 502, safeHost(target));
4045
+ return refuse2(502, refusal.error, refusal.message);
4046
+ }
3549
4047
  if (read.outcome === "error") {
3550
4048
  if (request.signal.aborted) return callerLeft(record, safeHost(target));
3551
4049
  await record("upstream_error", 502, safeHost(target));
@@ -3560,7 +4058,76 @@ async function handleBrokerRequest(request, deps) {
3560
4058
  const out2 = answerHeaders(upstream.headers);
3561
4059
  const empty = upstream.status === 204 || upstream.status === 304;
3562
4060
  if (!empty) out2.set("content-length", String(body.byteLength));
3563
- return new Response(empty ? null : body, { status: upstream.status, headers: out2 });
4061
+ if (answers === void 0 || heldAnswer === null || empty || body.byteLength === 0) {
4062
+ return new Response(empty ? null : body, { status: upstream.status, headers: out2 });
4063
+ }
4064
+ const exact = body.byteLength < body.buffer.byteLength ? body.slice() : body;
4065
+ const handed = handOut(exact, heldAnswer, request.signal, answers.clock, answers.limits.heldAnswerHandOutMs);
4066
+ let answer;
4067
+ try {
4068
+ answer = new Response(handed, { status: upstream.status, headers: out2 });
4069
+ } catch (error) {
4070
+ await handed.cancel().catch(() => {
4071
+ });
4072
+ throw error;
4073
+ }
4074
+ holding.answer = null;
4075
+ return answer;
4076
+ }
4077
+ async function ownerOfApp(deps, appId) {
4078
+ if (deps.ownerOf === void 0 || deps.heldBodies === void 0 && deps.heldAnswers === void 0) return null;
4079
+ const owner = await deps.ownerOf(appId);
4080
+ return typeof owner === "string" && owner.length > 0 ? owner : null;
4081
+ }
4082
+ function handOut(bytes, held, signal, clock, handOutMs) {
4083
+ let left = bytes;
4084
+ let stopWait = () => {
4085
+ };
4086
+ let appLeft = () => {
4087
+ };
4088
+ const finish = () => {
4089
+ if (left === null) return false;
4090
+ left = null;
4091
+ stopWait();
4092
+ signal.removeEventListener("abort", appLeft);
4093
+ held.release();
4094
+ return true;
4095
+ };
4096
+ return new ReadableStream(
4097
+ {
4098
+ start(controller) {
4099
+ const wait = clock.wait(handOutMs);
4100
+ stopWait = () => wait.cancel();
4101
+ void wait.done.then(() => {
4102
+ if (!finish()) return;
4103
+ const cut2 = new Error(
4104
+ `The answer was not all taken within ${String(handOutMs / 1e3)} seconds of being handed back, the longest wirl holds one, and was cut there.`
4105
+ );
4106
+ cut2.stack = `Error: ${cut2.message}`;
4107
+ controller.error(cut2);
4108
+ });
4109
+ appLeft = () => {
4110
+ if (finish()) controller.close();
4111
+ };
4112
+ if (signal.aborted) appLeft();
4113
+ else signal.addEventListener("abort", appLeft, { once: true });
4114
+ },
4115
+ pull(controller) {
4116
+ if (left === null) return;
4117
+ const slice = left.slice(0, HAND_OUT_BYTES);
4118
+ left = left.subarray(slice.byteLength);
4119
+ controller.enqueue(slice);
4120
+ if (left.byteLength > 0) return;
4121
+ finish();
4122
+ controller.close();
4123
+ },
4124
+ cancel() {
4125
+ finish();
4126
+ }
4127
+ },
4128
+ // Nothing is cut ahead of the app: the server's pace is the app's.
4129
+ { highWaterMark: 0 }
4130
+ );
3564
4131
  }
3565
4132
  function egressAuditPath(target) {
3566
4133
  let pathname;
@@ -3614,13 +4181,14 @@ function declaredLength(headers) {
3614
4181
  return Number(raw.trim());
3615
4182
  }
3616
4183
  async function streamAnswer(upstream, body, context) {
3617
- let release = () => {
3618
- };
4184
+ const giveBack = () => context.held?.release();
4185
+ let release = giveBack;
3619
4186
  if (context.slots !== null) {
3620
4187
  const slot = context.slots.acquire(context.appId);
3621
4188
  if (slot === null) {
3622
4189
  await body.cancel().catch(() => {
3623
4190
  });
4191
+ giveBack();
3624
4192
  await context.record("busy", 429);
3625
4193
  return refuse2(
3626
4194
  429,
@@ -3628,25 +4196,60 @@ async function streamAnswer(upstream, body, context) {
3628
4196
  `This app already has ${context.slots.perApp} streamed answers open, the most it may hold at once; try again when one of them has finished.`
3629
4197
  );
3630
4198
  }
3631
- release = slot;
4199
+ release = () => {
4200
+ slot();
4201
+ giveBack();
4202
+ };
3632
4203
  }
3633
4204
  try {
3634
4205
  await context.record("allowed", upstream.status);
3635
4206
  } catch (error) {
4207
+ await body.cancel().catch(() => {
4208
+ });
3636
4209
  release();
4210
+ throw error;
4211
+ }
4212
+ try {
4213
+ return new Response(passOn(body, context.limit, release, context.safetyMs, context.signal, context.closing, context.sent, context.sentMs), {
4214
+ status: upstream.status,
4215
+ headers: answerHeaders(upstream.headers)
4216
+ });
4217
+ } catch (error) {
3637
4218
  await body.cancel().catch(() => {
3638
4219
  });
4220
+ release();
3639
4221
  throw error;
3640
4222
  }
3641
- return new Response(passOn(body, context.limit, release, context.safetyMs, context.signal), {
3642
- status: upstream.status,
3643
- headers: answerHeaders(upstream.headers)
3644
- });
3645
4223
  }
3646
- function passOn(body, limit, release, safetyMs, signal) {
4224
+ async function eventSent(sent, ms) {
4225
+ if (sent === null) {
4226
+ await new Promise((resolve7) => setTimeout(resolve7, 0));
4227
+ return;
4228
+ }
4229
+ const stop = new AbortController();
4230
+ let bound;
4231
+ try {
4232
+ await Promise.race([
4233
+ sent(stop.signal),
4234
+ new Promise((resolve7) => {
4235
+ bound = setTimeout(resolve7, ms);
4236
+ bound.unref?.();
4237
+ })
4238
+ ]);
4239
+ } catch {
4240
+ } finally {
4241
+ clearTimeout(bound);
4242
+ stop.abort();
4243
+ }
4244
+ }
4245
+ function passOn(body, limit, release, safetyMs, signal, closing, sent, sentMs) {
3647
4246
  const reader = body.getReader();
4247
+ const says = closing && limit > CUT_EVENT_RESERVE_BYTES;
4248
+ const room = says ? limit - CUT_EVENT_RESERVE_BYTES : limit;
3648
4249
  let total = 0;
3649
4250
  let over = false;
4251
+ let parting = null;
4252
+ let unread;
3650
4253
  let safety;
3651
4254
  let appLeft = () => {
3652
4255
  };
@@ -3658,17 +4261,40 @@ function passOn(body, limit, release, safetyMs, signal) {
3658
4261
  release();
3659
4262
  return true;
3660
4263
  };
3661
- const cut2 = (controller, error) => {
4264
+ const cut2 = (controller, error, why) => {
3662
4265
  if (!finish()) return;
3663
- controller.error(error);
3664
4266
  reader.cancel(error).catch(() => {
3665
4267
  });
4268
+ if (!says) {
4269
+ controller.error(error);
4270
+ return;
4271
+ }
4272
+ try {
4273
+ controller.enqueue(streamCutEvent(why));
4274
+ } catch {
4275
+ controller.error(error);
4276
+ return;
4277
+ }
4278
+ parting = error;
4279
+ unread = setTimeout(() => {
4280
+ if (parting === null) return;
4281
+ parting = null;
4282
+ try {
4283
+ controller.error(error);
4284
+ } catch {
4285
+ }
4286
+ }, sentMs);
4287
+ unread.unref?.();
3666
4288
  };
3667
4289
  return new ReadableStream(
3668
4290
  {
3669
4291
  start(controller) {
3670
4292
  const timer = setTimeout(() => {
3671
- cut2(controller, new Error(`The upstream's answer was still open after ${safetyMs} ms, the longest the broker holds one, and was cut there.`));
4293
+ cut2(
4294
+ controller,
4295
+ new Error(`The upstream's answer was still open after ${safetyMs} ms, the longest the broker holds one, and was cut there.`),
4296
+ { cause: "held", ms: safetyMs }
4297
+ );
3672
4298
  }, safetyMs);
3673
4299
  timer.unref?.();
3674
4300
  safety = timer;
@@ -3682,11 +4308,21 @@ function passOn(body, limit, release, safetyMs, signal) {
3682
4308
  else signal.addEventListener("abort", appLeft, { once: true });
3683
4309
  },
3684
4310
  async pull(controller) {
4311
+ if (parting !== null) {
4312
+ const error = parting;
4313
+ parting = null;
4314
+ clearTimeout(unread);
4315
+ await eventSent(sent, sentMs);
4316
+ controller.error(error);
4317
+ return;
4318
+ }
3685
4319
  let next;
3686
4320
  try {
3687
4321
  next = await reader.read();
3688
4322
  } catch (error) {
3689
- if (finish()) controller.error(error);
4323
+ const bound = error instanceof PinnedFetchError ? error.streamBound : void 0;
4324
+ if (bound !== void 0) cut2(controller, error, { cause: bound.kind, ms: bound.ms });
4325
+ else if (finish()) controller.error(error);
3690
4326
  return;
3691
4327
  }
3692
4328
  if (over) return;
@@ -3696,14 +4332,21 @@ function passOn(body, limit, release, safetyMs, signal) {
3696
4332
  return;
3697
4333
  }
3698
4334
  total += next.value.byteLength;
3699
- if (total > limit) {
3700
- cut2(controller, new Error(`The upstream's answer passed ${limit} bytes, the most a call may return, and was cut there.`));
4335
+ if (total > room) {
4336
+ cut2(
4337
+ controller,
4338
+ new Error(
4339
+ says ? `The upstream's answer reached the ${limit} bytes a call may return, counting the event that closes it, and was cut there.` : `The upstream's answer passed ${limit} bytes, the most a call may return, and was cut there.`
4340
+ ),
4341
+ { cause: "size", bytes: limit }
4342
+ );
3701
4343
  return;
3702
4344
  }
3703
4345
  controller.enqueue(next.value);
3704
4346
  },
3705
4347
  cancel(reason) {
3706
4348
  finish();
4349
+ clearTimeout(unread);
3707
4350
  return reader.cancel(reason).catch(() => {
3708
4351
  });
3709
4352
  }
@@ -3712,37 +4355,62 @@ function passOn(body, limit, release, safetyMs, signal) {
3712
4355
  { highWaterMark: 0 }
3713
4356
  );
3714
4357
  }
3715
- async function readWithin(body, limit) {
3716
- if (body === null) return { outcome: "ok", bytes: new ArrayBuffer(0) };
3717
- const reader = body.getReader();
3718
- const chunks = [];
4358
+ async function readWithin(body, limit, bounds) {
4359
+ if (body === null) return { outcome: "ok", bytes: new Uint8Array(0) };
4360
+ let reader;
4361
+ let held = new Uint8Array(0);
3719
4362
  let total = 0;
4363
+ const time = bounds?.time;
4364
+ const arrival = time?.clock.wait(time.arrivalMs);
4365
+ const slow = arrival?.done.then(() => SLOW);
3720
4366
  try {
4367
+ reader = body.getReader();
3721
4368
  for (; ; ) {
3722
- const next = await reader.read();
4369
+ const next = await nextWithin(reader, time, slow);
4370
+ if (next === STALLED || next === SLOW) {
4371
+ await reader.cancel().catch(() => {
4372
+ });
4373
+ return { outcome: next === STALLED ? "stalled" : "slow" };
4374
+ }
3723
4375
  if (next.done) break;
3724
4376
  const chunk = next.value;
3725
4377
  if (chunk === void 0) continue;
3726
- total += chunk.byteLength;
3727
- if (total > limit) {
4378
+ const needed = total + chunk.byteLength;
4379
+ if (needed > limit) {
3728
4380
  await reader.cancel().catch(() => {
3729
4381
  });
3730
4382
  return { outcome: "too_large" };
3731
4383
  }
3732
- chunks.push(chunk);
4384
+ if (bounds !== void 0 && !bounds.arrive(chunk.byteLength)) {
4385
+ await reader.cancel().catch(() => {
4386
+ });
4387
+ return { outcome: "full" };
4388
+ }
4389
+ if (needed > held.byteLength) {
4390
+ const grown = new Uint8Array(Math.min(limit, Math.max(needed, held.byteLength * 2, FIRST_HOLD_BYTES)));
4391
+ grown.set(held.subarray(0, total));
4392
+ held = grown;
4393
+ }
4394
+ held.set(chunk, total);
4395
+ total = needed;
3733
4396
  }
3734
4397
  } catch (error) {
3735
- await reader.cancel().catch(() => {
4398
+ await reader?.cancel().catch(() => {
3736
4399
  });
3737
4400
  return { outcome: "error", error };
4401
+ } finally {
4402
+ arrival?.cancel();
3738
4403
  }
3739
- const joined2 = new Uint8Array(new ArrayBuffer(total));
3740
- let at2 = 0;
3741
- for (const chunk of chunks) {
3742
- joined2.set(chunk, at2);
3743
- at2 += chunk.byteLength;
4404
+ return { outcome: "ok", bytes: held.subarray(0, total) };
4405
+ }
4406
+ async function nextWithin(reader, time, slow) {
4407
+ if (time === void 0 || slow === void 0) return reader.read();
4408
+ const stall = time.clock.wait(time.stallMs);
4409
+ try {
4410
+ return await Promise.race([reader.read(), stall.done.then(() => STALLED), slow]);
4411
+ } finally {
4412
+ stall.cancel();
3744
4413
  }
3745
- return { outcome: "ok", bytes: joined2.buffer };
3746
4414
  }
3747
4415
  function credentialShape(kind) {
3748
4416
  return Object.hasOwn(CREDENTIAL_SHAPES, kind) ? CREDENTIAL_SHAPES[kind] : null;
@@ -3792,19 +4460,21 @@ function readBearer(request) {
3792
4460
  const value = header.slice(7).trim();
3793
4461
  return value.length > 0 ? value : null;
3794
4462
  }
3795
- function refuse2(status, reason, message) {
4463
+ function refuse2(status, reason, message, extra = {}) {
3796
4464
  return new Response(JSON.stringify({ error: reason, message }, null, 2), {
3797
4465
  status,
3798
4466
  headers: new Headers({
3799
4467
  "content-type": "application/json; charset=utf-8",
3800
- "cache-control": "no-store"
4468
+ "cache-control": "no-store",
4469
+ ...extra
3801
4470
  })
3802
4471
  });
3803
4472
  }
3804
- var RESOLVE_BY_HOST, STRIPPED_PREFIXES, STRIPPED, HOP_PREFIXES, HOP_HEADERS, isHopHeader, AUDIT_PATH_CHARS, CREDENTIAL_SHAPES, isHttpKind, safeHost;
4473
+ var RESOLVE_BY_HOST, STRIPPED_PREFIXES, STRIPPED, HOP_PREFIXES, HOP_HEADERS, isHopHeader, HAND_OUT_BYTES, AUDIT_PATH_CHARS, STALLED, SLOW, FIRST_HOLD_BYTES, CREDENTIAL_SHAPES, isHttpKind, safeHost;
3805
4474
  var init_broker = __esm({
3806
4475
  "packages/core/src/broker.ts"() {
3807
4476
  "use strict";
4477
+ init_broker_bodies();
3808
4478
  init_broker_stream();
3809
4479
  init_broker_token();
3810
4480
  init_egress();
@@ -3856,7 +4526,11 @@ var init_broker = __esm({
3856
4526
  "malicious-uploads-detection"
3857
4527
  ]);
3858
4528
  isHopHeader = (lower) => HOP_HEADERS.has(lower) || HOP_PREFIXES.some((prefix) => lower.startsWith(prefix));
4529
+ HAND_OUT_BYTES = 64 * 1024;
3859
4530
  AUDIT_PATH_CHARS = 200;
4531
+ STALLED = /* @__PURE__ */ Symbol("stalled");
4532
+ SLOW = /* @__PURE__ */ Symbol("slow");
4533
+ FIRST_HOLD_BYTES = 16 * 1024;
3860
4534
  CREDENTIAL_SHAPES = Object.freeze({
3861
4535
  http_bearer: Object.freeze({ sends: "bearer", fields: Object.freeze(["token"]) }),
3862
4536
  http_header: Object.freeze({ sends: "headers", fields: Object.freeze([]) }),
@@ -3973,7 +4647,7 @@ arrives: pass \`response.body\` straight on. There are no WebSockets. A front en
3973
4647
  answers everything else; add \`"spa": true\` for a client-side router. Nothing outside that folder is served.
3974
4648
 
3975
4649
  Preview before you deploy: \`wirl preview\` gives a folder its own address and database (at first only what
3976
- \`migrations/\` makes). \`wirl fetch <path> --preview <name>\` or \`wirl_preview_fetch\` shows what it answers.
4650
+ \`migrations/\` makes). \`wirl fetch <path> --preview <name>\` or \`wirl_preview_fetch\` shows what it answers. Every request a preview's module gets names the preview in \`x-wirl-preview\`, and wirl marks its pages as previews.
3977
4651
 
3978
4652
  npm packages work: install them in the project and the deploy bundles them in. Nothing is installed on the
3979
4653
  server: native code will not run. Node built-ins work (\`node:crypto\`, \`node:path\`, ...; \`Buffer\` and
@@ -4039,7 +4713,7 @@ var init_version = __esm({
4039
4713
  "packages/core/src/version.ts"() {
4040
4714
  "use strict";
4041
4715
  WORKSPACE_VERSION = "0.0.0";
4042
- BUILT_VERSION = true ? "0.2.1" : null;
4716
+ BUILT_VERSION = true ? "0.2.2" : null;
4043
4717
  }
4044
4718
  });
4045
4719
 
@@ -4067,6 +4741,32 @@ var init_install = __esm({
4067
4741
  }
4068
4742
  });
4069
4743
 
4744
+ // packages/core/src/share-role.ts
4745
+ function readShareRole(value) {
4746
+ if (value === void 0 || value === null) return { ok: true, role: void 0 };
4747
+ if (value === AppRole.VIEWER || value === AppRole.EDITOR) return { ok: true, role: value };
4748
+ return { ok: false, message: SHARE_ROLE_REFUSAL };
4749
+ }
4750
+ var SHARE_ROLE_REFUSAL;
4751
+ var init_share_role = __esm({
4752
+ "packages/core/src/share-role.ts"() {
4753
+ "use strict";
4754
+ init_types();
4755
+ SHARE_ROLE_REFUSAL = "role must be viewer or editor.";
4756
+ }
4757
+ });
4758
+
4759
+ // packages/core/src/connect.ts
4760
+ var CLAUDE_CODE_SIGN_IN, CLAUDE_CODE_LOCAL_SIGN_IN;
4761
+ var init_connect = __esm({
4762
+ "packages/core/src/connect.ts"() {
4763
+ "use strict";
4764
+ init_install();
4765
+ CLAUDE_CODE_SIGN_IN = "`claude mcp add` only saves the server's configuration. Then sign in: run `/mcp` inside Claude Code and follow the steps in the browser, or run `claude mcp login wirl` in a terminal.";
4766
+ CLAUDE_CODE_LOCAL_SIGN_IN = `The local server signs in the first time a wirl tool needs it: the agent calls \`wirl_login\`, which returns a link and a code, and the agent shows both to the person. The person approves the code in the browser, and the agent's next wirl tool call works. A machine already signed in with \`${WIRL_BIN} login\` skips this.`;
4767
+ }
4768
+ });
4769
+
4070
4770
  // packages/core/src/porting.ts
4071
4771
  function dependencyNames(packageJson) {
4072
4772
  if (packageJson === void 0) return [];
@@ -4287,6 +4987,66 @@ var init_porting = __esm({
4287
4987
  }
4288
4988
  });
4289
4989
 
4990
+ // packages/core/src/throttle-report.ts
4991
+ var THROTTLE_REPORT, EDGE_REFUSALS_NOTE;
4992
+ var init_throttle_report = __esm({
4993
+ "packages/core/src/throttle-report.ts"() {
4994
+ "use strict";
4995
+ init_public_path();
4996
+ THROTTLE_REPORT = {
4997
+ /**
4998
+ * One report per app a minute at most from each gate isolate, and what
4999
+ * one report counts spans a minute at most.
5000
+ */
5001
+ windowMs: 6e4,
5002
+ /**
5003
+ * How long the refusal that opens a window waits for more before the
5004
+ * report goes, so a burst is one report with its count rather than a
5005
+ * report of its first refusal.
5006
+ */
5007
+ gatherMs: 15e3,
5008
+ /**
5009
+ * The longest a refused request is asked to stay open, after its 429 has
5010
+ * gone, for the report to be sent. Cloudflare keeps a request's
5011
+ * `waitUntil` work for 30 seconds after the response, shared by all of
5012
+ * it, and the send itself has `sendTimeoutMs` of what is left.
5013
+ */
5014
+ maxWaitMs: 2e4,
5015
+ sendTimeoutMs: 5e3,
5016
+ /**
5017
+ * Reports one request carries for windows no refusal could wait for
5018
+ * (`flushDue`). A request that was never refused must not queue its own
5019
+ * reads behind posts that are not its own: Cloudflare runs six of a
5020
+ * request's connections at once and queues the rest.
5021
+ */
5022
+ flushPerRequest: 2,
5023
+ /** Apps one gate isolate counts for at once; a refusal for another app past this is not counted. */
5024
+ maxApps: 1e3,
5025
+ /** Lines in one report: each of an app's public paths, by each ceiling. */
5026
+ maxEntries: 2 * PUBLIC_PATH_LIMITS.perApp,
5027
+ /** A path is cut to this many characters, as `public.refused`'s is. */
5028
+ maxPathChars: 256,
5029
+ /**
5030
+ * The most of a report's body the control plane reads. A full report is
5031
+ * under 8 KiB: a path a request matched is ASCII, as a URL's path is.
5032
+ */
5033
+ bodyBytes: 16384,
5034
+ /**
5035
+ * Reports the control plane reads for one app a minute, from every isolate
5036
+ * and location together. Of those, the first writes the app's one
5037
+ * `public.throttled` row for that minute and the rest write nothing.
5038
+ */
5039
+ perAppPerMinute: 10,
5040
+ /** The org's Activity page summarises this many apps' latest `public.throttled` lines, a line an app, from the last `logsPageDays` days. */
5041
+ activityLines: 5,
5042
+ /** The app's Logs page shows its latest this many `public.throttled` lines, from the last `logsPageDays` days. */
5043
+ logsPageLines: 20,
5044
+ logsPageDays: 7
5045
+ };
5046
+ EDGE_REFUSALS_NOTE = "Requests wirl refuses before your app is called, such as a public path's 429 over its ceiling, are not invocations and never show here; anyone who can deploy the app sees those 429s counted as public.throttled on its Logs page in the dashboard.";
5047
+ }
5048
+ });
5049
+
4290
5050
  // packages/core/src/supplied-bundle.ts
4291
5051
  var SUPPLIED_BUNDLE_PATH, MAX_SUPPLIED_BUNDLE_BYTES;
4292
5052
  var init_supplied_bundle = __esm({
@@ -4310,11 +5070,22 @@ var init_manifest_transport = __esm({
4310
5070
  }
4311
5071
  });
4312
5072
 
5073
+ // packages/core/src/preview.ts
5074
+ function versionNumbersSentence(listing) {
5075
+ return `A preview takes a version number from the same count as a deploy, and ${listing} never lists a preview, so a deploy's number can skip: if the newest version listed is v1 and this deploy is v4, v2 and v3 were previews.`;
5076
+ }
5077
+ var init_preview = __esm({
5078
+ "packages/core/src/preview.ts"() {
5079
+ "use strict";
5080
+ }
5081
+ });
5082
+
4313
5083
  // packages/core/src/visit-token.ts
4314
- var VISIT_QUERY_PARAM, VISIT_HEADER;
5084
+ var VISIT_TOKEN_TTL_SECONDS, VISIT_QUERY_PARAM, VISIT_HEADER;
4315
5085
  var init_visit_token = __esm({
4316
5086
  "packages/core/src/visit-token.ts"() {
4317
5087
  "use strict";
5088
+ VISIT_TOKEN_TTL_SECONDS = 900;
4318
5089
  VISIT_QUERY_PARAM = "__wirl_visit";
4319
5090
  VISIT_HEADER = "x-wirl-visit";
4320
5091
  }
@@ -4346,9 +5117,13 @@ var init_src = __esm({
4346
5117
  init_contract();
4347
5118
  init_version();
4348
5119
  init_install();
5120
+ init_share_role();
5121
+ init_connect();
4349
5122
  init_porting();
5123
+ init_throttle_report();
4350
5124
  init_supplied_bundle();
4351
5125
  init_manifest_transport();
5126
+ init_preview();
4352
5127
  init_visit_token();
4353
5128
  }
4354
5129
  });
@@ -5995,7 +6770,7 @@ import { constants as fsConstants, createReadStream } from "node:fs";
5995
6770
  import { access, link, lstat as lstat2, mkdir as mkdir3, open, readFile as readFile3, readdir, rename as rename2, rm as rm3, stat as stat3, unlink, writeFile as writeFile2 } from "node:fs/promises";
5996
6771
  import { homedir } from "node:os";
5997
6772
  import { basename as basename2, dirname as dirname3, isAbsolute, join as join5, relative, resolve as resolve4, sep } from "node:path";
5998
- import { create as tarCreate, extract as tarExtract } from "tar";
6773
+ import { create as tarCreate, extract as tarExtract, Parser as TarParser } from "tar";
5999
6774
  import { gzipSync } from "node:zlib";
6000
6775
 
6001
6776
  // packages/core/src/node/pinned-fetch.ts
@@ -6090,7 +6865,7 @@ function createPinnedFetch(options = {}) {
6090
6865
  if (streamsBody !== void 0 && !streamsBody(status, answered)) return null;
6091
6866
  clearTimeout(timer);
6092
6867
  timer = setTimeout(() => {
6093
- fail(new PinnedFetchError("timeout", url.hostname, `${url.hostname} did not finish its answer within ${bodyTimeoutMs} ms.`));
6868
+ fail(new PinnedFetchError("timeout", url.hostname, `${url.hostname} did not finish its answer within ${bodyTimeoutMs} ms.`, { kind: "time", ms: bodyTimeoutMs }));
6094
6869
  }, bodyTimeoutMs);
6095
6870
  return bodyIdleMs;
6096
6871
  };
@@ -6227,13 +7002,17 @@ function sendTo(wire, address, attempt, attemptMs) {
6227
7002
  budget = idle ?? Math.max(MIN_ATTEMPT_MS, wire.remainingMs());
6228
7003
  request.setTimeout(budget);
6229
7004
  const bodyless = NO_BODY.has(status) || method.toUpperCase() === "HEAD";
7005
+ const answerOver = () => {
7006
+ wire.done();
7007
+ if (!request.writableFinished) request.destroy();
7008
+ };
6230
7009
  if (bodyless) {
6231
7010
  incoming.resume();
6232
7011
  incoming.on("error", () => {
6233
7012
  });
6234
- wire.done();
7013
+ answerOver();
6235
7014
  } else {
6236
- incoming.once("close", wire.done);
7015
+ incoming.once("close", answerOver);
6237
7016
  }
6238
7017
  resolve7(new Response(bodyless ? null : Readable.toWeb(incoming), { status, headers: out2 }));
6239
7018
  } catch (error) {
@@ -6251,7 +7030,10 @@ function sendTo(wire, address, attempt, attemptMs) {
6251
7030
  const error = new PinnedFetchError(
6252
7031
  "timeout",
6253
7032
  url.hostname,
6254
- streaming ? `${url.hostname}'s answer stalled: nothing moved on it for ${budget} ms on ${address}, because the host sent nothing or nothing was read from it.` : `${url.hostname} did not answer within ${budget} ms on ${address}.`
7033
+ streaming ? `${url.hostname}'s answer stalled: nothing moved on it for ${budget} ms on ${address}, because the host sent nothing or nothing was read from it.` : `${url.hostname} did not answer within ${budget} ms on ${address}.`,
7034
+ // Which bound it was, for the broker to say what it cut a stream
7035
+ // for (OIM-837): only on a body on bounds of its own.
7036
+ streaming ? { kind: "idle", ms: budget } : void 0
6255
7037
  );
6256
7038
  if (delivered !== null) delivered.destroy(error);
6257
7039
  else request.destroy(error);
@@ -6271,6 +7053,7 @@ function sendTo(wire, address, attempt, attemptMs) {
6271
7053
  }
6272
7054
 
6273
7055
  // packages/core/src/node/broker-fetch.ts
7056
+ init_broker_bodies();
6274
7057
  init_broker_stream();
6275
7058
  var BROKER_FETCH_OPTIONS = Object.freeze({
6276
7059
  timeoutMs: BROKER_LIMITS.headersMs,
@@ -6281,10 +7064,38 @@ var BROKER_FETCH_OPTIONS = Object.freeze({
6281
7064
  function createBrokerTransport(options = {}) {
6282
7065
  return {
6283
7066
  fetchImpl: options.fetchImpl ?? createPinnedFetch({ ...BROKER_FETCH_OPTIONS, ...options.pinning }),
6284
- streamSlots: createStreamSlots()
7067
+ streamSlots: createStreamSlots(),
7068
+ heldBodies: createHeldBodies({ limits: options.heldBodyLimits, log: options.log }),
7069
+ heldAnswers: createHeldAnswers({ limits: options.heldAnswerLimits, log: options.log })
6285
7070
  };
6286
7071
  }
6287
7072
 
7073
+ // packages/core/src/node/sent-on.ts
7074
+ var LOOK_EVERY_MS = 2;
7075
+ function sentOn(response) {
7076
+ if (response === void 0) return void 0;
7077
+ const nothingLeft = () => {
7078
+ const socket = response.socket;
7079
+ if (response.destroyed === true || socket === null || socket?.destroyed === true) return true;
7080
+ return (response.writableLength ?? socket?.writableLength ?? 0) === 0;
7081
+ };
7082
+ return (stop) => new Promise((resolve7) => {
7083
+ let timer;
7084
+ const settle2 = () => {
7085
+ clearTimeout(timer);
7086
+ stop.removeEventListener("abort", settle2);
7087
+ resolve7();
7088
+ };
7089
+ const look = () => {
7090
+ if (stop.aborted || nothingLeft()) return settle2();
7091
+ timer = setTimeout(look, LOOK_EVERY_MS);
7092
+ timer.unref?.();
7093
+ };
7094
+ stop.addEventListener("abort", settle2, { once: true });
7095
+ look();
7096
+ });
7097
+ }
7098
+
6288
7099
  // packages/core/src/node/dev-identity.ts
6289
7100
  import { createHash } from "node:crypto";
6290
7101
  function devUserId(email) {
@@ -6300,10 +7111,9 @@ init_slug();
6300
7111
  import { createHash as createHash2 } from "node:crypto";
6301
7112
  var HASH_SUFFIX_LENGTH = 5;
6302
7113
  function previewNameFor(raw, orgSlug, appSlug) {
6303
- const slug = slugify(raw);
6304
- if (!slug) {
6305
- return { ok: false, message: "That name has no usable characters, or is reserved." };
6306
- }
7114
+ const made = previewSlugFor(raw);
7115
+ if (!made.ok) return { ok: false, message: made.message };
7116
+ const slug = made.slug;
6307
7117
  const budget = MAX_HOST_LABEL - (appLabel(orgSlug, appSlug).length + HOST_SEPARATOR.length);
6308
7118
  if (budget < 2) {
6309
7119
  return {
@@ -6423,6 +7233,64 @@ function shellPath(value) {
6423
7233
  return shellQuote(value);
6424
7234
  }
6425
7235
 
7236
+ // packages/cli/src/skill.ts
7237
+ init_src();
7238
+ var SKILL_INTRO = `A new app is visible to everyone in the org when the org came from a
7239
+ company email domain; in any other org, including a personal one or one
7240
+ made with \`wirl orgs create\`, it starts private. Deploys are one
7241
+ command; sharing is by email; a third-party API is declared, never pasted.`;
7242
+ var SKILL_DOCS_LINE = "Before you build, fetch https://docs.wirl.dev/llms-full.txt: every page of the docs as one Markdown file, so there is no need to read the site page by page.";
7243
+ var SKILL_RULES = `1. **Never ask for or paste an API key.** Declare the connection in \`wirl.json\` and deploy. Needs no key at all (a public API)? Declare it with "kind": "none" and its hosts. If the key is missing, the deploy prints a link to the app's keys page; show the person that link. Locally, keys live in \`.wirl/keys.json\`, which git ignores. To try a vendor call locally without the key, map its connection to a server of your own in \`.wirl/mocks.json\`, like \`{ "anthropic": "http://127.0.0.1:4010" }\`: \`wirl dev\` then sends the calls to that connection's hosts there instead (\`wirl dev --help\` has the rest).
7244
+ 2. **Call third-party APIs by their real URL** (\`fetch("https://api.hubspot.com/...")\`). wirl attaches the key on the way out, deployed and under \`wirl dev\` alike.
7245
+ 3. If a command names several organisations and asks which, ask the person; pass \`--org\` or put \`"org"\` in \`wirl.json\`.
7246
+ 4. To keep a file out of the deploy, list it in a \`.wirlignore\` at the project root (gitignore-style lines; no \`!\` negation).
7247
+ 5. **A file that can lose data waits for a person.** A deploy that would apply a destructive file (one wirl.json's \`"destructive"\` list must name) to the live database stops and names the page where a person approves each one. Show the person those pages, and deploy again once they say they approved. If \`${WIRL_BIN} deploy\` asks you to type a file's name, stop it and show the page instead, or deploy with \`--json\`, which never asks. Never type the name yourself or send the confirmation: that is the person's, and no flag skips it.`;
7248
+ var SKILL_MANIFEST_EXAMPLE = `\`\`\`json
7249
+ { "app": "crm-sync", "server": "server.mjs", "connections": [{ "slug": "hubspot", "hosts": ["api.hubspot.com"] }] }
7250
+ \`\`\``;
7251
+ var SKILL_FILES_PARAGRAPH = "No blob storage: files go in `env.DB`, at most 2,000,000 bytes a value or row (the limit Cloudflare publishes for D1), so store a larger file in chunks, one row each, a few MB to a batch. `wirl dev` refuses a single bound value over 2,000,000 bytes, but a row's total, a value grown in SQL, or a batch bigger than one call to D1 carries can still pass locally and fail deployed.";
7252
+ var SKILL_STATIC_PARAGRAPH = 'A static folder maps a path to one file exactly: a path ending in `/` serves the `index.html` in that folder, `/about` never finds `about.html`, and a path that names no file goes to the module (with `"spa": true`, a page navigation gets `index.html` instead; in an app with no module, the top `index.html`, or a 404 for a missing file with an extension). Next.js runs only as a static export: `output: "export"` and `trailingSlash: true` in `next.config` make `next build` write `out/`, which goes under `"static"`; `next start` cannot run, and API routes move into the module.';
7253
+ function composeSkill(parts) {
7254
+ if (parts.description.trim() === "" || /[\r\n]/.test(parts.description)) {
7255
+ throw new Error("a skill description is one non-empty line");
7256
+ }
7257
+ return `---
7258
+ name: wirl
7259
+ description: ${parts.description}
7260
+ ---
7261
+
7262
+ # wirl
7263
+
7264
+ ${SKILL_INTRO}
7265
+
7266
+ ${SKILL_DOCS_LINE}
7267
+
7268
+ ## Commands
7269
+
7270
+ ${parts.commands.trimEnd()}
7271
+
7272
+ ## Rules
7273
+
7274
+ ${SKILL_RULES}
7275
+
7276
+ ## wirl.json
7277
+
7278
+ ${SKILL_MANIFEST_EXAMPLE}
7279
+
7280
+ ## What runs on wirl
7281
+
7282
+ ${BUILD_CONTRACT}
7283
+
7284
+ ${SKILL_FILES_PARAGRAPH}
7285
+
7286
+ ${SKILL_STATIC_PARAGRAPH}
7287
+
7288
+ ## Flows
7289
+
7290
+ ${FLOWS_CONTRACT}
7291
+ `;
7292
+ }
7293
+
6426
7294
  // packages/cli/src/core.ts
6427
7295
  function credentialsPath(env = process.env) {
6428
7296
  return env["WIRL_CREDENTIALS_PATH"] || join5(homedir(), ".config", "wirl", "credentials.json");
@@ -6793,23 +7661,9 @@ var SKILL_RUN = NPM_PUBLISHED ? `Run the CLI as \`${WIRL_BIN} <command>\`, which
6793
7661
  ` : "";
6794
7662
  var SKILL_FALLBACK = hasFallback(SKILL_INSTALL) ? `- ${fallbackWhen(namedControlPlane(SKILL_ORIGIN))} install the CLI from the control plane instead with \`npm install -g ${SKILL_INSTALL.tarball.cli}\`, run plain \`wirl\` where this file says \`${WIRL_BIN}\`, and name \`${SKILL_INSTALL.tarball.mcp}\` in place of \`${SKILL_INSTALL.mcp}\`.
6795
7663
  ` : "";
6796
- var SKILL_MARKDOWN = `---
6797
- name: wirl
6798
- description: Deploy and share the internal app in this project with wirl (${WIRL_BIN}). Use when the user wants a tool, dashboard or prototype put somewhere colleagues can open it, shared with a named person, run locally the way it will run deployed, or connected to a third-party API; or wants work that outlives one request, such as waiting for a person to approve something or trying again tomorrow.
6799
- ---
6800
-
6801
- # wirl
6802
-
6803
- A new app is visible to everyone in the org when the org came from a
6804
- company email domain; in any other org, including a personal one or one
6805
- made with \`wirl orgs create\`, it starts private. Deploys are one
6806
- command; sharing is by email; a third-party API is declared, never pasted.
6807
-
6808
- Before you build, fetch https://docs.wirl.dev/llms-full.txt: every page of the docs as one Markdown file, so there is no need to read the site page by page.
6809
-
6810
- ## Commands
6811
-
6812
- ${SKILL_RUN}- Connect first, if the agent supports MCP: \`claude mcp add --transport http wirl https://app.wirl.dev/mcp\` opens the browser once to sign in, and needs no install. No HTTP MCP support? \`claude mcp add wirl -- npx -y ${SKILL_INSTALL.mcp}\` does the same, running locally. Neither available: \`${WIRL_BIN} login\` prints a link and a code; the person approves in the browser. Do this once, when any command says you are not signed in.
7664
+ var SKILL_MARKDOWN = composeSkill({
7665
+ description: `Deploy and share the internal app in this project with wirl (${WIRL_BIN}). Use when the user wants a tool, dashboard or prototype put somewhere colleagues can open it, shared with a named person, run locally the way it will run deployed, or connected to a third-party API; or wants work that outlives one request, such as waiting for a person to approve something or trying again tomorrow.`,
7666
+ commands: `${SKILL_RUN}- Connect first, if the agent supports MCP: \`claude mcp add --transport http wirl https://app.wirl.dev/mcp\` needs no install. ${CLAUDE_CODE_SIGN_IN} No HTTP MCP support? \`claude mcp add wirl -- npx -y ${SKILL_INSTALL.mcp}\` runs the same tools locally. ${CLAUDE_CODE_LOCAL_SIGN_IN} Neither available: \`${WIRL_BIN} login\` prints a link and a code; the person approves in the browser. Do this once, when any command says you are not signed in.
6813
7667
  - \`${WIRL_BIN} deploy\` \u2014 deploys this directory. Prints the URL and who can open it.
6814
7668
  - \`${WIRL_BIN} share someone@company.com\` \u2014 that person can open it. Add \`--role editor\` so they can deploy too.
6815
7669
  - \`${WIRL_BIN} dev\` \u2014 runs the app here as it will run deployed, signed in as a stub user.
@@ -6818,33 +7672,8 @@ ${SKILL_RUN}- Connect first, if the agent supports MCP: \`claude mcp add --trans
6818
7672
  - \`${WIRL_BIN} migrations\` \u2014 what this app's database has applied, read only.
6819
7673
  - \`${WIRL_BIN} migrations adopt\` \u2014 once, for an app whose tables were made in code: writes \`migrations/0001_init.sql\` from the live database's shape.
6820
7674
  - \`${WIRL_BIN} --help\` \u2014 everything else: orgs, members, connections, move, transfer, rollback, flows and runs.
6821
- ${SKILL_FALLBACK}
6822
- ## Rules
6823
-
6824
- 1. **Never ask for or paste an API key.** Declare the connection in \`wirl.json\` and deploy. Needs no key at all (a public API)? Declare it with "kind": "none" and its hosts. If the key is missing, the deploy prints a link to the app's keys page; show the person that link. Locally, keys live in \`.wirl/keys.json\`, which git ignores. To try a vendor call locally without the key, map its connection to a server of your own in \`.wirl/mocks.json\`, like \`{ "anthropic": "http://127.0.0.1:4010" }\`: \`wirl dev\` then sends the calls to that connection's hosts there instead (\`wirl dev --help\` has the rest).
6825
- 2. **Call third-party APIs by their real URL** (\`fetch("https://api.hubspot.com/...")\`). wirl attaches the key on the way out, deployed and under \`wirl dev\` alike.
6826
- 3. If a command names several organisations and asks which, ask the person; pass \`--org\` or put \`"org"\` in \`wirl.json\`.
6827
- 4. To keep a file out of the deploy, list it in a \`.wirlignore\` at the project root (gitignore-style lines; no \`!\` negation).
6828
- 5. **A file that can lose data waits for a person.** A deploy that would apply a destructive file (one wirl.json's \`"destructive"\` list must name) to the live database stops and names the page where a person approves each one. Show the person those pages, and deploy again once they say they approved. If \`${WIRL_BIN} deploy\` asks you to type a file's name, stop it and show the page instead, or deploy with \`--json\`, which never asks. Never type the name yourself or send the confirmation: that is the person's, and no flag skips it.
6829
-
6830
- ## wirl.json
6831
-
6832
- \`\`\`json
6833
- { "app": "crm-sync", "server": "server.mjs", "connections": [{ "slug": "hubspot", "hosts": ["api.hubspot.com"] }] }
6834
- \`\`\`
6835
-
6836
- ## What runs on wirl
6837
-
6838
- ${BUILD_CONTRACT}
6839
-
6840
- No blob storage: files go in \`env.DB\`, at most 2,000,000 bytes a value or row (the limit Cloudflare publishes for D1), so store a larger file in chunks, one row each, a few MB to a batch. \`wirl dev\` refuses a single bound value over 2,000,000 bytes, but a row's total, a value grown in SQL, or a batch bigger than one call to D1 carries can still pass locally and fail deployed.
6841
-
6842
- A static folder maps a path to one file exactly: a path ending in \`/\` serves the \`index.html\` in that folder, \`/about\` never finds \`about.html\`, and a path that names no file goes to the module (with \`"spa": true\`, a page navigation gets \`index.html\` instead; in an app with no module, the top \`index.html\`, or a 404 for a missing file with an extension). Next.js runs only as a static export: \`output: "export"\` and \`trailingSlash: true\` in \`next.config\` make \`next build\` write \`out/\`, which goes under \`"static"\`; \`next start\` cannot run, and API routes move into the module.
6843
-
6844
- ## Flows
6845
-
6846
- ${FLOWS_CONTRACT}
6847
- `;
7675
+ ${SKILL_FALLBACK}`
7676
+ });
6848
7677
  async function writeSkill(dir) {
6849
7678
  const path = join5(dir, SKILL_PATH);
6850
7679
  const existing = await readFile3(path, "utf8").catch(() => null);
@@ -7216,23 +8045,51 @@ async function callApiBytes(creds, path, options = {}) {
7216
8045
  }
7217
8046
  const declared = Number(response.headers.get("content-length") ?? 0);
7218
8047
  if (declared > MAX_PULL_BYTES) throw new CliError(`That is ${Math.round(declared / 1048576)} MB, more than wirl will pull.`);
7219
- const bytes = new Uint8Array(await response.arrayBuffer());
8048
+ let bytes;
8049
+ try {
8050
+ bytes = new Uint8Array(await response.arrayBuffer());
8051
+ } catch {
8052
+ throw new CliError(PULL_CUT, void 0, { code: "pull_cut" });
8053
+ }
7220
8054
  if (bytes.byteLength > MAX_PULL_BYTES) throw new CliError("The download was larger than wirl will pull.");
7221
8055
  return bytes;
7222
8056
  }
8057
+ var PULL_CUT = "The pull was cut before it finished, so nothing was written. Run it again.";
8058
+ var pullShort = (dir) => `The pull's archive is cut short or damaged, so nothing was written to ${dir}. Run it again.`;
7223
8059
  var MAX_PULL_BYTES = 256 * 1048576;
7224
8060
  var pullApp = (creds, org, app, options = {}) => callApiBytes(creds, `/api/v1/apps/${seg(org)}/${seg(app)}/pull`, options.fetchImpl ? { fetchImpl: options.fetchImpl } : {});
8061
+ function isWholeArchive(archive) {
8062
+ return new Promise((resolve7, reject) => {
8063
+ let sound = true;
8064
+ let ended2 = false;
8065
+ const parser = new TarParser({ onReadEntry: (entry) => void entry.resume() });
8066
+ parser.on("warn", (code) => {
8067
+ if (code === "TAR_BAD_ARCHIVE" || code === "TAR_ENTRY_INVALID") sound = false;
8068
+ });
8069
+ parser.on("eof", () => {
8070
+ ended2 = true;
8071
+ });
8072
+ parser.on("error", (error) => typeof error.code === "string" && error.code.startsWith("Z_") ? resolve7(false) : reject(error));
8073
+ parser.on("end", () => resolve7(sound && ended2));
8074
+ parser.end(archive);
8075
+ });
8076
+ }
7225
8077
  async function unpackPull(bytes, dir, options = {}) {
7226
- await mkdir3(dir, { recursive: true });
7227
- const existing = await readdir(dir);
8078
+ const existing = await readdir(dir).catch((error) => {
8079
+ if (error.code === "ENOENT") return [];
8080
+ throw error;
8081
+ });
7228
8082
  if (existing.length > 0 && !options.force) {
7229
8083
  throw new CliError(`${dir} is not empty.`, "Pull into a new directory, or pass --force to overwrite.");
7230
8084
  }
8085
+ const archive = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
8086
+ if (!await isWholeArchive(archive)) throw new CliError(pullShort(dir), void 0, { code: "pull_cut" });
8087
+ await mkdir3(dir, { recursive: true });
7231
8088
  await new Promise((resolve7, reject) => {
7232
8089
  const extractor = tarExtract({ cwd: dir });
7233
8090
  extractor.on("error", reject);
7234
8091
  extractor.on("finish", resolve7);
7235
- extractor.end(Buffer.from(bytes));
8092
+ extractor.end(archive);
7236
8093
  });
7237
8094
  const written = [];
7238
8095
  await collect(dir, dir, written);
@@ -7625,10 +8482,13 @@ function describeVisibility(level, org) {
7625
8482
  }
7626
8483
  }
7627
8484
  var readVisibility = (creds, org, app) => callApi(creds, `/api/v1/apps/${seg(org)}/${seg(app)}`).then((r) => r.app.visibility ?? "unknown");
7628
- async function changeVisibility(creds, options) {
7629
- if (!VISIBILITY_LEVELS.includes(options.level)) {
7630
- throw new CliError(`Visibility is private, org or public, not "${String(options.level)}".`);
8485
+ function refuseVisibilityLevel(level) {
8486
+ if (!VISIBILITY_LEVELS.includes(level)) {
8487
+ throw new CliError(`Visibility is private, org or public, not "${String(level)}".`);
7631
8488
  }
8489
+ }
8490
+ async function changeVisibility(creds, options) {
8491
+ refuseVisibilityLevel(options.level);
7632
8492
  if (options.level === "public") {
7633
8493
  throw new CliError(
7634
8494
  "Public means anyone on the internet with the link can open it, without signing in, so a person decides that with their own click.",
@@ -7673,11 +8533,59 @@ var transferApp = (creds, org, app, email, options = {}) => callApi(creds, `/api
7673
8533
  ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
7674
8534
  });
7675
8535
  var unmailedNote = (result) => result.mailed === false && result.message ? ` ${result.message}` : "";
8536
+ function refuseShareRole(value) {
8537
+ const read = readShareRole(value);
8538
+ if (read.ok) return read.role;
8539
+ const shown2 = typeof value === "string" ? terminalSafe(value.slice(0, 40)) : typeof value;
8540
+ throw new CliError(`Role is viewer or editor, not "${shown2}".`);
8541
+ }
7676
8542
  var shareApp = (creds, org, app, email, role) => callApi(creds, `/api/v1/apps/${seg(org)}/${seg(app)}/share`, {
7677
8543
  method: "POST",
7678
- body: JSON.stringify({ email, role }),
8544
+ // No role is no `role`: the control plane then keeps the one a person has.
8545
+ body: JSON.stringify(role === void 0 ? { email } : { email, role }),
8546
+ contentType: "application/json"
8547
+ });
8548
+ function shareSentence(result, email, asked, voice) {
8549
+ const roleOf = (value) => value === "viewer" || value === "editor" ? value : void 0;
8550
+ const outcome = ["added", "kept", "unchanged", "changed"].includes(String(result.outcome)) ? result.outcome : void 0;
8551
+ const role = roleOf(result.role);
8552
+ const old = outcome === void 0 || role === void 0;
8553
+ const now = role ?? asked ?? "viewer";
8554
+ const was = roleOf(result.previousRole);
8555
+ const article = (name) => name === "editor" ? "an editor" : "a viewer";
8556
+ const mail = unmailedNote(result);
8557
+ const signIn = voice === "cli" ? "the moment they first sign in" : "when they first sign in";
8558
+ if (result.pending) {
8559
+ const wait = `They will have access ${signIn}.`;
8560
+ if (!old && outcome === "kept") return `${email} was already invited as ${now}, and no role was given, so it stays ${now}. ${wait}${mail}`;
8561
+ const lead2 = `Invited ${email} as ${now}. ${wait}`;
8562
+ if (old || outcome === "added") return `${lead2}${mail}`;
8563
+ if (outcome === "unchanged") return `${lead2} The invitation already said that.${mail}`;
8564
+ if (was === void 0) return `${lead2} The invitation's role was changed.${mail}`;
8565
+ return `${lead2} The invitation said ${was} before.${mail}`;
8566
+ }
8567
+ if (!old && outcome === "kept") {
8568
+ const change = voice === "cli" ? "Pass --role viewer or --role editor" : 'Pass role "viewer" or "editor"';
8569
+ return `${email} already had access as ${now}, and no role was given, so it stays ${now}. ${change} to change it.`;
8570
+ }
8571
+ const lead = voice === "cli" ? `Shared with ${email} as ${now}.` : `${email} can now open the app as ${now}.`;
8572
+ if (old || outcome === "added") return lead;
8573
+ if (outcome === "unchanged") return `${lead} They already had that role.`;
8574
+ if (was === void 0) return `${lead} Their role was changed.`;
8575
+ return `${lead} They were ${article(was)} before.`;
8576
+ }
8577
+ var unshareApp = (creds, org, app, email) => callApi(creds, `/api/v1/apps/${seg(org)}/${seg(app)}/share`, {
8578
+ method: "DELETE",
8579
+ body: JSON.stringify({ email }),
7679
8580
  contentType: "application/json"
7680
8581
  });
8582
+ function unshareSentence(result, email) {
8583
+ const share = result.shareRevoked ?? result.revoked;
8584
+ const invitation = result.invitationWithdrawn ?? false;
8585
+ const found = share ? invitation ? `Revoked ${email}'s share and withdrew the invitation still waiting for them.` : `Revoked ${email}'s share.` : invitation ? `Withdrew ${email}'s invitation.` : `${email} held no share on the app and had no invitation to it, so there was nothing to take back.`;
8586
+ const wasDeleted = result.appDeleted === true && (share || invitation) ? " The app was deleted when this was asked, so a restore will not bring it back." : "";
8587
+ return `${found}${wasDeleted} That takes back only what was shared by name: the app's level (wirl_visibility) and the rights of its owner and the organisation's owners and admins are unchanged.`;
8588
+ }
7681
8589
  var listDeployments = (creds, org, app) => callApi(
7682
8590
  creds,
7683
8591
  `/api/v1/apps/${seg(org)}/${seg(app)}/deployments`
@@ -7821,7 +8729,7 @@ var fetchLogs = (creds, org, app, filters) => callApi(
7821
8729
  );
7822
8730
  var LINE_INDENT = " ";
7823
8731
  function terminalSafe(text) {
7824
- return escapeInvisible(text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`));
8732
+ return escapeInvisible(text.replace(/\r\n/g, "\n").replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`));
7825
8733
  }
7826
8734
  function utcSecond(at2) {
7827
8735
  const ms = Date.parse(at2);
@@ -7857,7 +8765,8 @@ function formatLogPage(page, since) {
7857
8765
  // queryable. Without this an empty answer right after a request
7858
8766
  // reads as "nothing happened".
7859
8767
  " A request made in the last minute may not have arrived yet: ask again shortly."
7860
- )}` : page.invocations.map((entry) => formatInvocation(entry)).join("\n\n");
8768
+ )}
8769
+ ${EDGE_REFUSALS_NOTE}` : page.invocations.map((entry) => formatInvocation(entry)).join("\n\n");
7861
8770
  return page.cursor === null ? body : `${body}
7862
8771
 
7863
8772
  More before this: pass cursor=${page.cursor}.`;
@@ -8403,20 +9312,22 @@ async function previewFetch(creds, org, app, name, path, options) {
8403
9312
  const headers = callerHeaders(options.headers);
8404
9313
  headers.set(VISIT_HEADER, mint.token);
8405
9314
  const networkFetch = options.networkFetchImpl ?? fetch;
8406
- const sentAt = Date.now();
9315
+ const clock = options.now ?? (() => performance.now());
9316
+ const sentAt = clock();
9317
+ const elapsed = () => Math.round(clock() - sentAt);
8407
9318
  const response = await networkFetch(target.toString(), {
8408
9319
  method: options.method ?? (options.body !== void 0 ? "POST" : "GET"),
8409
9320
  headers,
8410
9321
  redirect: "manual",
8411
9322
  ...options.body === void 0 ? {} : { body: options.body }
8412
9323
  });
8413
- const headersMs = Date.now() - sentAt;
9324
+ const headersMs = elapsed();
8414
9325
  const contentType = response.headers.get("content-type") ?? "";
8415
9326
  const location = response.headers.get("location");
8416
9327
  const text = isTextBody(contentType);
8417
9328
  const streamWaitMs = Math.min(options.streamWaitMs ?? FETCH_STREAM_WAIT_MS, FETCH_WAIT_MAX_SECONDS * 1e3);
8418
9329
  const keep = options.keepBodyBytes === void 0 ? null : Math.min(options.keepBodyBytes, FETCH_FILE_MAX_BYTES);
8419
- const read = await readBounded(response, streamWaitMs, sentAt, keep ?? FETCH_READ_CEILING_BYTES);
9330
+ const read = await readBounded(response, streamWaitMs, elapsed, keep ?? FETCH_READ_CEILING_BYTES);
8420
9331
  const bytes = read.bytes;
8421
9332
  const truncated = text && (read.cut || bytes.byteLength > FETCH_BODY_CAP_BYTES);
8422
9333
  const media = (contentType.split(";")[0] ?? "").trim().toLowerCase();
@@ -8504,7 +9415,7 @@ function parseWaitFlag(raw) {
8504
9415
  }
8505
9416
  var FETCH_READ_CEILING_BYTES = 8 * FETCH_BODY_CAP_BYTES;
8506
9417
  var FETCH_FILE_MAX_BYTES = 1e8;
8507
- async function readBounded(response, waitMs, sentAt, ceilingBytes) {
9418
+ async function readBounded(response, waitMs, elapsed, ceilingBytes) {
8508
9419
  if (response.body === null) return { bytes: new Uint8Array(0), chunks: [], stillStreaming: false, cut: false };
8509
9420
  const reader = response.body.getReader();
8510
9421
  const chunks = [];
@@ -8523,7 +9434,7 @@ async function readBounded(response, waitMs, sentAt, ceilingBytes) {
8523
9434
  break;
8524
9435
  }
8525
9436
  if (next.done) break;
8526
- chunks.push({ atMs: Date.now() - sentAt, bytes: next.value });
9437
+ chunks.push({ atMs: elapsed(), bytes: next.value });
8527
9438
  total += next.value.byteLength;
8528
9439
  if (total > ceilingBytes) {
8529
9440
  cut2 = true;
@@ -8568,9 +9479,9 @@ function formatFetchResult(result, saved) {
8568
9479
  0,
8569
9480
  `answered after ${seconds(result.headersMs)} s; ${said} line${said === 1 ? "" : "s"} in ${pieces} piece${pieces === 1 ? "" : "s"} over ${seconds(last)} s (each line shown at the time it was complete)`
8570
9481
  );
9482
+ const unfinished = lastLineUnfinished(result);
8571
9483
  timed.forEach((line, i) => {
8572
- const unfinished = i === timed.length - 1 && !result.timed.lastComplete && (!result.bodyComplete || result.truncated);
8573
- lines.push(line.text === "" ? "" : `+${seconds(line.atMs)}s ${line.text}${unfinished ? " (the line had not ended)" : ""}`);
9484
+ lines.push(line.text === "" ? "" : `+${seconds(line.atMs)}s ${line.text}${unfinished && i === timed.length - 1 ? " (the line had not ended)" : ""}`);
8574
9485
  });
8575
9486
  } else {
8576
9487
  lines.push(result.bodyText);
@@ -8583,6 +9494,10 @@ function formatFetchResult(result, saved) {
8583
9494
  }
8584
9495
  return lines.join("\n");
8585
9496
  }
9497
+ function lastLineUnfinished(result) {
9498
+ return result.timed !== null && !result.timed.lastComplete && (!result.bodyComplete || result.truncated);
9499
+ }
9500
+ var STREAMED_DATA_MAX_TEXT_BYTES = 16 * 1024;
8586
9501
  async function readDataFile(file, maxBytes = FETCH_FILE_MAX_BYTES) {
8587
9502
  if (file.length === 0) throw new CliError("--data @ needs a file name after the @.", void 0, { code: "bad_flag" });
8588
9503
  const tooBig = () => new CliError(
@@ -9049,6 +9964,137 @@ function formatMigrationsListing(listing, app) {
9049
9964
  return lines;
9050
9965
  }
9051
9966
 
9967
+ // packages/cli/src/args.ts
9968
+ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
9969
+ "json",
9970
+ "help",
9971
+ "create",
9972
+ "no-create",
9973
+ "no-skill",
9974
+ "print",
9975
+ "server",
9976
+ "force",
9977
+ "errors",
9978
+ "follow",
9979
+ "fast",
9980
+ "verbose",
9981
+ "yes"
9982
+ ]);
9983
+ var EVERYWHERE = {
9984
+ values: ["org", "app"],
9985
+ // `create` was the flag a deploy needed before an app was made by default, and is still read as the boolean it was, to no effect.
9986
+ booleans: ["json", "no-create", "create", "help"]
9987
+ };
9988
+ function commandFlags(listed2, everywhere = true) {
9989
+ return {
9990
+ values: /* @__PURE__ */ new Set([...everywhere ? EVERYWHERE.values : [], ...listed2.values ?? []]),
9991
+ booleans: /* @__PURE__ */ new Set([...everywhere ? EVERYWHERE.booleans : [], ...listed2.booleans ?? []]),
9992
+ own: new Set(listed2.refusedByCommand ?? [])
9993
+ };
9994
+ }
9995
+ var COMMAND_FLAGS = /* @__PURE__ */ new Map([
9996
+ ["login", commandFlags({ values: ["token", "api"] })],
9997
+ ["init", commandFlags({ booleans: ["server", "no-skill"] })],
9998
+ ["skill", commandFlags({ booleans: ["print"] })],
9999
+ // Only what it lists, as DEV_FLAGS in bin.ts does (OIM-887): `--json` and `--app` were always refused here.
10000
+ ["dev", commandFlags({ values: ["user", "name", "role", "org", "port"], booleans: ["verbose"] }, false)],
10001
+ ["apps", commandFlags({})],
10002
+ // `--yes` changes nothing on a deploy: no flag answers for the person who approves a destructive file, and the
10003
+ // help says so ("--yes included"). It is read, as the boolean it is, and ignored; an agent that passes it still deploys.
10004
+ ["deploy", commandFlags({ booleans: ["yes"] })],
10005
+ ["preview", commandFlags({ values: ["name", "delete"], refusedByCommand: ["delete"] })],
10006
+ ["previews", commandFlags({})],
10007
+ ["fetch", commandFlags({ values: ["preview", "method", "data", "header", "wait", "output"], refusedByCommand: ["preview"] })],
10008
+ ["pull", commandFlags({ booleans: ["force"] })],
10009
+ // Only what DELETE_FLAGS in bin.ts lists (OIM-918), and `--preview`, which that file refuses in a sentence about previews.
10010
+ ["delete", commandFlags({ values: ["org", "app", "preview"], booleans: ["yes", "json"], refusedByCommand: ["preview"] }, false)],
10011
+ ["share", commandFlags({ values: ["role"] })],
10012
+ ["unshare", commandFlags({})],
10013
+ ["visibility", commandFlags({})],
10014
+ ["transfer", commandFlags({})],
10015
+ ["move", commandFlags({ values: ["to"] })],
10016
+ ["orgs", commandFlags({ values: ["name"] })],
10017
+ ["members", commandFlags({ values: ["role"] })],
10018
+ ["versions", commandFlags({})],
10019
+ ["rollback", commandFlags({})],
10020
+ ["migrations", commandFlags({ values: ["preview"] })],
10021
+ ["schedules", commandFlags({})],
10022
+ ["run", commandFlags({ values: ["preview"] })],
10023
+ ["flows", commandFlags({ values: ["preview", "input", "input-file", "key"], booleans: ["fast"] })],
10024
+ ["runs", commandFlags({ values: ["flow", "status", "limit", "preview", "data", "from"] })],
10025
+ ["logs", commandFlags({ values: ["since", "kind", "limit", "cursor", "run", "ray", "search", "preview"], booleans: ["errors", "follow"] })],
10026
+ ["egress", commandFlags({ values: ["since", "run", "limit", "cursor"] })],
10027
+ ["connections", commandFlags({ values: ["secret-file", "bearer", "hosts", "kind", "name"] })],
10028
+ ["whoami", commandFlags({})]
10029
+ ]);
10030
+ function takesValue(command, name) {
10031
+ const table = command === void 0 ? void 0 : COMMAND_FLAGS.get(command);
10032
+ return table === void 0 ? !BOOLEAN_FLAGS.has(name) : table.values.has(name);
10033
+ }
10034
+ function parseArgs(argv) {
10035
+ const positional = [];
10036
+ const flags = /* @__PURE__ */ new Map();
10037
+ const valueless = /* @__PURE__ */ new Set();
10038
+ for (let i = 0; i < argv.length; i += 1) {
10039
+ const token = argv[i] ?? "";
10040
+ if (!token.startsWith("--")) {
10041
+ positional.push(token);
10042
+ continue;
10043
+ }
10044
+ const name = token.slice(2);
10045
+ if (!takesValue(positional[0], name)) {
10046
+ flags.set(name, true);
10047
+ continue;
10048
+ }
10049
+ const next = argv[i + 1];
10050
+ if (next !== void 0 && !next.startsWith("--")) {
10051
+ flags.set(name, next);
10052
+ i += 1;
10053
+ } else {
10054
+ flags.set(name, true);
10055
+ valueless.add(name);
10056
+ }
10057
+ }
10058
+ return { command: positional.shift() ?? "help", positional, flags, valueless };
10059
+ }
10060
+ function textFlag(args2, name) {
10061
+ const value = args2.flags.get(name);
10062
+ if (value === true) throw needsValue(args2, name);
10063
+ return value;
10064
+ }
10065
+ function needsValue(args2, name) {
10066
+ return new CliError(`--${name} needs a value.`, `wirl ${args2.command} --help lists what each flag takes.`, { code: "bad_flag" });
10067
+ }
10068
+ var NAMED_FLAGS = 6;
10069
+ function refuseUnknownFlags(args2, allowed) {
10070
+ const unknown = [...args2.flags.keys()].filter((name) => !allowed.has(name));
10071
+ if (unknown.length === 0) return;
10072
+ const shown2 = unknown.map((name) => {
10073
+ const equals = name.indexOf("=");
10074
+ return equals === -1 ? `--${name}` : `--${name.slice(0, equals)}=\u2026`;
10075
+ });
10076
+ const named = unknown.length > NAMED_FLAGS ? `${shown2.slice(0, NAMED_FLAGS).join(", ")} or ${String(unknown.length - NAMED_FLAGS)} more flags` : `${unknown.length === 1 ? shown2[0] : `${shown2.slice(0, -1).join(", ")} or ${shown2.at(-1)}`} flag`;
10077
+ const withValue = unknown.find((name) => name.includes("="));
10078
+ const where = withValue === void 0 ? "" : withValue.slice(0, withValue.indexOf("="));
10079
+ throw new CliError(
10080
+ `wirl ${args2.command} has no ${named}.`,
10081
+ `wirl ${args2.command} --help lists the flags it takes.${withValue === void 0 ? "" : ` A flag's value is the next word, as in --${where} <value>, not --${where}=<value>.`}`,
10082
+ { code: "bad_flag" }
10083
+ );
10084
+ }
10085
+ var EMPTY_IS_NONE = /* @__PURE__ */ new Set(["token", "api", "preview", "org", "app"]);
10086
+ function refuseBadFlags(args2) {
10087
+ const table = COMMAND_FLAGS.get(args2.command);
10088
+ if (table === void 0) return;
10089
+ refuseUnknownFlags(args2, /* @__PURE__ */ new Set([...table.values, ...table.booleans]));
10090
+ for (const name of args2.valueless) {
10091
+ if (table.values.has(name) && !table.own.has(name)) throw needsValue(args2, name);
10092
+ }
10093
+ for (const [name, value] of args2.flags) {
10094
+ if (value === "" && EMPTY_IS_NONE.has(name) && table.values.has(name) && !table.own.has(name)) throw needsValue(args2, name);
10095
+ }
10096
+ }
10097
+
9052
10098
  // packages/cli/src/dev.ts
9053
10099
  init_src();
9054
10100
  import { readdirSync as readdirSync2, realpathSync, statSync, watch as watchFolder } from "node:fs";
@@ -9356,7 +10402,12 @@ async function startLocalBroker(options) {
9356
10402
  const keysPath = join6(options.dir, KEYS_FILE);
9357
10403
  const log = options.log ?? (() => {
9358
10404
  });
9359
- const transport = createBrokerTransport({ fetchImpl: options.fetchImpl, pinning: options.pinning });
10405
+ const transport = createBrokerTransport({
10406
+ fetchImpl: options.fetchImpl,
10407
+ pinning: options.pinning,
10408
+ heldBodyLimits: options.heldBodyLimits,
10409
+ heldAnswerLimits: options.heldAnswerLimits
10410
+ });
9360
10411
  const mocks = options.mocks ?? /* @__PURE__ */ new Map();
9361
10412
  const fetchImpl = mocks.size === 0 ? transport.fetchImpl : routeToMocks(
9362
10413
  options.manifest,
@@ -9374,6 +10425,11 @@ async function startLocalBroker(options) {
9374
10425
  connectionsFor: async () => localConnections(options.manifest, await readLocalKeys(options.dir), keysPath, mocks),
9375
10426
  fetchImpl,
9376
10427
  streamSlots: transport.streamSlots,
10428
+ // This response, so that an event stream wirl cut waits for its
10429
+ // closing event to leave before the connection is cut (OIM-837).
10430
+ streamSent: sentOn(outgoing),
10431
+ heldBodies: transport.heldBodies,
10432
+ heldAnswers: transport.heldAnswers,
9377
10433
  audit: (entry) => {
9378
10434
  log(
9379
10435
  `wirl dev \xB7 ${entry.connection || "(no connection)"} ${entry.method} ${entry.host || ""} \u2192 ${entry.outcome}${entry.status ? ` ${entry.status}` : ""}`
@@ -9925,7 +10981,9 @@ var DEV_RELOAD_NOTE = `The server module, and every file it imports, is rebuilt
9925
10981
  var DEV_KEYS_NOTE = `Keys come from .wirl/keys.json, in the shape this prints for each connection that has none. To try a connection without its key, map it to a server of your own in .wirl/mocks.json, like { "anthropic": "http://127.0.0.1:4010" }. A call to one of that connection's declared hosts then goes to that URL instead, its path added to the URL's own and its query kept. It carries the key from .wirl/keys.json, or, when that has none, the placeholder "wirl-dev-mock" in the shape of the connection's kind. With no key there, an http_header connection sends no key header, since only .wirl/keys.json names the header, and a none connection never sends one. A host the connection does not declare is refused as before. .wirl/mocks.json is read once, at start, and never uploaded.`;
9926
10982
  var DEV_FLAGS_NOTE = "A flag not listed below, other than --help, is refused, not ignored.";
9927
10983
  var DEV_TWO_PEOPLE_NOTE = "To try two people on one app, run wirl dev a second time in the same folder with another --user: the two share .wirl/local.sqlite, so each sees what the other writes.";
9928
- var PREVIEW_LINK_WARNING = "Whoever opens this link in the next 15 minutes is signed in as you: open it in your browser, and do not share it, save it or put it in a file. To check a page or a stream from a script, use wirl fetch.";
10984
+ var PREVIEW_LINK_MINUTES = String(VISIT_TOKEN_TTL_SECONDS / 60);
10985
+ var PREVIEW_PASS_MINUTES = String(GRANT_TTL_DEFAULT_SECONDS / 60);
10986
+ var PREVIEW_LINK_WARNING = `Whoever holds this link is signed in as you when they open it, like a password: it works for ${PREVIEW_LINK_MINUTES} minutes, and the sign-in it gives a browser lasts ${PREVIEW_PASS_MINUTES} minutes. Open it in your browser, and do not share it, save it or put it in a file. To check a page or a stream from a script, use wirl fetch.`;
9929
10987
  var USAGE = `wirl \u2014 deploy an app somewhere real, behind a sign-in
9930
10988
 
9931
10989
  wirl login Sign in from this terminal (or --token wirl_...)
@@ -9942,6 +11000,7 @@ var USAGE = `wirl \u2014 deploy an app somewhere real, behind a sign-in
9942
11000
  wirl apps restore <name> Bring a deleted app back, inside its ${GRACE_DAYS} days (its owner while a member, or an organisation owner or admin)
9943
11001
  wirl delete [app] [--yes] Delete an app: restorable for ${GRACE_DAYS} days, and its name stays taken
9944
11002
  wirl share <email> [--role viewer|editor]
11003
+ wirl unshare <email> Take a person's share back (an invitation waiting for them too)
9945
11004
  wirl visibility [private|org] See or change who can open the app (public is a person's click on its Access page)
9946
11005
  wirl transfer <email> Hand the app to a new owner (you keep editor access)
9947
11006
  wirl move --to <org> Move the app to another organisation you belong to
@@ -10027,7 +11086,7 @@ ${DEV_FLAGS_NOTE}`,
10027
11086
  },
10028
11087
  deploy: {
10029
11088
  usage: ["wirl deploy [dir] [--org <slug>] [--app <slug>] [--no-create] [--json]"],
10030
- note: `[dir] defaults to wirl.json's "dir", else the current folder. A [dir] with a wirl.json of its own is a project: that wirl.json is the one used (app, org, server, static) and its "dir" is relative to it. A [dir] without one is build output: the wirl.json here is used and that folder goes up. A .wirlignore at its root keeps extra files out of the upload. New files in migrations/ are applied to the app's database before the version goes live; one that fails fails the deploy, and the version already live, if any, keeps serving. A destructive file (one wirl.json's "destructive" list must name) waits for a person before it applies to the live database: the deploy is refused with the address of a page where a person approves it. Run at a terminal, signed in as yourself (not with a deploy token) and without --json, wirl deploy shows the file's destructive lines and asks the person to type its name instead. No flag skips the person, --yes included. A preview never asks.`,
11089
+ note: `[dir] defaults to wirl.json's "dir", else the current folder. A [dir] with a wirl.json of its own is a project: that wirl.json is the one used (app, org, server, static) and its "dir" is relative to it. A [dir] without one is build output: the wirl.json here is used and that folder goes up. A .wirlignore at its root keeps extra files out of the upload. New files in migrations/ are applied to the app's database before the version goes live; one that fails fails the deploy, and the version already live, if any, keeps serving. A destructive file (one wirl.json's "destructive" list must name) waits for a person before it applies to the live database: the deploy is refused with the address of a page where a person approves it. Run at a terminal, signed in as yourself (not with a deploy token) and without --json, wirl deploy shows the file's destructive lines and asks the person to type its name instead. No flag skips the person, --yes included. A preview never asks. ` + versionNumbersSentence("wirl versions"),
10031
11090
  flags: [
10032
11091
  ORG_FLAG,
10033
11092
  APP_FLAG,
@@ -10106,13 +11165,19 @@ ${DEV_FLAGS_NOTE}`,
10106
11165
  share: {
10107
11166
  usage: ["wirl share <email> [--role viewer|editor] [--org <slug>] [--app <slug>] [--json]"],
10108
11167
  flags: [
10109
- { flag: "--role viewer|editor", desc: "Access level. Defaults to viewer." },
11168
+ { flag: "--role viewer|editor", desc: "Access level. A new person is a viewer; someone who already has access keeps their role unless you name one." },
10110
11169
  ORG_FLAG,
10111
11170
  APP_FLAG,
10112
11171
  JSON_FLAG
10113
11172
  ],
10114
11173
  example: "wirl share sam@acme.test --role editor"
10115
11174
  },
11175
+ unshare: {
11176
+ usage: ["wirl unshare <email> [--org <slug>] [--app <slug>] [--json]"],
11177
+ note: "With --json, shareRevoked and invitationWithdrawn say what was taken and appDeleted whether the app was deleted; revoked is always true (the request was carried out).",
11178
+ flags: [ORG_FLAG, APP_FLAG, JSON_FLAG],
11179
+ example: "wirl unshare sam@acme.test"
11180
+ },
10116
11181
  transfer: {
10117
11182
  usage: ["wirl transfer <email> [--org <slug>] [--app <slug>] [--json]"],
10118
11183
  flags: [ORG_FLAG, APP_FLAG, JSON_FLAG],
@@ -10251,6 +11316,7 @@ wirl migrations adopt runs once, for an app whose own code made its tables: the
10251
11316
  "wirl logs [--since 1h] [--errors] [--follow] [--limit n] [--cursor n]",
10252
11317
  " [--kind <kind>] [--run <id>] [--ray <id>] [--search <text>] [--preview <name>] [--org <slug>] [--app <slug>] [--json]"
10253
11318
  ],
11319
+ note: EDGE_REFUSALS_NOTE,
10254
11320
  flags: [
10255
11321
  { flag: "--since <window>", desc: "How far back to look, e.g. 1h, 30m. Defaults to 1h." },
10256
11322
  { flag: "--errors", desc: "Only failed requests." },
@@ -10375,11 +11441,10 @@ function personAt(input, output) {
10375
11441
  }
10376
11442
 
10377
11443
  // packages/cli/src/bin.ts
10378
- var out = (line = "") => process.stdout.write(`${escapeInvisible(line)}
11444
+ var out = (line = "") => process.stdout.write(`${terminalSafe(line)}
10379
11445
  `);
10380
- var err = (line = "") => process.stderr.write(`${escapeInvisible(line)}
11446
+ var err = (line = "") => process.stderr.write(`${terminalSafe(line)}
10381
11447
  `);
10382
- var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["json", "help", "create", "no-create", "no-skill", "print", "server", "force", "errors", "follow", "fast", "verbose", "yes"]);
10383
11448
  function readTypedLine(question) {
10384
11449
  return new Promise((done) => {
10385
11450
  const typed = createInterface2({ input: process.stdin, output: process.stderr, terminal: false });
@@ -10387,7 +11452,7 @@ function readTypedLine(question) {
10387
11452
  typed.on("close", () => {
10388
11453
  if (!answered) done(null);
10389
11454
  });
10390
- process.stderr.write(escapeInvisible(question));
11455
+ process.stderr.write(terminalSafe(question));
10391
11456
  typed.once("line", (line) => {
10392
11457
  answered = true;
10393
11458
  typed.close();
@@ -10395,39 +11460,6 @@ function readTypedLine(question) {
10395
11460
  });
10396
11461
  });
10397
11462
  }
10398
- function parseArgs(argv) {
10399
- const positional = [];
10400
- const flags = /* @__PURE__ */ new Map();
10401
- for (let i = 0; i < argv.length; i += 1) {
10402
- const token = argv[i] ?? "";
10403
- if (token.startsWith("--")) {
10404
- const name = token.slice(2);
10405
- const next = argv[i + 1];
10406
- if (!BOOLEAN_FLAGS.has(name) && next !== void 0 && !next.startsWith("--")) {
10407
- flags.set(name, next);
10408
- i += 1;
10409
- } else {
10410
- flags.set(name, true);
10411
- }
10412
- } else {
10413
- positional.push(token);
10414
- }
10415
- }
10416
- return { command: positional.shift() ?? "help", positional, flags };
10417
- }
10418
- function textFlag(args2, name) {
10419
- const value = args2.flags.get(name);
10420
- if (value === true) {
10421
- throw new CliError(`--${name} needs a value.`, `wirl ${args2.command} --help lists what each flag takes.`, { code: "bad_flag" });
10422
- }
10423
- return value;
10424
- }
10425
- function refuseUnknownFlags(args2, allowed) {
10426
- const unknown = [...args2.flags.keys()].filter((name) => !allowed.has(name)).map((name) => `--${name}`);
10427
- if (unknown.length === 0) return;
10428
- const named = unknown.length === 1 ? unknown[0] : `${unknown.slice(0, -1).join(", ")} or ${unknown.at(-1)}`;
10429
- throw new CliError(`wirl ${args2.command} has no ${named} flag.`, `wirl ${args2.command} --help lists the flags it takes.`, { code: "bad_flag" });
10430
- }
10431
11463
  var NAMED_WORDS = 6;
10432
11464
  function nameWords(words) {
10433
11465
  const shown2 = words.map((word) => word === "" ? '""' : word);
@@ -10454,6 +11486,8 @@ function parsePortFlag(raw) {
10454
11486
  }
10455
11487
  return port;
10456
11488
  }
11489
+ var unknownMembersCommand = (sub) => new CliError(`Unknown members command: ${sub}`, "wirl members list | add <email> --role | role <email> --role | remove <email>");
11490
+ var unknownConnectionsCommand = (sub) => new CliError(`Unknown: wirl connections ${sub}`, "list, create, rotate, delete, show, grants, pending, approve, revoke, request");
10457
11491
  var RUNS_SUBCOMMANDS = /* @__PURE__ */ new Set(["show", "send", "cancel", "retry"]);
10458
11492
  var REFUSES_AS_JSON = /* @__PURE__ */ new Set(["flows", "runs"]);
10459
11493
  var refusesAsJson = (args2) => args2.flags.has("json") && REFUSES_AS_JSON.has(args2.command);
@@ -10487,9 +11521,12 @@ async function main() {
10487
11521
  return 0;
10488
11522
  }
10489
11523
  if (args.command === "login") {
11524
+ refuseBadFlags(args);
10490
11525
  refuseExtraPositionals("login", args.positional, 0, "no argument", { secret: true, hint: "A token goes after --token: wirl login --token <token>." });
10491
- const token = String(args.flags.get("token") ?? "").trim();
10492
- const apiOrigin = String(args.flags.get("api") ?? apiOriginFromEnv()).replace(/\/+$/, "");
11526
+ const given = textFlag(args, "token");
11527
+ const token = (given ?? "").trim();
11528
+ if (given !== void 0 && token === "") throw needsValue(args, "token");
11529
+ const apiOrigin = String(textFlag(args, "api") ?? apiOriginFromEnv()).replace(/\/+$/, "");
10493
11530
  if (token) {
10494
11531
  out(`Saved to ${await saveCredentials({ apiOrigin, token })}`);
10495
11532
  return 0;
@@ -10510,6 +11547,7 @@ async function main() {
10510
11547
  return 0;
10511
11548
  }
10512
11549
  if (args.command === "init") {
11550
+ refuseBadFlags(args);
10513
11551
  refuseExtraPositionals("init", args.positional, 1, "one folder", { hint: folderHint("The app's name goes after --app <name>.") });
10514
11552
  const dir = resolve6(args.positional[0] ?? ".");
10515
11553
  const app = String(args.flags.get("app") ?? basename4(dir));
@@ -10524,6 +11562,7 @@ async function main() {
10524
11562
  return 0;
10525
11563
  }
10526
11564
  if (args.command === "skill") {
11565
+ refuseBadFlags(args);
10527
11566
  refuseExtraPositionals("skill", args.positional, 1, "one folder", { hint: folderHint() });
10528
11567
  if (args.flags.has("print")) {
10529
11568
  out(SKILL_MARKDOWN);
@@ -10535,6 +11574,7 @@ async function main() {
10535
11574
  return 0;
10536
11575
  }
10537
11576
  if (args.command === "dev") {
11577
+ refuseBadFlags(args);
10538
11578
  refuseUnknownFlags(args, DEV_FLAGS);
10539
11579
  refuseExtraPositionals("dev", args.positional, 1, "one folder", { hint: folderHint() });
10540
11580
  const portFlag = textFlag(args, "port");
@@ -10572,6 +11612,7 @@ async function main() {
10572
11612
  return 0;
10573
11613
  }
10574
11614
  const creds = await loadCredentials();
11615
+ refuseBadFlags(args);
10575
11616
  switch (args.command) {
10576
11617
  case "whoami": {
10577
11618
  refuseExtraPositionals("whoami", args.positional, 0, "no argument");
@@ -10790,6 +11831,8 @@ async function main() {
10790
11831
  });
10791
11832
  } else if (sub === "remove") {
10792
11833
  refuseExtraPositionals("members remove", args.positional.slice(1), 1, "one email", { hint: "Run it once for each person." });
11834
+ } else {
11835
+ throw unknownMembersCommand(sub);
10793
11836
  }
10794
11837
  const org = String(args.flags.get("org") ?? (await loadManifest("."))?.org ?? "") || (await resolveTarget(creds, args, ".")).org;
10795
11838
  if (sub === "list") {
@@ -10835,7 +11878,7 @@ async function main() {
10835
11878
  }
10836
11879
  return 0;
10837
11880
  }
10838
- throw new CliError(`Unknown members command: ${sub}`, "wirl members list | add <email> --role | role <email> --role | remove <email>");
11881
+ throw unknownMembersCommand(sub);
10839
11882
  }
10840
11883
  case "connections": {
10841
11884
  const sub = args.positional[0] ?? "list";
@@ -10858,6 +11901,8 @@ async function main() {
10858
11901
  refuseExtraPositionals("connections show", words, 1, "one app", { hint: "wirl connections grants lists every app and what it uses (admins)." });
10859
11902
  } else if (sub === "approve" || sub === "revoke" || sub === "request") {
10860
11903
  refuseExtraPositionals(`connections ${sub}`, words, 2, "an app and a connection", { hint: "Run it once for each connection." });
11904
+ } else {
11905
+ throw unknownConnectionsCommand(sub);
10861
11906
  }
10862
11907
  const orgSlug = String(args.flags.get("org") ?? (await loadManifest("."))?.org ?? "");
10863
11908
  const org = orgSlug || (await resolveTarget(creds, args, ".")).org;
@@ -10975,7 +12020,7 @@ async function main() {
10975
12020
  out(`${sub === "approve" ? "Approved" : "Revoked"} ${slug} for ${app}.`);
10976
12021
  return 0;
10977
12022
  }
10978
- throw new CliError(`Unknown: wirl connections ${sub}`, "list, create, rotate, delete, show, grants, pending, approve, revoke, request");
12023
+ throw unknownConnectionsCommand(sub);
10979
12024
  }
10980
12025
  case "delete": {
10981
12026
  const preview = args.flags.get("preview");
@@ -11057,6 +12102,7 @@ async function main() {
11057
12102
  case "visibility": {
11058
12103
  refuseExtraPositionals("visibility", args.positional, 1, "one level");
11059
12104
  const level = args.positional[0];
12105
+ if (level) refuseVisibilityLevel(level);
11060
12106
  const target = await resolveTarget(creds, args, ".");
11061
12107
  const now = level ? await changeVisibility(creds, { org: target.org, app: target.app, level }) : await readVisibility(creds, target.org, target.app);
11062
12108
  out(json2 ? JSON.stringify({ visibility: now }) : describeVisibility(now, target.org));
@@ -11068,12 +12114,23 @@ async function main() {
11068
12114
  });
11069
12115
  const email = args.positional[0];
11070
12116
  if (!email) throw new CliError("Who should it be shared with?", "wirl share someone@company.com");
11071
- const role = args.flags.get("role") === "editor" ? "editor" : "viewer";
12117
+ const role = refuseShareRole(args.flags.get("role"));
11072
12118
  const target = await resolveTarget(creds, args, ".");
11073
12119
  const result = await shareApp(creds, target.org, target.app, email, role);
11074
- out(
11075
- json2 ? JSON.stringify(result) : result.pending ? `Invited ${email} as ${role}. They will have access the moment they first sign in.${unmailedNote(result)}` : `Shared with ${email} as ${role}.`
11076
- );
12120
+ out(json2 ? JSON.stringify(result) : shareSentence(result, email, role, "cli"));
12121
+ return 0;
12122
+ }
12123
+ case "unshare": {
12124
+ refuseExtraPositionals("unshare", args.positional, 1, "one email", {
12125
+ hint: "Take back one person's whole share at a time. To change a role instead, use wirl share --role."
12126
+ });
12127
+ const email = args.positional[0];
12128
+ if (!email) throw new CliError("Whose access?", "wirl unshare someone@company.com");
12129
+ const target = await resolveTarget(creds, args, ".");
12130
+ if (!isWellFormedSlug(target.app)) throw new CliError(`"${target.app}" is not an app's name, so nothing was changed.`, void 0, { code: "bad_name" });
12131
+ if (!isWellFormedSlug(target.org)) throw new CliError(`"${target.org}" is not an organisation's name, so nothing was changed.`, void 0, { code: "bad_name" });
12132
+ const result = await unshareApp(creds, target.org, target.app, email);
12133
+ out(json2 ? JSON.stringify(result) : unshareSentence(result, email));
11077
12134
  return 0;
11078
12135
  }
11079
12136
  case "versions": {