@wirl/mcp 0.2.0 → 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 +10 -6
  2. package/bin.js +670 -140
  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}`;
@@ -1794,6 +1822,9 @@ var init_migrations = __esm({
1794
1822
  leaseMs: 6e5,
1795
1823
  orgApplies: 2,
1796
1824
  // per org: the limit one tenant's applies meet
1825
+ // Per app, its live database and its previews' together, inside the org's
1826
+ // two, so one app's deployers can never take both (OIM-927).
1827
+ appApplies: 1,
1797
1828
  // Per control-plane process. The lease holds no pooled connection, so this
1798
1829
  // bounds the process's work, not its pool; at `orgApplies` two each,
1799
1830
  // filling it takes eight orgs (T5b-6).
@@ -2695,7 +2726,7 @@ var init_runtime = __esm({
2695
2726
  });
2696
2727
 
2697
2728
  // packages/core/src/broker-stream.ts
2698
- var BROKER_LIMITS, STREAMED_MEDIA_TYPES;
2729
+ var BROKER_LIMITS, STREAMED_MEDIA_TYPES, MIB;
2699
2730
  var init_broker_stream = __esm({
2700
2731
  "packages/core/src/broker-stream.ts"() {
2701
2732
  "use strict";
@@ -2712,15 +2743,132 @@ var init_broker_stream = __esm({
2712
2743
  streamIdleMs: 4 * 6e4,
2713
2744
  /** Bytes in any one answer, streamed or read whole. */
2714
2745
  answerBytes: 25 * 1024 * 1024,
2746
+ /**
2747
+ * Bytes in the body of any one call an app makes. The body is read whole
2748
+ * before the call is sent, and before its grant is looked up, so a body
2749
+ * past this never opens a key and never reaches a vendor (OIM-931).
2750
+ * 100 MiB: Cloudflare publishes 100 MB as the request body its Free and
2751
+ * Pro plans take, and a deployed app's call is first held whole by the
2752
+ * egress Worker, in an isolate Cloudflare gives 128 MB of memory. Nothing
2753
+ * else bounds a caller that holds a broker token and reaches the control
2754
+ * plane itself: Railway sets no size on a body, only five minutes to send it.
2755
+ */
2756
+ requestBytes: 100 * 1024 * 1024,
2757
+ /**
2758
+ * Bytes of call bodies one app's calls hold at once, its previews' with
2759
+ * them (OIM-951): counted as each chunk arrives, from a call's first byte
2760
+ * until the broker has answered it (broker-bodies.ts). At least
2761
+ * `requestBytes`, so one call at the cap always fits an app that holds
2762
+ * nothing else; a quarter of `heldBodyBytes`, so four apps of four owners
2763
+ * at their share fill the whole (one owner's apps cannot: `ownerHeldBodyBytes`).
2764
+ */
2765
+ appHeldBodyBytes: 128 * 1024 * 1024,
2766
+ /**
2767
+ * Bytes of call bodies the calls of every app one owner has hold at once,
2768
+ * their previews' with them (OIM-967): the person `apps.owner_user_id`
2769
+ * names, whichever organisations their apps are in. Apps and
2770
+ * organisations cost nothing to create (no cap on either), so a share per
2771
+ * app let one person with four apps fill the whole, and one per
2772
+ * organisation would let them do it with four organisations; a person
2773
+ * costs a sign-in. Two apps at their share, so one app that runs
2774
+ * away still leaves its owner's other apps room, and half of
2775
+ * `heldBodyBytes`, so one owner can never take more than half, and a
2776
+ * second sign-in is what it takes to fill it. At least `requestBytes`, so
2777
+ * one call at the cap always fits an owner whose apps hold nothing else.
2778
+ */
2779
+ ownerHeldBodyBytes: 256 * 1024 * 1024,
2780
+ /**
2781
+ * Bytes of call bodies every app's calls hold at once, in this process.
2782
+ * A call's reader starts at 16 KiB and doubles its buffer as the body
2783
+ * arrives, so the buffers behind this hold at most twice it, 1 GiB, and
2784
+ * 16 KiB a call beside (the ruling in OIM-951).
2785
+ */
2786
+ heldBodyBytes: 512 * 1024 * 1024,
2787
+ /** The `Retry-After` of a call refused for either. */
2788
+ heldBodyRetryAfterSeconds: 10,
2789
+ /**
2790
+ * How long a call's body may send nothing before its read is stopped:
2791
+ * an upload's (OIM-935). A deployed app's body comes from the egress
2792
+ * Worker, which holds it whole before it sends it, and `wirl dev` reads it
2793
+ * whole first, so no ordinary sender ever pauses (fix round 1, R1-F2).
2794
+ */
2795
+ heldBodyStallMs: 3e4,
2796
+ /**
2797
+ * How long a call's body may take to arrive whole: 100 MiB in 120 seconds
2798
+ * is about 7 Mbit/s, which a body between Cloudflare and the control plane
2799
+ * passes many times over, and well under Node's 300 seconds a request,
2800
+ * after which the body would be cut anyway (fix round 1, R1-F2).
2801
+ */
2802
+ heldBodyArrivalMs: 12e4,
2803
+ /**
2804
+ * Bytes of answers read whole that one app's calls hold at once, its
2805
+ * previews' with them (OIM-966): counted as each chunk arrives from the
2806
+ * vendor, from an answer's first byte until wirl's server has taken the
2807
+ * last of it, or the app has gone (broker-bodies.ts). A vendor's event
2808
+ * stream is not counted: it is passed on as the app reads it, and holds
2809
+ * about 285 to 441 KiB at a time, measured end to end through the control
2810
+ * plane's server (`passOn` in broker.ts; OIM-966 fix round 1, R1-F3,
2811
+ * R2-F2). More than two `answerBytes`, so two answers at the cap fit an
2812
+ * app that holds nothing else; a quarter of `heldAnswerBytes`, so four apps
2813
+ * of four owners at their share fill the whole (one owner's apps cannot:
2814
+ * `ownerHeldAnswerBytes`).
2815
+ */
2816
+ appHeldAnswerBytes: 64 * 1024 * 1024,
2817
+ /**
2818
+ * Bytes of answers read whole that the calls of every app one owner has
2819
+ * hold at once, their previews' with them (OIM-967), for the reasons
2820
+ * `ownerHeldBodyBytes` gives: two apps at their share, half of
2821
+ * `heldAnswerBytes`, and more than two `answerBytes` and than the room a
2822
+ * call needs, so an owner whose apps hold nothing else can always send.
2823
+ */
2824
+ ownerHeldAnswerBytes: 128 * 1024 * 1024,
2825
+ /**
2826
+ * Bytes of answers read whole that every app's calls hold at once, in this
2827
+ * process. An answer's reader doubles its buffer as it fills, as a body's
2828
+ * does, and never past `answerBytes`, so while answers arrive the buffers
2829
+ * behind this hold at most twice it, 512 MiB, and 16 KiB an answer beside;
2830
+ * an answer read whole is copied to exactly its size before it is handed
2831
+ * on, so from then its count is its memory (the ruling in OIM-966, and its
2832
+ * fix round 1, R1-F2).
2833
+ */
2834
+ heldAnswerBytes: 256 * 1024 * 1024,
2835
+ /**
2836
+ * The room an app's share of answers, and the whole, must each have left
2837
+ * for a call to be sent at all: one answer at the cap. With less, the call
2838
+ * is refused with a 503 before it leaves wirl, rather than run at its
2839
+ * vendor and its answer cut (OIM-966 fix round 1, R1-F1).
2840
+ */
2841
+ heldAnswerRoomBytes: 25 * 1024 * 1024,
2842
+ /** The `Retry-After` of a call refused for that room, before it was sent. */
2843
+ heldAnswerRetryAfterSeconds: 10,
2844
+ /**
2845
+ * How long an answer read whole may wait for wirl's server to take the
2846
+ * last of it once it is handed back: past this it is cut, and its count
2847
+ * goes back, so no app holds its share, or the whole, by not reading.
2848
+ * Under Cloudflare's measured 125 seconds with nothing moving, which ends
2849
+ * a deployed app's unread answer anyway (OIM-966 fix round 1, R1-F1).
2850
+ */
2851
+ heldAnswerHandOutMs: 12e4,
2715
2852
  /**
2716
2853
  * Streamed answers one app may have open at once. Each holds a vendor
2717
2854
  * socket and an inbound connection on one control-plane instance for up
2718
2855
  * to fourteen minutes, so one app cannot be allowed an unbounded number.
2719
2856
  * Counted in memory, per instance, like the control plane's other limits.
2720
2857
  */
2721
- openStreamsPerApp: 20
2858
+ openStreamsPerApp: 20,
2859
+ /**
2860
+ * How long an event stream wirl cut waits for its closing event to leave
2861
+ * the server before it ends the stream with its error anyway (OIM-837 fix
2862
+ * round 1). The event is on its way when the server's queue of what it has
2863
+ * to write has emptied; a reader that is slow but reading empties a full
2864
+ * kernel buffer, a few hundred kilobytes, well inside this. Past it the
2865
+ * reader is not reading, and the stream ends as it always did; the write
2866
+ * stall that holds a connection longer than this is OIM-968's.
2867
+ */
2868
+ cutEventSentMs: 5e3
2722
2869
  };
2723
2870
  STREAMED_MEDIA_TYPES = Object.freeze(["text/event-stream", "application/x-ndjson"]);
2871
+ MIB = 1024 * 1024;
2724
2872
  }
2725
2873
  });
2726
2874
 
@@ -2788,20 +2936,21 @@ arrives: pass \`response.body\` straight on. There are no WebSockets. A front en
2788
2936
  \`"static": "dist"\` serves that folder's files at their own paths (\`/\` is its \`index.html\`) and the module
2789
2937
  answers everything else; add \`"spa": true\` for a client-side router. Nothing outside that folder is served.
2790
2938
 
2791
- Preview before you deploy: \`wirl preview\` puts the folder up at its own address with
2792
- its own database (empty at first), and \`wirl fetch <path> --preview <name>\` (or the
2793
- \`wirl_preview_fetch\` tool) shows you what it serves.
2939
+ Preview before you deploy: \`wirl preview\` gives a folder its own address and database (at first only what
2940
+ \`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.
2794
2941
 
2795
2942
  npm packages work: install them in the project and the deploy bundles them in. Nothing is installed on the
2796
2943
  server: native code will not run. Node built-ins work (\`node:crypto\`, \`node:path\`, ...; \`Buffer\` and
2797
2944
  \`process\` are global), but what \`node:fs\` writes is gone by the next request: use \`env.DB\`.
2798
2945
 
2799
2946
  \`env\` holds \`DB\` and nothing else. \`env.DB\` is Cloudflare D1: prepared statements with \`.bind()\`, at most
2800
- 100 bound parameters a statement. \`exec()\` runs one statement per line. Declare a host under \`connections\`
2801
- in \`wirl.json\`, then \`fetch\` the vendor's real URL: wirl attaches the key. An undeclared host is refused; a
2802
- public API needing no key uses \`"kind": "none"\`. Declaring one deploys before anyone approves it;
2803
- the deploy says per connection whether it is usable now or waiting on an admin. A vendor has 60
2804
- seconds to answer, whole, unless it streams: ask a model for a stream.
2947
+ 100 bound parameters a statement. \`exec()\` runs one statement per line, as does each file in \`migrations/\`,
2948
+ which wirl applies in order to a preview and before going live; a failing one fails the deploy. Never edit
2949
+ an applied file. Dropping or renaming a table or column needs \`"migrations": { "destructive": [...] }\`, its
2950
+ own live deploy and a person's yes. Declare a host under \`connections\`, then \`fetch\` the vendor's real URL:
2951
+ wirl attaches the key. An undeclared host is refused; a public API needing no key uses \`"kind": "none"\`.
2952
+ Declaring one deploys before anyone approves it; the deploy says per connection whether it is usable now or
2953
+ waiting on an admin. A vendor has 60 seconds to answer, whole, unless it streams: ask a model for a stream.
2805
2954
 
2806
2955
  Six headers say who is asking: \`x-wirl-user-id\`, \`x-wirl-user-email\`, \`x-wirl-user-name\`,
2807
2956
  \`x-wirl-role\`, \`x-wirl-app-id\`, \`x-wirl-org\`. Read the name with
@@ -2811,10 +2960,9 @@ Schedules POST to \`/__wirl/schedule/<name>\`; a run gets 5 minutes unless that
2811
2960
  says otherwise, and at most 15. A \`public_paths\` path takes requests with no sign-in.
2812
2961
  Work that outlives one request (waiting for a person, trying tomorrow, a long loop) is a flow: see Flows.
2813
2962
 
2814
- A new app defaults to org-wide visibility when its org has a company email domain,
2815
- and to private otherwise, including one made with \`wirl orgs create\`.
2816
- Sharing by email adds one named person; \`wirl visibility private|org\` (or the tool)
2817
- sets the level. Public is only ever a person's click on the app's Access page.
2963
+ A new app defaults to org-wide visibility when its org has a company email domain, and to private
2964
+ otherwise, including one made with \`wirl orgs create\`; \`wirl visibility private|org\` sets the level.
2965
+ Sharing by email adds one named person. Public is only ever a person's click on the app's Access page.
2818
2966
 
2819
2967
  Porting an existing project: route it through the \`fetch\` handler, replace ORM calls with
2820
2968
  \`env.DB.prepare(...).bind(...)\`, turn timers into \`schedules\`, and move keys into \`connections\`. Keep the
@@ -2846,18 +2994,67 @@ early, repeated or late one gets 409, or 404 once the run ends; check \`x-wirl-u
2846
2994
  }
2847
2995
  });
2848
2996
 
2997
+ // packages/core/src/version.ts
2998
+ function wirlVersion() {
2999
+ return BUILT_VERSION ?? WORKSPACE_VERSION;
3000
+ }
3001
+ var WORKSPACE_VERSION, BUILT_VERSION;
3002
+ var init_version = __esm({
3003
+ "packages/core/src/version.ts"() {
3004
+ "use strict";
3005
+ WORKSPACE_VERSION = "0.0.0";
3006
+ BUILT_VERSION = true ? "0.2.2" : null;
3007
+ }
3008
+ });
3009
+
2849
3010
  // packages/core/src/install.ts
3011
+ function fallbackWhen(controlPlane = "this control plane") {
3012
+ return `If your npm registry does not carry wirl, or to run exactly the build ${controlPlane} serves,`;
3013
+ }
3014
+ function namedControlPlane(controlOrigin) {
3015
+ return `the wirl control plane (${controlOrigin.replace(/^https?:\/\//, "").replace(/\/+$/, "")})`;
3016
+ }
2850
3017
  function installFor(controlOrigin) {
2851
- if (NPM_PUBLISHED) return { cli: "wirl", mcp: "@wirl/mcp" };
2852
3018
  const origin = controlOrigin.replace(/\/+$/, "");
2853
- return { cli: `${origin}/cli/wirl.tgz`, mcp: `${origin}/cli/wirl-mcp.tgz` };
3019
+ const tarball = { cli: `${origin}/cli/wirl.tgz`, mcp: `${origin}/cli/wirl-mcp.tgz` };
3020
+ if (NPM_PUBLISHED) return { cli: "wirl", mcp: "@wirl/mcp", tarball };
3021
+ return { ...tarball, tarball };
2854
3022
  }
2855
- var NPM_PUBLISHED, WIRL_BIN;
3023
+ var NPM_PUBLISHED, WIRL_BIN, FALLBACK_WHEN, hasFallback;
2856
3024
  var init_install = __esm({
2857
3025
  "packages/core/src/install.ts"() {
2858
3026
  "use strict";
2859
- NPM_PUBLISHED = false;
3027
+ NPM_PUBLISHED = true;
2860
3028
  WIRL_BIN = NPM_PUBLISHED ? "npx wirl" : "wirl";
3029
+ FALLBACK_WHEN = fallbackWhen();
3030
+ hasFallback = (install) => install.cli !== install.tarball.cli;
3031
+ }
3032
+ });
3033
+
3034
+ // packages/core/src/share-role.ts
3035
+ function readShareRole(value) {
3036
+ if (value === void 0 || value === null) return { ok: true, role: void 0 };
3037
+ if (value === AppRole.VIEWER || value === AppRole.EDITOR) return { ok: true, role: value };
3038
+ return { ok: false, message: SHARE_ROLE_REFUSAL };
3039
+ }
3040
+ var SHARE_ROLE_REFUSAL;
3041
+ var init_share_role = __esm({
3042
+ "packages/core/src/share-role.ts"() {
3043
+ "use strict";
3044
+ init_types();
3045
+ SHARE_ROLE_REFUSAL = "role must be viewer or editor.";
3046
+ }
3047
+ });
3048
+
3049
+ // packages/core/src/connect.ts
3050
+ var CLAUDE_CODE_SIGN_IN, CLAUDE_CODE_LOCAL_SIGN_IN, CLI_LOGIN_DOES;
3051
+ var init_connect = __esm({
3052
+ "packages/core/src/connect.ts"() {
3053
+ "use strict";
3054
+ init_install();
3055
+ 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.";
3056
+ 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.`;
3057
+ CLI_LOGIN_DOES = "prints a link and a code to approve in the browser";
2861
3058
  }
2862
3059
  });
2863
3060
 
@@ -3081,6 +3278,66 @@ var init_porting = __esm({
3081
3278
  }
3082
3279
  });
3083
3280
 
3281
+ // packages/core/src/throttle-report.ts
3282
+ var THROTTLE_REPORT, EDGE_REFUSALS_NOTE;
3283
+ var init_throttle_report = __esm({
3284
+ "packages/core/src/throttle-report.ts"() {
3285
+ "use strict";
3286
+ init_public_path();
3287
+ THROTTLE_REPORT = {
3288
+ /**
3289
+ * One report per app a minute at most from each gate isolate, and what
3290
+ * one report counts spans a minute at most.
3291
+ */
3292
+ windowMs: 6e4,
3293
+ /**
3294
+ * How long the refusal that opens a window waits for more before the
3295
+ * report goes, so a burst is one report with its count rather than a
3296
+ * report of its first refusal.
3297
+ */
3298
+ gatherMs: 15e3,
3299
+ /**
3300
+ * The longest a refused request is asked to stay open, after its 429 has
3301
+ * gone, for the report to be sent. Cloudflare keeps a request's
3302
+ * `waitUntil` work for 30 seconds after the response, shared by all of
3303
+ * it, and the send itself has `sendTimeoutMs` of what is left.
3304
+ */
3305
+ maxWaitMs: 2e4,
3306
+ sendTimeoutMs: 5e3,
3307
+ /**
3308
+ * Reports one request carries for windows no refusal could wait for
3309
+ * (`flushDue`). A request that was never refused must not queue its own
3310
+ * reads behind posts that are not its own: Cloudflare runs six of a
3311
+ * request's connections at once and queues the rest.
3312
+ */
3313
+ flushPerRequest: 2,
3314
+ /** Apps one gate isolate counts for at once; a refusal for another app past this is not counted. */
3315
+ maxApps: 1e3,
3316
+ /** Lines in one report: each of an app's public paths, by each ceiling. */
3317
+ maxEntries: 2 * PUBLIC_PATH_LIMITS.perApp,
3318
+ /** A path is cut to this many characters, as `public.refused`'s is. */
3319
+ maxPathChars: 256,
3320
+ /**
3321
+ * The most of a report's body the control plane reads. A full report is
3322
+ * under 8 KiB: a path a request matched is ASCII, as a URL's path is.
3323
+ */
3324
+ bodyBytes: 16384,
3325
+ /**
3326
+ * Reports the control plane reads for one app a minute, from every isolate
3327
+ * and location together. Of those, the first writes the app's one
3328
+ * `public.throttled` row for that minute and the rest write nothing.
3329
+ */
3330
+ perAppPerMinute: 10,
3331
+ /** The org's Activity page summarises this many apps' latest `public.throttled` lines, a line an app, from the last `logsPageDays` days. */
3332
+ activityLines: 5,
3333
+ /** The app's Logs page shows its latest this many `public.throttled` lines, from the last `logsPageDays` days. */
3334
+ logsPageLines: 20,
3335
+ logsPageDays: 7
3336
+ };
3337
+ 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.";
3338
+ }
3339
+ });
3340
+
3084
3341
  // packages/core/src/supplied-bundle.ts
3085
3342
  var SUPPLIED_BUNDLE_PATH, MAX_SUPPLIED_BUNDLE_BYTES;
3086
3343
  var init_supplied_bundle = __esm({
@@ -3105,6 +3362,9 @@ var init_manifest_transport = __esm({
3105
3362
  });
3106
3363
 
3107
3364
  // packages/core/src/preview.ts
3365
+ function versionNumbersSentence(listing) {
3366
+ 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.`;
3367
+ }
3108
3368
  var PREVIEW_TTL_DAYS, MAX_PREVIEWS_PER_APP;
3109
3369
  var init_preview = __esm({
3110
3370
  "packages/core/src/preview.ts"() {
@@ -3142,8 +3402,12 @@ var init_src = __esm({
3142
3402
  init_broker_stream();
3143
3403
  init_retention();
3144
3404
  init_contract();
3405
+ init_version();
3145
3406
  init_install();
3407
+ init_share_role();
3408
+ init_connect();
3146
3409
  init_porting();
3410
+ init_throttle_report();
3147
3411
  init_supplied_bundle();
3148
3412
  init_manifest_transport();
3149
3413
  init_preview();
@@ -4789,7 +5053,7 @@ init_src();
4789
5053
  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";
4790
5054
  import { homedir } from "node:os";
4791
5055
  import { basename as basename2, dirname as dirname3, isAbsolute, join as join5, relative, resolve as resolve4, sep } from "node:path";
4792
- import { create as tarCreate, extract as tarExtract } from "tar";
5056
+ import { create as tarCreate, extract as tarExtract, Parser as TarParser } from "tar";
4793
5057
  import { gzipSync } from "node:zlib";
4794
5058
 
4795
5059
  // packages/core/src/node/preview-name.ts
@@ -4797,10 +5061,9 @@ init_slug();
4797
5061
  import { createHash } from "node:crypto";
4798
5062
  var HASH_SUFFIX_LENGTH = 5;
4799
5063
  function previewNameFor(raw, orgSlug, appSlug) {
4800
- const slug = slugify(raw);
4801
- if (!slug) {
4802
- return { ok: false, message: "That name has no usable characters, or is reserved." };
4803
- }
5064
+ const made = previewSlugFor(raw);
5065
+ if (!made.ok) return { ok: false, message: made.message };
5066
+ const slug = made.slug;
4804
5067
  const budget = MAX_HOST_LABEL - (appLabel(orgSlug, appSlug).length + HOST_SEPARATOR.length);
4805
5068
  if (budget < 2) {
4806
5069
  return {
@@ -4920,6 +5183,64 @@ function shellPath(value) {
4920
5183
  return shellQuote(value);
4921
5184
  }
4922
5185
 
5186
+ // packages/cli/src/skill.ts
5187
+ init_src();
5188
+ var SKILL_INTRO = `A new app is visible to everyone in the org when the org came from a
5189
+ company email domain; in any other org, including a personal one or one
5190
+ made with \`wirl orgs create\`, it starts private. Deploys are one
5191
+ command; sharing is by email; a third-party API is declared, never pasted.`;
5192
+ 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.";
5193
+ 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).
5194
+ 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.
5195
+ 3. If a command names several organisations and asks which, ask the person; pass \`--org\` or put \`"org"\` in \`wirl.json\`.
5196
+ 4. To keep a file out of the deploy, list it in a \`.wirlignore\` at the project root (gitignore-style lines; no \`!\` negation).
5197
+ 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.`;
5198
+ var SKILL_MANIFEST_EXAMPLE = `\`\`\`json
5199
+ { "app": "crm-sync", "server": "server.mjs", "connections": [{ "slug": "hubspot", "hosts": ["api.hubspot.com"] }] }
5200
+ \`\`\``;
5201
+ 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.";
5202
+ 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.';
5203
+ function composeSkill(parts) {
5204
+ if (parts.description.trim() === "" || /[\r\n]/.test(parts.description)) {
5205
+ throw new Error("a skill description is one non-empty line");
5206
+ }
5207
+ return `---
5208
+ name: wirl
5209
+ description: ${parts.description}
5210
+ ---
5211
+
5212
+ # wirl
5213
+
5214
+ ${SKILL_INTRO}
5215
+
5216
+ ${SKILL_DOCS_LINE}
5217
+
5218
+ ## Commands
5219
+
5220
+ ${parts.commands.trimEnd()}
5221
+
5222
+ ## Rules
5223
+
5224
+ ${SKILL_RULES}
5225
+
5226
+ ## wirl.json
5227
+
5228
+ ${SKILL_MANIFEST_EXAMPLE}
5229
+
5230
+ ## What runs on wirl
5231
+
5232
+ ${BUILD_CONTRACT}
5233
+
5234
+ ${SKILL_FILES_PARAGRAPH}
5235
+
5236
+ ${SKILL_STATIC_PARAGRAPH}
5237
+
5238
+ ## Flows
5239
+
5240
+ ${FLOWS_CONTRACT}
5241
+ `;
5242
+ }
5243
+
4923
5244
  // packages/cli/src/core.ts
4924
5245
  function credentialsPath(env = process.env) {
4925
5246
  return env["WIRL_CREDENTIALS_PATH"] || join5(homedir(), ".config", "wirl", "credentials.json");
@@ -4980,7 +5301,7 @@ async function loadCredentials(env = process.env) {
4980
5301
  }
4981
5302
  } catch {
4982
5303
  }
4983
- throw new CliError("Not signed in.", "Run `wirl login` first, or set WIRL_TOKEN.", { code: "not_signed_in" });
5304
+ throw new CliError("Not signed in.", `Run \`${WIRL_BIN} login\` first, or set WIRL_TOKEN.`, { code: "not_signed_in" });
4984
5305
  }
4985
5306
  async function saveCredentials(creds, env = process.env) {
4986
5307
  const path = credentialsPath(env);
@@ -5041,11 +5362,11 @@ async function pollDeviceLogin(apiOrigin, login, options = {}) {
5041
5362
  continue;
5042
5363
  }
5043
5364
  case "expired_token":
5044
- throw new CliError("The sign-in code expired before it was approved.", "Run `wirl login` again.");
5365
+ throw new CliError("The sign-in code expired before it was approved.", `Run \`${WIRL_BIN} login\` again.`);
5045
5366
  default:
5046
5367
  throw new CliError(
5047
5368
  typeof body["error"] === "string" ? `Sign-in failed: ${body["error"]}.` : `Sign-in failed (${response.status}).`,
5048
- "Run `wirl login` again."
5369
+ `Run \`${WIRL_BIN} login\` again.`
5049
5370
  );
5050
5371
  }
5051
5372
  }
@@ -5096,6 +5417,10 @@ var SERVER_TEMPLATE = [
5096
5417
  " *",
5097
5418
  " * Everything real happens here. The page is HTML this module returns. The",
5098
5419
  " * data lives in env.DB, a SQLite database that belongs to this app alone.",
5420
+ " * Its tables are in migrations/0001_init.sql: wirl applies each new file in",
5421
+ " * migrations/ before a version goes live, to a preview's database when the",
5422
+ " * preview is put up, and to the local one when wirl dev starts. Put the",
5423
+ " * next change in migrations/0002_<what>.sql rather than editing that one.",
5099
5424
  " * The person making the request is in headers wirl sets after verifying",
5100
5425
  " * them, which the browser cannot forge. Any rule that matters, such as",
5101
5426
  ' * "you only see your own notes", is a line of code in this file: a check',
@@ -5107,18 +5432,8 @@ var SERVER_TEMPLATE = [
5107
5432
  " * live in .wirl/keys.json beside this file, which git ignores.",
5108
5433
  " */",
5109
5434
  "",
5110
- "// One line: exec runs one statement per line, as D1 does.",
5111
- "const SCHEMA =",
5112
- ' "create table if not exists notes (" +',
5113
- ' " id integer primary key," +',
5114
- ' " user_id text not null," +',
5115
- ' " body text not null," +',
5116
- ` " created_at text not null default (datetime('now'))" +`,
5117
- ' ")";',
5118
- "",
5119
5435
  "export default {",
5120
5436
  " async fetch(request, env) {",
5121
- " await env.DB.exec(SCHEMA);",
5122
5437
  " const url = new URL(request.url);",
5123
5438
  "",
5124
5439
  ` // One line per request. Read it with ${WIRL_BIN} logs.`,
@@ -5201,56 +5516,34 @@ var SERVER_TEMPLATE = [
5201
5516
  "}",
5202
5517
  ""
5203
5518
  ].join("\n");
5519
+ var INIT_MIGRATION = [
5520
+ "-- This app's tables. wirl applies each new file here, in order, before a version goes live.",
5521
+ "-- Make the next change in 0002_<what>.sql; never edit a file that has been deployed.",
5522
+ "create table notes (id integer primary key, user_id text not null, body text not null, created_at text not null default (datetime('now')))",
5523
+ ""
5524
+ ].join("\n");
5525
+ var INIT_MIGRATION_PATH = `${MIGRATIONS_DIR}/${ADOPTED_FILE}`;
5204
5526
  var SKILL_PATH = join5(".claude", "skills", "wirl", "SKILL.md");
5205
- var SKILL_MARKDOWN = `---
5206
- name: wirl
5207
- 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.
5208
- ---
5209
-
5210
- # wirl
5211
-
5212
- A new app is visible to everyone in the org when the org came from a
5213
- company email domain; in any other org, including a personal one or one
5214
- made with \`wirl orgs create\`, it starts private. Deploys are one
5215
- command; sharing is by email; a third-party API is declared, never pasted.
5216
-
5217
- 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.
5218
-
5219
- ## Commands
5527
+ var SKILL_ORIGIN = "https://app.wirl.dev";
5528
+ var SKILL_INSTALL = installFor(SKILL_ORIGIN);
5529
+ var SKILL_RUN = NPM_PUBLISHED ? `Run the CLI as \`${WIRL_BIN} <command>\`, which needs no install beyond Node 22.13 or later (in a terminal npm asks "Ok to proceed?" the first time: answer y); this file writes a command as \`wirl <command>\` for short anywhere but the list below.
5220
5530
 
5221
- - 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. No HTTP MCP support? \`claude mcp add wirl -- npx -y ${installFor("https://app.wirl.dev").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.
5531
+ ` : "";
5532
+ 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}\`.
5533
+ ` : "";
5534
+ var SKILL_MARKDOWN = composeSkill({
5535
+ 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.`,
5536
+ 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.
5222
5537
  - \`${WIRL_BIN} deploy\` \u2014 deploys this directory. Prints the URL and who can open it.
5223
5538
  - \`${WIRL_BIN} share someone@company.com\` \u2014 that person can open it. Add \`--role editor\` so they can deploy too.
5224
5539
  - \`${WIRL_BIN} dev\` \u2014 runs the app here as it will run deployed, signed in as a stub user.
5225
5540
  - \`${WIRL_BIN} pull <app>\` \u2014 fetches a deployed app's source to keep building.
5226
5541
  - \`${WIRL_BIN} logs\` \u2014 what the deployed app printed, per request, newest first; \`--errors\` for failures only, \`--follow\` to keep watching, \`--cursor <n>\` for the page before a "More before this" line. Read this before changing code when something misbehaves.
5542
+ - \`${WIRL_BIN} migrations\` \u2014 what this app's database has applied, read only.
5543
+ - \`${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.
5227
5544
  - \`${WIRL_BIN} --help\` \u2014 everything else: orgs, members, connections, move, transfer, rollback, flows and runs.
5228
-
5229
- ## Rules
5230
-
5231
- 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).
5232
- 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.
5233
- 3. If a command names several organisations and asks which, ask the person; pass \`--org\` or put \`"org"\` in \`wirl.json\`.
5234
- 4. To keep a file out of the deploy, list it in a \`.wirlignore\` at the project root (gitignore-style lines; no \`!\` negation).
5235
-
5236
- ## wirl.json
5237
-
5238
- \`\`\`json
5239
- { "app": "crm-sync", "server": "server.mjs", "connections": [{ "slug": "hubspot", "hosts": ["api.hubspot.com"] }] }
5240
- \`\`\`
5241
-
5242
- ## What runs on wirl
5243
-
5244
- ${BUILD_CONTRACT}
5245
-
5246
- 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.
5247
-
5248
- 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.
5249
-
5250
- ## Flows
5251
-
5252
- ${FLOWS_CONTRACT}
5253
- `;
5545
+ ${SKILL_FALLBACK}`
5546
+ });
5254
5547
  async function pullNextStep(dir) {
5255
5548
  return `cd ${shellPath(dir)} && ${await pulledDevCommand(dir)}`;
5256
5549
  }
@@ -5522,7 +5815,7 @@ async function callApi(creds, path, options = {}) {
5522
5815
  throw new CliError(body.message, void 0, refusal);
5523
5816
  }
5524
5817
  if (response.status === 401 || response.status === 403) {
5525
- throw new CliError("Not signed in, or this token cannot do that.", "Try `wirl login`.", refusal);
5818
+ throw new CliError("Not signed in, or this token cannot do that.", `Try \`${WIRL_BIN} login\`.`, refusal);
5526
5819
  }
5527
5820
  if (response.status === 404) {
5528
5821
  if (body.error !== void 0 && body.error !== "not_found" && body.message) throw new CliError(body.message, void 0, refusal);
@@ -5554,30 +5847,58 @@ async function callApiBytes(creds, path, options = {}) {
5554
5847
  body = {};
5555
5848
  }
5556
5849
  if (response.status === 401 || response.status === 403) {
5557
- throw new CliError("Not signed in, or this token cannot do that.", "Try `wirl login`.");
5850
+ throw new CliError("Not signed in, or this token cannot do that.", `Try \`${WIRL_BIN} login\`.`);
5558
5851
  }
5559
5852
  if (response.status === 404) throw new CliError("Not found, or you do not have access to it.");
5560
5853
  throw new CliError(body.message ?? body.error ?? `Request failed (${response.status}).`);
5561
5854
  }
5562
5855
  const declared = Number(response.headers.get("content-length") ?? 0);
5563
5856
  if (declared > MAX_PULL_BYTES) throw new CliError(`That is ${Math.round(declared / 1048576)} MB, more than wirl will pull.`);
5564
- const bytes = new Uint8Array(await response.arrayBuffer());
5857
+ let bytes;
5858
+ try {
5859
+ bytes = new Uint8Array(await response.arrayBuffer());
5860
+ } catch {
5861
+ throw new CliError(PULL_CUT, void 0, { code: "pull_cut" });
5862
+ }
5565
5863
  if (bytes.byteLength > MAX_PULL_BYTES) throw new CliError("The download was larger than wirl will pull.");
5566
5864
  return bytes;
5567
5865
  }
5866
+ var PULL_CUT = "The pull was cut before it finished, so nothing was written. Run it again.";
5867
+ var pullShort = (dir) => `The pull's archive is cut short or damaged, so nothing was written to ${dir}. Run it again.`;
5568
5868
  var MAX_PULL_BYTES = 256 * 1048576;
5569
5869
  var pullApp = (creds, org, app, options = {}) => callApiBytes(creds, `/api/v1/apps/${seg(org)}/${seg(app)}/pull`, options.fetchImpl ? { fetchImpl: options.fetchImpl } : {});
5870
+ function isWholeArchive(archive) {
5871
+ return new Promise((resolve6, reject) => {
5872
+ let sound = true;
5873
+ let ended2 = false;
5874
+ const parser = new TarParser({ onReadEntry: (entry) => void entry.resume() });
5875
+ parser.on("warn", (code) => {
5876
+ if (code === "TAR_BAD_ARCHIVE" || code === "TAR_ENTRY_INVALID") sound = false;
5877
+ });
5878
+ parser.on("eof", () => {
5879
+ ended2 = true;
5880
+ });
5881
+ parser.on("error", (error) => typeof error.code === "string" && error.code.startsWith("Z_") ? resolve6(false) : reject(error));
5882
+ parser.on("end", () => resolve6(sound && ended2));
5883
+ parser.end(archive);
5884
+ });
5885
+ }
5570
5886
  async function unpackPull(bytes, dir, options = {}) {
5571
- await mkdir3(dir, { recursive: true });
5572
- const existing = await readdir(dir);
5887
+ const existing = await readdir(dir).catch((error) => {
5888
+ if (error.code === "ENOENT") return [];
5889
+ throw error;
5890
+ });
5573
5891
  if (existing.length > 0 && !options.force) {
5574
5892
  throw new CliError(`${dir} is not empty.`, "Pull into a new directory, or pass --force to overwrite.");
5575
5893
  }
5894
+ const archive = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
5895
+ if (!await isWholeArchive(archive)) throw new CliError(pullShort(dir), void 0, { code: "pull_cut" });
5896
+ await mkdir3(dir, { recursive: true });
5576
5897
  await new Promise((resolve6, reject) => {
5577
5898
  const extractor = tarExtract({ cwd: dir });
5578
5899
  extractor.on("error", reject);
5579
5900
  extractor.on("finish", resolve6);
5580
- extractor.end(Buffer.from(bytes));
5901
+ extractor.end(archive);
5581
5902
  });
5582
5903
  const written = [];
5583
5904
  await collect(dir, dir, written);
@@ -5935,10 +6256,13 @@ function describeVisibility(level, org) {
5935
6256
  }
5936
6257
  }
5937
6258
  var readVisibility = (creds, org, app) => callApi(creds, `/api/v1/apps/${seg(org)}/${seg(app)}`).then((r) => r.app.visibility ?? "unknown");
5938
- async function changeVisibility(creds, options) {
5939
- if (!VISIBILITY_LEVELS.includes(options.level)) {
5940
- throw new CliError(`Visibility is private, org or public, not "${String(options.level)}".`);
6259
+ function refuseVisibilityLevel(level) {
6260
+ if (!VISIBILITY_LEVELS.includes(level)) {
6261
+ throw new CliError(`Visibility is private, org or public, not "${String(level)}".`);
5941
6262
  }
6263
+ }
6264
+ async function changeVisibility(creds, options) {
6265
+ refuseVisibilityLevel(options.level);
5942
6266
  if (options.level === "public") {
5943
6267
  throw new CliError(
5944
6268
  "Public means anyone on the internet with the link can open it, without signing in, so a person decides that with their own click.",
@@ -5953,11 +6277,59 @@ async function changeVisibility(creds, options) {
5953
6277
  return result2.visibility;
5954
6278
  }
5955
6279
  var unmailedNote = (result2) => result2.mailed === false && result2.message ? ` ${result2.message}` : "";
6280
+ function refuseShareRole(value) {
6281
+ const read = readShareRole(value);
6282
+ if (read.ok) return read.role;
6283
+ const shown2 = typeof value === "string" ? terminalSafe(value.slice(0, 40)) : typeof value;
6284
+ throw new CliError(`Role is viewer or editor, not "${shown2}".`);
6285
+ }
5956
6286
  var shareApp = (creds, org, app, email, role) => callApi(creds, `/api/v1/apps/${seg(org)}/${seg(app)}/share`, {
5957
6287
  method: "POST",
5958
- body: JSON.stringify({ email, role }),
6288
+ // No role is no `role`: the control plane then keeps the one a person has.
6289
+ body: JSON.stringify(role === void 0 ? { email } : { email, role }),
5959
6290
  contentType: "application/json"
5960
6291
  });
6292
+ function shareSentence(result2, email, asked, voice) {
6293
+ const roleOf = (value) => value === "viewer" || value === "editor" ? value : void 0;
6294
+ const outcome = ["added", "kept", "unchanged", "changed"].includes(String(result2.outcome)) ? result2.outcome : void 0;
6295
+ const role = roleOf(result2.role);
6296
+ const old = outcome === void 0 || role === void 0;
6297
+ const now = role ?? asked ?? "viewer";
6298
+ const was = roleOf(result2.previousRole);
6299
+ const article = (name) => name === "editor" ? "an editor" : "a viewer";
6300
+ const mail = unmailedNote(result2);
6301
+ const signIn = voice === "cli" ? "the moment they first sign in" : "when they first sign in";
6302
+ if (result2.pending) {
6303
+ const wait = `They will have access ${signIn}.`;
6304
+ if (!old && outcome === "kept") return `${email} was already invited as ${now}, and no role was given, so it stays ${now}. ${wait}${mail}`;
6305
+ const lead2 = `Invited ${email} as ${now}. ${wait}`;
6306
+ if (old || outcome === "added") return `${lead2}${mail}`;
6307
+ if (outcome === "unchanged") return `${lead2} The invitation already said that.${mail}`;
6308
+ if (was === void 0) return `${lead2} The invitation's role was changed.${mail}`;
6309
+ return `${lead2} The invitation said ${was} before.${mail}`;
6310
+ }
6311
+ if (!old && outcome === "kept") {
6312
+ const change = voice === "cli" ? "Pass --role viewer or --role editor" : 'Pass role "viewer" or "editor"';
6313
+ return `${email} already had access as ${now}, and no role was given, so it stays ${now}. ${change} to change it.`;
6314
+ }
6315
+ const lead = voice === "cli" ? `Shared with ${email} as ${now}.` : `${email} can now open the app as ${now}.`;
6316
+ if (old || outcome === "added") return lead;
6317
+ if (outcome === "unchanged") return `${lead} They already had that role.`;
6318
+ if (was === void 0) return `${lead} Their role was changed.`;
6319
+ return `${lead} They were ${article(was)} before.`;
6320
+ }
6321
+ var unshareApp = (creds, org, app, email) => callApi(creds, `/api/v1/apps/${seg(org)}/${seg(app)}/share`, {
6322
+ method: "DELETE",
6323
+ body: JSON.stringify({ email }),
6324
+ contentType: "application/json"
6325
+ });
6326
+ function unshareSentence(result2, email) {
6327
+ const share = result2.shareRevoked ?? result2.revoked;
6328
+ const invitation = result2.invitationWithdrawn ?? false;
6329
+ 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.`;
6330
+ const wasDeleted = result2.appDeleted === true && (share || invitation) ? " The app was deleted when this was asked, so a restore will not bring it back." : "";
6331
+ 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.`;
6332
+ }
5961
6333
  var listDeployments = (creds, org, app) => callApi(
5962
6334
  creds,
5963
6335
  `/api/v1/apps/${seg(org)}/${seg(app)}/deployments`
@@ -6085,7 +6457,7 @@ var fetchLogs = (creds, org, app, filters) => callApi(
6085
6457
  );
6086
6458
  var LINE_INDENT = " ";
6087
6459
  function terminalSafe(text) {
6088
- return escapeInvisible(text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`));
6460
+ 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")}`));
6089
6461
  }
6090
6462
  function utcSecond(at2) {
6091
6463
  const ms = Date.parse(at2);
@@ -6121,7 +6493,8 @@ function formatLogPage(page, since) {
6121
6493
  // queryable. Without this an empty answer right after a request
6122
6494
  // reads as "nothing happened".
6123
6495
  " A request made in the last minute may not have arrived yet: ask again shortly."
6124
- )}` : page.invocations.map((entry) => formatInvocation(entry)).join("\n\n");
6496
+ )}
6497
+ ${EDGE_REFUSALS_NOTE}` : page.invocations.map((entry) => formatInvocation(entry)).join("\n\n");
6125
6498
  return page.cursor === null ? body : `${body}
6126
6499
 
6127
6500
  More before this: pass cursor=${page.cursor}.`;
@@ -6521,20 +6894,22 @@ async function previewFetch(creds, org, app, name, path, options) {
6521
6894
  const headers = callerHeaders(options.headers);
6522
6895
  headers.set(VISIT_HEADER, mint.token);
6523
6896
  const networkFetch = options.networkFetchImpl ?? fetch;
6524
- const sentAt = Date.now();
6897
+ const clock = options.now ?? (() => performance.now());
6898
+ const sentAt = clock();
6899
+ const elapsed = () => Math.round(clock() - sentAt);
6525
6900
  const response = await networkFetch(target.toString(), {
6526
6901
  method: options.method ?? (options.body !== void 0 ? "POST" : "GET"),
6527
6902
  headers,
6528
6903
  redirect: "manual",
6529
6904
  ...options.body === void 0 ? {} : { body: options.body }
6530
6905
  });
6531
- const headersMs = Date.now() - sentAt;
6906
+ const headersMs = elapsed();
6532
6907
  const contentType = response.headers.get("content-type") ?? "";
6533
6908
  const location = response.headers.get("location");
6534
6909
  const text = isTextBody(contentType);
6535
6910
  const streamWaitMs = Math.min(options.streamWaitMs ?? FETCH_STREAM_WAIT_MS, FETCH_WAIT_MAX_SECONDS * 1e3);
6536
6911
  const keep = options.keepBodyBytes === void 0 ? null : Math.min(options.keepBodyBytes, FETCH_FILE_MAX_BYTES);
6537
- const read = await readBounded(response, streamWaitMs, sentAt, keep ?? FETCH_READ_CEILING_BYTES);
6912
+ const read = await readBounded(response, streamWaitMs, elapsed, keep ?? FETCH_READ_CEILING_BYTES);
6538
6913
  const bytes = read.bytes;
6539
6914
  const truncated = text && (read.cut || bytes.byteLength > FETCH_BODY_CAP_BYTES);
6540
6915
  const media = (contentType.split(";")[0] ?? "").trim().toLowerCase();
@@ -6610,7 +6985,7 @@ var FETCH_STREAM_WAIT_MS = 1e4;
6610
6985
  var FETCH_WAIT_MAX_SECONDS = 300;
6611
6986
  var FETCH_READ_CEILING_BYTES = 8 * FETCH_BODY_CAP_BYTES;
6612
6987
  var FETCH_FILE_MAX_BYTES = 1e8;
6613
- async function readBounded(response, waitMs, sentAt, ceilingBytes) {
6988
+ async function readBounded(response, waitMs, elapsed, ceilingBytes) {
6614
6989
  if (response.body === null) return { bytes: new Uint8Array(0), chunks: [], stillStreaming: false, cut: false };
6615
6990
  const reader = response.body.getReader();
6616
6991
  const chunks = [];
@@ -6629,7 +7004,7 @@ async function readBounded(response, waitMs, sentAt, ceilingBytes) {
6629
7004
  break;
6630
7005
  }
6631
7006
  if (next.done) break;
6632
- chunks.push({ atMs: Date.now() - sentAt, bytes: next.value });
7007
+ chunks.push({ atMs: elapsed(), bytes: next.value });
6633
7008
  total += next.value.byteLength;
6634
7009
  if (total > ceilingBytes) {
6635
7010
  cut = true;
@@ -6674,9 +7049,9 @@ function formatFetchResult(result2, saved) {
6674
7049
  0,
6675
7050
  `answered after ${seconds(result2.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)`
6676
7051
  );
7052
+ const unfinished = lastLineUnfinished(result2);
6677
7053
  timed.forEach((line, i) => {
6678
- const unfinished = i === timed.length - 1 && !result2.timed.lastComplete && (!result2.bodyComplete || result2.truncated);
6679
- lines.push(line.text === "" ? "" : `+${seconds(line.atMs)}s ${line.text}${unfinished ? " (the line had not ended)" : ""}`);
7054
+ lines.push(line.text === "" ? "" : `+${seconds(line.atMs)}s ${line.text}${unfinished && i === timed.length - 1 ? " (the line had not ended)" : ""}`);
6680
7055
  });
6681
7056
  } else {
6682
7057
  lines.push(result2.bodyText);
@@ -6689,6 +7064,44 @@ function formatFetchResult(result2, saved) {
6689
7064
  }
6690
7065
  return lines.join("\n");
6691
7066
  }
7067
+ function lastLineUnfinished(result2) {
7068
+ return result2.timed !== null && !result2.timed.lastComplete && (!result2.bodyComplete || result2.truncated);
7069
+ }
7070
+ var STREAMED_DATA_MAX_LINES = 2e3;
7071
+ var STREAMED_DATA_MAX_TEXT_BYTES = 16 * 1024;
7072
+ function streamedFetchData(result2) {
7073
+ if (result2.bodyText === null || result2.timed === null) return null;
7074
+ const listed2 = listedLines(result2.timed.lines);
7075
+ return {
7076
+ status: result2.status,
7077
+ statusText: result2.statusText,
7078
+ contentType: result2.contentType,
7079
+ location: result2.location,
7080
+ refused: result2.refused,
7081
+ answeredAfterMs: result2.headersMs,
7082
+ streamWaitMs: result2.streamWaitMs,
7083
+ stillStreaming: result2.stillStreaming,
7084
+ bodyComplete: result2.bodyComplete,
7085
+ truncated: result2.truncated,
7086
+ bodyBytes: result2.bodyBytes,
7087
+ pieces: result2.timed.pieces,
7088
+ lastLineEnded: !lastLineUnfinished(result2),
7089
+ lines: listed2,
7090
+ linesLeftOut: result2.timed.lines.length - listed2.length
7091
+ };
7092
+ }
7093
+ function listedLines(all) {
7094
+ const listed2 = [];
7095
+ let bytes = 0;
7096
+ for (const line of all) {
7097
+ if (listed2.length >= STREAMED_DATA_MAX_LINES) break;
7098
+ const size = Buffer.byteLength(line.text);
7099
+ if (listed2.length > 0 && bytes + size > STREAMED_DATA_MAX_TEXT_BYTES) break;
7100
+ bytes += size;
7101
+ listed2.push({ atMs: line.atMs, text: line.text });
7102
+ }
7103
+ return listed2;
7104
+ }
6692
7105
  function seconds(ms) {
6693
7106
  return (ms / 1e3).toFixed(2);
6694
7107
  }
@@ -6709,7 +7122,7 @@ var ADOPT_SENTENCES2 = {
6709
7122
  */
6710
7123
  cannotTell: (what) => `wirl did not get the adopt route's answer (${what}), so it cannot tell whether the live database recorded migrations/${ADOPTED_FILE}, and nothing was written here. Run wirl migrations adopt again: while an adopted ${ADOPTED_FILE} is all the live database records, adopting again replaces that record and writes the file.`,
6711
7124
  /** After the call, so the live database records the file; a CLI that reads files otherwise than the control plane says this every time. */
6712
- notAsRecorded: (install) => `The live database now records migrations/${ADOPTED_FILE} as adopted, but the file wirl sent does not match its own record of it, so nothing was written here. Run wirl migrations adopt again: while an adopted ${ADOPTED_FILE} is all the live database records, adopting again replaces that record and writes the file. If it says this again, this copy of wirl reads migration files differently from the control plane: update it (npm install -g ${install}).`,
7125
+ notAsRecorded: (install) => `The live database now records migrations/${ADOPTED_FILE} as adopted, but the file wirl sent does not match its own record of it, so nothing was written here. Run wirl migrations adopt again: while an adopted ${ADOPTED_FILE} is all the live database records, adopting again replaces that record and writes the file. If it says this again, this copy of wirl reads migration files differently from the control plane: update it (npm install -g ${install.cli}${hasFallback(install) ? `, or, to match this control plane exactly, npm install -g ${install.tarball.cli}` : ""}).`,
6713
7126
  unwritten: (code) => `The live database now records migrations/${ADOPTED_FILE} as adopted, but wirl could not write that file here (${code}). Make migrations/ a folder you can write that holds no .sql file, then run wirl migrations adopt again: while an adopted ${ADOPTED_FILE} is all the live database records, adopting again replaces that record and writes the file.`,
6714
7127
  nothingWritten: "Nothing was written to migrations/.",
6715
7128
  /**
@@ -6887,7 +7300,7 @@ async function adoptIntoProject(creds, options) {
6887
7300
  const read = await readMigrations([{ name: ADOPTED_FILE, text: answer.file.text }], []);
6888
7301
  const [file, ...others] = read.ok ? read.files : [];
6889
7302
  if (file === void 0 || others.length > 0 || file.sha256 !== answer.file.sha256 || file.statements.length !== answer.file.statements) {
6890
- throw new CliError(ADOPT_SENTENCES2.notAsRecorded(installFor(creds.apiOrigin).cli));
7303
+ throw new CliError(ADOPT_SENTENCES2.notAsRecorded(installFor(creds.apiOrigin)));
6891
7304
  }
6892
7305
  const path = await writeAdopted(projectDir, answer.file.text);
6893
7306
  const { recordLocalAdoption: recordLocalAdoption2 } = await Promise.resolve().then(() => (init_migrations_local(), migrations_local_exports));
@@ -7039,6 +7452,8 @@ function formatMigrationsListing(listing, app) {
7039
7452
  init_src();
7040
7453
  import { hostname } from "node:os";
7041
7454
  import { basename as basename3, resolve as resolve5 } from "node:path";
7455
+ var reads = (title) => ({ title, readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false });
7456
+ var readsTheirText = (title) => ({ ...reads(title), openWorldHint: true });
7042
7457
  var APPS_DOMAIN = "wirl.run";
7043
7458
  var pendingLogin = null;
7044
7459
  function loginInstructions(login) {
@@ -7121,6 +7536,7 @@ var SHARED = [
7121
7536
  {
7122
7537
  name: "wirl_list_connections",
7123
7538
  description: `List the connections in the organisation: named credentials an app may be granted, such as a HubSpot or Stripe key, with the hosts each allows, who made each (by name and email) and whether it holds its key, is waiting for one, or needs none. Use this to learn the exact name to put under "connections" in wirl.json before deploying an app that needs one; a name already listed here is that connection, not a new one, and other apps may be granted it too. The app never receives the key: it calls the vendor's own real URL (fetch("https://api.hubspot.com/...")) and wirl attaches the key on the way out. Declaring one at deploy grants it at once only when you made it or are an org admin, signed in with full access, it was never revoked from that app, and no request for it is already waiting; otherwise, unless the app already holds it, it opens or keeps a request that its maker or an org admin approves. There is no tool to approve one.`,
7539
+ annotations: reads("List connections"),
7124
7540
  inputSchema: {
7125
7541
  type: "object",
7126
7542
  properties: {
@@ -7143,27 +7559,55 @@ var SHARED = [
7143
7559
  },
7144
7560
  {
7145
7561
  name: "wirl_share",
7146
- description: `Give a named colleague access to a deployed app, by email address. Use this when the user says someone should be able to see what they built. Access belongs to that person and can be taken back. Viewers are free \u2014 sharing never costs anything, so there is no reason to hesitate. Role "viewer" can open it; "editor" can also deploy to it. A connection with deploy-only access cannot share; if the server says so, tell the person to open the app's Access page in the dashboard instead.`,
7562
+ description: `Give a named colleague access to a deployed app, by email address. Use this when the user says someone should be able to see what they built. Access belongs to that person and can be taken back. Viewers are free \u2014 sharing never costs anything, so there is no reason to hesitate. Role "viewer" can open it; "editor" can also deploy to it. Sharing again with no role keeps the role a person has: pass "role" only to give or change one, and the answer says what the role is and, when it changed one, what it was. A connection with deploy-only access cannot share; if the server says so, tell the person to open the app's Access page in the dashboard instead.`,
7563
+ annotations: { title: "Share an app with a person", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
7147
7564
  inputSchema: {
7148
7565
  type: "object",
7149
7566
  properties: {
7150
7567
  app: { type: "string", description: 'The app slug, e.g. "quarterly-dashboard".' },
7151
7568
  email: { type: "string", description: "Work email address of the person." },
7152
- role: { type: "string", enum: ["viewer", "editor"], description: "Defaults to viewer." },
7569
+ role: {
7570
+ type: "string",
7571
+ enum: ["viewer", "editor"],
7572
+ description: "Leave it out unless the person should be given or moved to a role: a new person is a viewer, and someone who already has access, or is already invited, keeps the role they have. Naming one sets it, lowering an editor to viewer included."
7573
+ },
7574
+ org: { type: "string", description: "Organisation slug. Optional when the person belongs to only one; otherwise the tool says which ones exist and asks." }
7575
+ },
7576
+ required: ["app", "email"]
7577
+ },
7578
+ async handler(args, ctx) {
7579
+ const role = refuseShareRole(args["role"]);
7580
+ const org = await defaultOrg(ctx.creds, str(args, "org"));
7581
+ const email = str(args, "email");
7582
+ const result2 = await shareApp(ctx.creds, org, str(args, "app"), email, role);
7583
+ return shareSentence(result2, email, role, "mcp");
7584
+ }
7585
+ },
7586
+ {
7587
+ name: "wirl_unshare",
7588
+ description: `Take back a named person's access to a deployed app, by the email address it was shared with. Use this when the user says someone should no longer see what they built. It takes the whole share away, one person at a time; to change a role instead, use wirl_share with "role". It also withdraws an invitation that is still waiting for an address, and works while the app is deleted and can still be restored. It says what it found; finding nothing is not an error. Needs a sign-in with full access, like wirl_share.`,
7589
+ annotations: { title: "Take back a share", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
7590
+ inputSchema: {
7591
+ type: "object",
7592
+ properties: {
7593
+ app: { type: "string", description: 'The app slug, e.g. "quarterly-dashboard".' },
7594
+ email: { type: "string", description: "The email address the app was shared with." },
7153
7595
  org: { type: "string", description: "Organisation slug. Optional when the person belongs to only one; otherwise the tool says which ones exist and asks." }
7154
7596
  },
7155
7597
  required: ["app", "email"]
7156
7598
  },
7157
7599
  async handler(args, ctx) {
7600
+ refuseUnshareArgs(args);
7158
7601
  const org = await defaultOrg(ctx.creds, str(args, "org"));
7159
- const role = str(args, "role") === "editor" ? "editor" : "viewer";
7160
- const result2 = await shareApp(ctx.creds, org, str(args, "app"), str(args, "email"), role);
7161
- return result2.pending ? `Invited ${str(args, "email")} as ${role}. They will have access when they first sign in.${unmailedNote(result2)}` : `${str(args, "email")} can now open the app as ${role}.`;
7602
+ const email = str(args, "email");
7603
+ const result2 = await unshareApp(ctx.creds, org, str(args, "app"), email);
7604
+ return unshareSentence(result2, email);
7162
7605
  }
7163
7606
  },
7164
7607
  {
7165
7608
  name: "wirl_visibility",
7166
7609
  description: `See or change who can open an app. With no level it only reports the current one. "private": only its owner, the organisation's owners and admins, and the people it is shared with by email. "org": everyone signed in at the organisation. Use this when the person says who the app is for ("only me", "just my team and whoever I share it with", "the whole company"). It cannot make an app public, and nothing an agent can call can: anyone on the internet being able to open it is a decision a person takes with their own click, on the app's Access page in the dashboard, so if they ask for that, give them that link. To give one named person access use wirl_share instead. A connection with deploy-only access cannot change this either; if the server says so, send the person to the same Access page.`,
7610
+ annotations: { title: "See or set who can open an app", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
7167
7611
  inputSchema: {
7168
7612
  type: "object",
7169
7613
  properties: {
@@ -7187,6 +7631,7 @@ var SHARED = [
7187
7631
  {
7188
7632
  name: "wirl_list_apps",
7189
7633
  description: "List the apps the user can open, with their URLs and who can see them. Use this to find an app before deploying to it or sharing it, or when the user asks what they have already deployed.",
7634
+ annotations: reads("List apps"),
7190
7635
  inputSchema: { type: "object", properties: { org: { type: "string", description: "Organisation slug. Optional when the person belongs to only one; otherwise the tool says which ones exist and asks." } } },
7191
7636
  async handler(args, ctx) {
7192
7637
  const org = await defaultOrg(ctx.creds, str(args, "org"));
@@ -7198,6 +7643,7 @@ var SHARED = [
7198
7643
  {
7199
7644
  name: "wirl_list_versions",
7200
7645
  description: "List past deployments of an app, newest first, with their version numbers and ids. Use this before rolling back, to find which version to return to. A preview takes a version number from the same sequence and is never listed here, so the numbers can skip: a first deploy after one preview is v2.",
7646
+ annotations: reads("List app versions"),
7201
7647
  inputSchema: {
7202
7648
  type: "object",
7203
7649
  properties: {
@@ -7216,6 +7662,7 @@ var SHARED = [
7216
7662
  {
7217
7663
  name: "wirl_rollback",
7218
7664
  description: "Put an earlier deployment back to being the live one. Use this when a deploy broke something and the user wants the previous version back. Instant \u2014 nothing is rebuilt. Get the deployment id from wirl_list_versions first.",
7665
+ annotations: { title: "Roll an app back", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
7219
7666
  inputSchema: {
7220
7667
  type: "object",
7221
7668
  properties: {
@@ -7237,6 +7684,7 @@ var SHARED = [
7237
7684
  {
7238
7685
  name: "wirl_migrations",
7239
7686
  description: "List what one of an app's databases has applied, read only: its own record of migrations (_wirl_migrations), read now, and wirl's ledger of what it sent there, one line a file, saying where the two differ. Use this before changing anything when a deploy was refused over a migration, or said it could not tell whether a file applied. It writes nothing, and needs the access a deploy needs.",
7687
+ annotations: reads("Show applied migrations"),
7240
7688
  inputSchema: {
7241
7689
  type: "object",
7242
7690
  properties: {
@@ -7257,6 +7705,7 @@ var SHARED = [
7257
7705
  {
7258
7706
  name: "wirl_list_schedules",
7259
7707
  description: "List an app's schedules (from its wirl.json) with the last run's outcome and the next run time. Use this to see whether a scheduled job is healthy.",
7708
+ annotations: reads("List schedules"),
7260
7709
  inputSchema: {
7261
7710
  type: "object",
7262
7711
  properties: {
@@ -7275,6 +7724,7 @@ var SHARED = [
7275
7724
  {
7276
7725
  name: "wirl_run",
7277
7726
  description: "Run one of an app's schedules right now and wait for the outcome (up to the schedule's timeout). Use this after deploying a schedule to check it works, instead of waiting for the clock. With `preview`, runs one of a preview's own schedules instead: it must be declared in the previewed wirl.json (a preview writes no schedule rows, so nothing of its fires on a clock), and the outcome comes back at once, with no queue to wait on.",
7727
+ annotations: { title: "Run a schedule now", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
7278
7728
  inputSchema: {
7279
7729
  type: "object",
7280
7730
  properties: {
@@ -7308,6 +7758,7 @@ var SHARED = [
7308
7758
  {
7309
7759
  name: "wirl_list_flows",
7310
7760
  description: `List the flows an app declares under "flows" in its wirl.json, each with its step timeout, its retries, how many of its runs are unfinished and its last run. Use this to find a flow's exact name before wirl_start_flow, or to see whether runs are piling up. With preview, lists what that preview's own build declares instead of the live app's.`,
7761
+ annotations: reads("List flows"),
7311
7762
  inputSchema: {
7312
7763
  type: "object",
7313
7764
  properties: {
@@ -7328,6 +7779,7 @@ var SHARED = [
7328
7779
  {
7329
7780
  name: "wirl_start_flow",
7330
7781
  description: "Start a run of one of an app's flows. Use this to try a flow you have built: put it up with wirl_preview, start it here with preview and fast (fast, for a preview's run only, makes each sleep, until and retry delay the flow asks for 5 seconds, each wait's timeout 60 seconds, and each retry of a failing step 10 seconds, 90 after a try that ran out of time; wirl_retry_run still refuses a failed run until 4 minutes after it failed), follow it with wirl_show_run, answer each wait it reaches with wirl_send_event, and read what each step printed with wirl_logs with the run's id, and with preview for a preview's run. A key names the run in the app's own terms: starting the flow again with the same key finds the run it started instead of starting another, until 30 days after that run ends, so give each new try a new key. The input is at most 64 KiB: keep rows in env.DB and pass ids. Where flows are not on for the organisation yet, the answer says so and nothing starts.",
7782
+ annotations: { title: "Start a flow run", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
7331
7783
  inputSchema: {
7332
7784
  type: "object",
7333
7785
  properties: {
@@ -7373,6 +7825,7 @@ var SHARED = [
7373
7825
  {
7374
7826
  name: "wirl_list_runs",
7375
7827
  description: "List an app's flow runs, newest first, each with its status, the step it is at and when it started. Use this to find a run to look at with wirl_show_run, or to see which runs failed or are waiting. Narrow it with flow and status. A preview's runs are listed only with preview, and the live app's only without it.",
7828
+ annotations: reads("List flow runs"),
7376
7829
  inputSchema: {
7377
7830
  type: "object",
7378
7831
  properties: {
@@ -7397,6 +7850,7 @@ var SHARED = [
7397
7850
  {
7398
7851
  name: "wirl_show_run",
7399
7852
  description: `Show one flow run: its status, who started it, what it waits for or sleeps until or, while wirl is still creating it, when wirl tries again, the step it goes on with next and since or from when (a step reports only when it ends, so one that is running says it has not reported yet), its output or error, its page on the dashboard, its newest ${SHOWN_RUN_ROWS} attempts with the version of the app that answered each and each attempt's deadline, and the earlier attempts that went wrong, and its newest ${SHOWN_RUN_ROWS} events with who sent each and whether it reached the run (the run's page lists every one). Use this after wirl_start_flow to follow a run step by step, and to learn which event a waiting run asks for before wirl_send_event. A preview's run is found only with preview, and a live run only without it.`,
7853
+ annotations: readsTheirText("Show a flow run"),
7400
7854
  inputSchema: {
7401
7855
  type: "object",
7402
7856
  properties: {
@@ -7417,6 +7871,7 @@ var SHARED = [
7417
7871
  {
7418
7872
  name: "wirl_send_event",
7419
7873
  description: "Answer what a preview's run waits for: send it the event its wait asks for, with an optional JSON object as the event's body. Use this to walk a flow through its waits while trying it on a preview, once wirl_show_run says which event the run waits for. The event is sent as you, so its from.email is your own address, and an app that takes a decision only from people it lists takes yours only if you are listed; to try that path, list yourself in the build you preview, and never loosen the check. It is for a preview's runs only: a live run is answered by a person, on the app's own page or the run's page on the dashboard, so this refuses a live run before sending anything and says where the person goes. Only a wait open for that event takes it, and only the first answer to a wait counts.",
7874
+ annotations: { title: "Answer a wait on a preview run", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
7420
7875
  inputSchema: {
7421
7876
  type: "object",
7422
7877
  properties: {
@@ -7458,6 +7913,7 @@ var SHARED = [
7458
7913
  {
7459
7914
  name: "wirl_cancel_run",
7460
7915
  description: "Cancel a flow run: a step already running is stopped where it is. Use this when a run is stuck, was started by mistake, or is doing the wrong thing. A run that had already finished is left as it is, and the answer says so. A preview's run is found only with preview, and a live run only without it.",
7916
+ annotations: { title: "Cancel a flow run", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
7461
7917
  inputSchema: {
7462
7918
  type: "object",
7463
7919
  properties: {
@@ -7478,6 +7934,7 @@ var SHARED = [
7478
7934
  {
7479
7935
  name: "wirl_retry_run",
7480
7936
  description: "Run a failed flow run again, from the step it failed at, or from an earlier step with from. Use this when what made the run fail has passed, such as a vendor that was down, or once a fix is deployed live, rather than starting a new run. A run can be retried from four minutes after it failed until three days after, while Cloudflare still keeps it; outside that the answer says why. A preview's failed run cannot be retried once the preview has been deployed again or removed, or has expired: start a new run. A preview's run is found only with preview, and a live run only without it. Where flows are not on for the organisation yet, the answer says so.",
7937
+ annotations: { title: "Retry a failed flow run", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
7481
7938
  inputSchema: {
7482
7939
  type: "object",
7483
7940
  properties: {
@@ -7507,6 +7964,7 @@ var SHARED = [
7507
7964
  {
7508
7965
  name: "wirl_logs",
7509
7966
  description: "What a deployed app printed, grouped per request (page load, webhook, schedule run, flow step), newest first, with the status and any exception. Use this first when a deploy misbehaves, a schedule or webhook failed, or someone reports an error, before changing code. Defaults to the last hour. A line usually appears within a minute of the request, not at once; if the run you expect is missing, wait and read again. With `preview`, reads that preview's own script instead of the live app's history. Logs are what the app printed, as far as Cloudflare kept them: Workers Logs can miss a request's lines, all of them at times. A run's record is wirl_show_run and the run's page, not its logs: never count a run's steps from them.",
7967
+ annotations: readsTheirText("Read app logs"),
7510
7968
  inputSchema: {
7511
7969
  type: "object",
7512
7970
  properties: {
@@ -7559,6 +8017,7 @@ var SHARED = [
7559
8017
  {
7560
8018
  name: "wirl_egress",
7561
8019
  description: "What a deployed app called out through wirl, call by call, newest first: when, the method and host, the connection, the status, and allowed or the code wirl refused it with; a flow step's call also names its run, step number n and attempt, and the path it called, never its query string. No header or body is recorded. Use this to check which calls a page, a schedule or a flow step actually made to a vendor, or why one was refused, before changing code: two calls of a retried step differ by attempt. Defaults to the last hour, at most seven days back. A flow step's call refused because its deadline had passed is not recorded. Only the app's own calls, for someone who can deploy it, as its logs are.",
8020
+ annotations: readsTheirText("Read app outgoing calls"),
7562
8021
  inputSchema: {
7563
8022
  type: "object",
7564
8023
  properties: {
@@ -7590,6 +8049,7 @@ var SHARED = [
7590
8049
  {
7591
8050
  name: "wirl_list_previews",
7592
8051
  description: `List an app's previews: folders put up at their own address, never live, each with its own database (its tables from migrations/, and at first no rows but the ones those files insert). Use this to find a preview's exact name before calling wirl_preview_fetch or wirl_delete_preview, or to see when one expires. An app holds at most ${MAX_PREVIEWS_PER_APP} previews at once, each for up to ${PREVIEW_TTL_DAYS} days unless redeployed.`,
8052
+ annotations: reads("List previews"),
7593
8053
  inputSchema: {
7594
8054
  type: "object",
7595
8055
  properties: {
@@ -7607,7 +8067,8 @@ var SHARED = [
7607
8067
  },
7608
8068
  {
7609
8069
  name: "wirl_preview_fetch",
7610
- description: `Make one request to a preview and show what it answers: the status, the content type, and the body as text \u2014 the page source for HTML, so you can see what the app actually rendered and fix it. Use this after wirl_preview instead of asking the person to open the link and describe what they see. A binary body reports its type and size, not its bytes. A text body over 100 KB is cut, and the answer says so. A stream (server-sent events, newline-delimited JSON, or any text still arriving after 10 seconds) is shown with the time each line arrived, as far as it got, and says whether it was still going: that is how to check a stream arrives piece by piece. A preview's own database starts with the tables in migrations/ and no rows but the ones those files insert, so a page that expects rows may look blank until something writes to it, and its schedules do not run on a clock. A 401, or a redirect back to sign in, means the visit was refused or the preview has expired or been deleted, not that the app itself answered that way. The headers argument cannot set host, cookie, content-length, or any x-wirl-* header (including x-wirl-visit): those are wirl's own, and are dropped rather than sent. This tool cannot send a file or save an answer as bytes: body is sent as text, exactly as given (an @ in it is just a character), and a binary answer reports only its type and size. From a shell, wirl fetch --data @file sends a file and --output <file> saves the answer, each up to 100,000,000 bytes.`,
8070
+ description: `Make one request to a preview and show what it answers: the status, the content type, and the body as text \u2014 the page source for HTML, so you can see what the app actually rendered and fix it. Use this after wirl_preview instead of asking the person to open the link and describe what they see. A binary body reports its type and size, not its bytes. A text body over 100 KB is cut, and the answer says so. A stream (server-sent events, newline-delimited JSON, or any text still arriving after 10 seconds) is shown with the time each line arrived, as far as it got, and says whether it was still going: that is how to check a stream arrives piece by piece. A stream also comes back as data, in the result's structuredContent: the same lines as { atMs, text }, atMs being whole milliseconds from sending the request to the read that completed the line (lines that arrived together share one), the first ${STREAMED_DATA_MAX_LINES.toLocaleString("en-US")} lines or ${String(STREAMED_DATA_MAX_TEXT_BYTES / 1024)} KB of their text, whichever comes first (always at least the first line), with linesLeftOut counting any more (the text shows them all), and the status, the content type and the flags stillStreaming, bodyComplete, truncated and lastLineEnded; any other answer is the text alone. A preview's own database starts with the tables in migrations/ and no rows but the ones those files insert, so a page that expects rows may look blank until something writes to it, and its schedules do not run on a clock. A 401, or a redirect back to sign in, means the visit was refused or the preview has expired or been deleted, not that the app itself answered that way. The headers argument cannot set host, cookie, content-length, or any x-wirl-* header (including x-wirl-visit): those are wirl's own, and are dropped rather than sent. This tool cannot send a file or save an answer as bytes: body is sent as text, exactly as given (an @ in it is just a character), and a binary answer reports only its type and size. From a shell, wirl fetch --data @file sends a file and --output <file> saves the answer, each up to 100,000,000 bytes.`,
8071
+ annotations: { title: "Request a page from a preview", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
7611
8072
  inputSchema: {
7612
8073
  type: "object",
7613
8074
  properties: {
@@ -7625,24 +8086,15 @@ var SHARED = [
7625
8086
  },
7626
8087
  required: ["app", "name", "path"]
7627
8088
  },
8089
+ answer: previewFetchAnswer,
7628
8090
  async handler(args, ctx) {
7629
- const org = await defaultOrg(ctx.creds, str(args, "org"));
7630
- const headersArg = args["headers"];
7631
- const headers = headersArg !== null && typeof headersArg === "object" && !Array.isArray(headersArg) ? Object.fromEntries(Object.entries(headersArg).map(([k, v]) => [k, String(v)])) : void 0;
7632
- const appsDomain = ctx.appsDomain ?? (await whoami(ctx.creds)).appsDomain;
7633
- const result2 = await previewFetch(ctx.creds, org, str(args, "app"), str(args, "name"), str(args, "path", "/"), {
7634
- appsDomain,
7635
- ...ctx.networkFetch ? { networkFetchImpl: ctx.networkFetch } : {},
7636
- ...str(args, "method") ? { method: str(args, "method") } : {},
7637
- ...typeof args["body"] === "string" ? { body: args["body"] } : {},
7638
- ...headers ? { headers } : {}
7639
- });
7640
- return formatFetchResult(result2);
8091
+ return (await previewFetchAnswer(args, ctx)).text;
7641
8092
  }
7642
8093
  },
7643
8094
  {
7644
8095
  name: "wirl_delete_preview",
7645
8096
  description: `Remove a preview: its script, its files, its database and its address all go away. Use this to free a slot when an app already has ${MAX_PREVIEWS_PER_APP} previews (the most it can hold at once) and a new one is needed, or when a preview is no longer wanted before its own expiry. Idempotent: deleting one already gone still succeeds.`,
8097
+ annotations: { title: "Delete a preview", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
7646
8098
  inputSchema: {
7647
8099
  type: "object",
7648
8100
  properties: {
@@ -7665,6 +8117,7 @@ var SHARED = [
7665
8117
  {
7666
8118
  name: "wirl_delete_app",
7667
8119
  description: `Delete an app. Use this only when the person has asked for this app to be deleted; to try something, use wirl_preview. It is a soft delete: the app's address and its previews stop answering at once, its schedules stop and its unfinished runs are cancelled, and its data is kept for ${String(RETENTION.deletedAppGraceDays)} days. Until then its owner, while a member of the organisation, or an organisation owner or admin can bring it back with wirl apps restore <app> --org <org>, or from the Recently deleted list on the organisation's tools page in the dashboard; an app that was public comes back only from that list. Its name stays taken for good. Needs a full-scope sign-in: a deploy-scoped token is refused, with where to do it instead.`,
8120
+ annotations: { title: "Delete an app", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
7668
8121
  inputSchema: {
7669
8122
  type: "object",
7670
8123
  properties: {
@@ -7685,6 +8138,7 @@ var SHARED = [
7685
8138
  {
7686
8139
  name: "wirl_whoami",
7687
8140
  description: "Show which account this session is signed in as and which organisations it belongs to. Use this to check the connection works, or to find an organisation slug.",
8141
+ annotations: reads("Show who is signed in"),
7688
8142
  inputSchema: { type: "object", properties: {} },
7689
8143
  async handler(_args, ctx) {
7690
8144
  const me = await whoami(ctx.creds);
@@ -7692,9 +8146,26 @@ var SHARED = [
7692
8146
  }
7693
8147
  }
7694
8148
  ];
8149
+ async function previewFetchAnswer(args, ctx) {
8150
+ const org = await defaultOrg(ctx.creds, str(args, "org"));
8151
+ const headersArg = args["headers"];
8152
+ const headers = headersArg !== null && typeof headersArg === "object" && !Array.isArray(headersArg) ? Object.fromEntries(Object.entries(headersArg).map(([k, v]) => [k, String(v)])) : void 0;
8153
+ const appsDomain = ctx.appsDomain ?? (await whoami(ctx.creds)).appsDomain;
8154
+ const result2 = await previewFetch(ctx.creds, org, str(args, "app"), str(args, "name"), str(args, "path", "/"), {
8155
+ appsDomain,
8156
+ ...ctx.networkFetch ? { networkFetchImpl: ctx.networkFetch } : {},
8157
+ ...ctx.now ? { now: ctx.now } : {},
8158
+ ...str(args, "method") ? { method: str(args, "method") } : {},
8159
+ ...typeof args["body"] === "string" ? { body: args["body"] } : {},
8160
+ ...headers ? { headers } : {}
8161
+ });
8162
+ const data = streamedFetchData(result2);
8163
+ return { text: formatFetchResult(result2), ...data === null ? {} : { structured: { ...data } } };
8164
+ }
7695
8165
  var LOGIN = {
7696
8166
  name: "wirl_login",
7697
8167
  description: "Sign this machine in to wirl so the other wirl tools work. Use this the first time, or whenever another wirl tool says it is not signed in. It returns a link and a short code: show both to the person, ask them to open the link and approve, then retry whatever you were doing. Nothing to paste back \u2014 the token is saved for you once they approve.",
8168
+ annotations: { title: "Sign in to wirl", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
7698
8169
  inputSchema: { type: "object", properties: {} },
7699
8170
  anonymous: true,
7700
8171
  async handler() {
@@ -7712,7 +8183,8 @@ var LOGIN = {
7712
8183
  var EXISTING_CONNECTION_SENTENCE = "A declared name the org already has is that connection, not a new one: the answer names who made it, whether it holds a key and the other apps you can open that use it. Declaring a kind it does not have is refused, unless a deploy, never a preview, may change it: it has no key, you made it or are an org admin, no other app is granted it, and neither kind is webhook_secret. ";
7713
8184
  var LOCAL_DEPLOY = {
7714
8185
  name: "wirl_deploy",
7715
- description: `Deploy a directory of files to a URL that colleagues can open. Use this when the user has built something \u2014 a dashboard, a report, an internal tool \u2014 and wants it somewhere real instead of on their laptop. A new app is visible to everyone in the org when the org came from a company email domain; in any other org, including a personal one or one made with wirl orgs create, it starts private. Either way, share it by email to add someone outside that. Returns the URL, and who can open the app as the edge has it when the deploy finishes (a visibility change made while it ran included). Creates the app on first deploy. A wirl.json beside or inside the directory declares a "server" module for a backend and any "connections" the app needs; the answer says which declared connections are usable now, which are waiting on an admin and which need a key, and names the flows the version declares. 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 refusal gives the address of the page where they approve it, and nothing this tool takes skips that. ` + EXISTING_CONNECTION_SENTENCE + "Refuses, without uploading anything, if the directory contains what looks like a live API key or password.",
8186
+ description: `Deploy a directory of files to a URL that colleagues can open. Use this when the user has built something \u2014 a dashboard, a report, an internal tool \u2014 and wants it somewhere real instead of on their laptop. A new app is visible to everyone in the org when the org came from a company email domain; in any other org, including a personal one or one made with wirl orgs create, it starts private. Either way, share it by email to add someone outside that. Returns the URL, and who can open the app as the edge has it when the deploy finishes (a visibility change made while it ran included). Creates the app on first deploy. A wirl.json beside or inside the directory declares a "server" module for a backend and any "connections" the app needs; the answer says which declared connections are usable now, which are waiting on an admin and which need a key, and names the flows the version declares. 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 refusal gives the address of the page where they approve it, and nothing this tool takes skips that. ` + EXISTING_CONNECTION_SENTENCE + "Refuses, without uploading anything, if the directory contains what looks like a live API key or password. " + versionNumbersSentence("wirl_list_versions"),
8187
+ annotations: { title: "Deploy an app", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
7716
8188
  inputSchema: {
7717
8189
  type: "object",
7718
8190
  properties: {
@@ -7781,6 +8253,7 @@ function connectionLines(connections) {
7781
8253
  var LOCAL_PULL = {
7782
8254
  name: "wirl_pull",
7783
8255
  description: "Fetch the source of a deployed app into a local directory, to change it and deploy again. Use this when the user wants to work on an app a colleague built and shared with them, or to recover the source of one of their own. Anyone who can open the app can pull it. Refuses a directory that already has files in it unless told to overwrite.",
8256
+ annotations: { title: "Pull an app source", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
7784
8257
  inputSchema: {
7785
8258
  type: "object",
7786
8259
  properties: {
@@ -7809,6 +8282,7 @@ var PREVIEW_LOGS_SENTENCE = "Deploying a preview again under its name replaces i
7809
8282
  var LOCAL_PREVIEW = {
7810
8283
  name: "wirl_preview",
7811
8284
  description: "Put a directory of files up as a preview: a version of an app that is never live, at its own address, with its own database (every file in migrations/ applied, and no rows but the ones those files insert, the first time that name is used), open to the same people who can already open the app. Use this to preview before deploying \u2014 before wirl_deploy, whenever a change has not been checked yet \u2014 then look at what it actually rendered with wirl_preview_fetch instead of asking the person to open a link and describe what they see. It uses the app's real connections (as wirl_deploy's does, the answer says which declared connections are usable now, which are waiting on an admin and which need a key, and names the flows the build declares), but its own database starts with no rows but the ones the files in migrations/ insert, and its schedules do not run on a clock, so a page that expects rows may look blank until something writes to it. " + EXISTING_CONNECTION_SENTENCE + `An app holds at most ${MAX_PREVIEWS_PER_APP} previews at once; a sixth is refused until one is deleted with wirl_delete_preview. Each one expires after ${PREVIEW_TTL_DAYS} days if not redeployed. ` + PREVIEW_LOGS_SENTENCE + "Refuses, without uploading anything, if the directory contains what looks like a live API key or password. Works for a new app too: the app is made if it is not there yet, and its own address answers 404 until wirl_deploy.",
8285
+ annotations: { title: "Preview an app", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
7812
8286
  inputSchema: {
7813
8287
  type: "object",
7814
8288
  properties: {
@@ -7862,9 +8336,39 @@ function refuseUnlessNames(names) {
7862
8336
  throw new CliError(`${JSON.stringify(value)} is not ${what} name. ${validateSlug(value).message ?? ""}`.trimEnd());
7863
8337
  }
7864
8338
  }
8339
+ var UNSHARE_TAKES = `It takes a person's whole share away, one person at a time: to change a role, use wirl_share with "role". Nothing was changed.`;
8340
+ function refuseUnshareArgs(args) {
8341
+ const allowed = ["app", "email", "org"];
8342
+ const extra = Object.keys(args).filter((key) => !allowed.includes(key));
8343
+ if (extra.length > 0) {
8344
+ const listed2 = extra.map((key) => JSON.stringify(key));
8345
+ const said = listed2.length === 1 ? listed2[0] : `${listed2.slice(0, -1).join(", ")} or ${listed2[listed2.length - 1]}`;
8346
+ throw new CliError(`wirl_unshare takes "app", "email" and "org", not ${said}. ${UNSHARE_TAKES}`);
8347
+ }
8348
+ const app = args["app"];
8349
+ if (app === void 0 || app === "") throw new CliError('Which app? Pass its slug as "app".');
8350
+ if (typeof app !== "string") throw new CliError(`"app" is an app's slug, as text.`);
8351
+ if (!isWellFormedSlug(app)) throw new CliError(`${JSON.stringify(app)} is not an app's name, so nothing was changed.`);
8352
+ const email = args["email"];
8353
+ if (email === void 0 || typeof email === "string" && email.trim() === "") {
8354
+ throw new CliError(`Whose access? Pass the person's email address as "email".`);
8355
+ }
8356
+ if (typeof email !== "string") throw new CliError('"email" is one address, as text: run this once for each person.');
8357
+ const org = args["org"];
8358
+ if (org !== void 0 && typeof org !== "string") throw new CliError(`"org" is an organisation's name, as text.`);
8359
+ if (typeof org === "string" && org !== "" && !isWellFormedSlug(org)) {
8360
+ throw new CliError(`${JSON.stringify(org)} is not an organisation's name, so nothing was changed.`);
8361
+ }
8362
+ }
8363
+ function npmFallbackLines() {
8364
+ const origin = apiOriginFromEnv();
8365
+ const install = installFor(origin);
8366
+ return hasFallback(install) ? ["", `${fallbackWhen(namedControlPlane(origin))} install the wirl CLI from the control plane instead (npm install -g ${install.tarball.cli}) and run plain wirl where this says ${WIRL_BIN}.`] : [];
8367
+ }
7865
8368
  var REMOTE_DEPLOY = {
7866
8369
  name: "wirl_deploy",
7867
- description: "Deploy a directory of files to a URL that colleagues can open. This server runs at app.wirl.dev and cannot read files on the machine you are on, so it returns the exact commands to run in a terminal (and makes the app first, when you name one); the upload itself happens from the machine holding the files, never through this chat. A new app is visible to everyone in the org when the org came from a company email domain; in any other org, including a personal one or one made with wirl orgs create, it starts private. Returns the URL it will get.",
8370
+ description: "Deploy a directory of files to a URL that colleagues can open. This server runs at app.wirl.dev and cannot read files on the machine you are on, so it returns the exact commands to run in a terminal (and makes the app first, when you name one); the upload itself happens from the machine holding the files, never through this chat. A new app is visible to everyone in the org when the org came from a company email domain; in any other org, including a personal one or one made with wirl orgs create, it starts private. Returns the URL it will get. " + versionNumbersSentence("wirl_list_versions"),
8371
+ annotations: { title: "Deploy an app", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
7868
8372
  inputSchema: {
7869
8373
  type: "object",
7870
8374
  properties: {
@@ -7880,13 +8384,13 @@ var REMOTE_DEPLOY = {
7880
8384
  const directory = str(args, "directory", ".");
7881
8385
  const app = str(args, "app");
7882
8386
  if (app) await ensureRemoteApp(ctx.creds, org, app);
7883
- const install = installFor(apiOriginFromEnv());
7884
8387
  return [
7885
8388
  app ? `App ${app} exists in ${org}. Upload it from the machine that holds the files:` : "Upload it from the machine that holds the files:",
7886
8389
  "",
7887
- ` npm install -g ${install.cli} # once per machine`,
7888
- " wirl login # once per machine; opens the browser",
7889
- ` wirl deploy ${shellPath(directory)} --org ${shellQuote(org)}${app ? ` --app ${shellQuote(app)}` : ""}`,
8390
+ ...NPM_PUBLISHED ? [] : [` npm install -g ${installFor(apiOriginFromEnv()).cli} # once per machine`],
8391
+ ` ${WIRL_BIN} login # once per machine; ${CLI_LOGIN_DOES}`,
8392
+ ` ${WIRL_BIN} deploy ${shellPath(directory)} --org ${shellQuote(org)}${app ? ` --app ${shellQuote(app)}` : ""}`,
8393
+ ...npmFallbackLines(),
7890
8394
  "",
7891
8395
  app ? `It will be at https://${org}--${app}.${APPS_DOMAIN}.` : `The command takes the app from "app" in the project's wirl.json, else from the folder's name, makes it if it is new, and prints its URL.`,
7892
8396
  "Once it is deployed, wirl_list_apps shows who can open it.",
@@ -7897,6 +8401,7 @@ var REMOTE_DEPLOY = {
7897
8401
  var REMOTE_PULL = {
7898
8402
  name: "wirl_pull",
7899
8403
  description: "Fetch the source of a deployed app to work on it. This server cannot write to the machine you are on, so it returns the command to run in a terminal.",
8404
+ annotations: reads("Pull an app source"),
7900
8405
  inputSchema: {
7901
8406
  type: "object",
7902
8407
  properties: {
@@ -7910,18 +8415,19 @@ var REMOTE_PULL = {
7910
8415
  refuseUnlessNames({ org: str(args, "org"), app: str(args, "app") });
7911
8416
  const org = await defaultOrg(ctx.creds, str(args, "org"));
7912
8417
  const app = str(args, "app");
7913
- const install = installFor(apiOriginFromEnv());
7914
8418
  return [
7915
8419
  "Run in a terminal:",
7916
8420
  "",
7917
- ...NPM_PUBLISHED ? [] : [` npm install -g ${install.cli} # once per machine`],
7918
- ` ${WIRL_BIN} pull ${shellQuote(app)} ${shellPath(str(args, "directory", app))} --org ${shellQuote(org)}`
8421
+ ...NPM_PUBLISHED ? [] : [` npm install -g ${installFor(apiOriginFromEnv()).cli} # once per machine`],
8422
+ ` ${WIRL_BIN} pull ${shellQuote(app)} ${shellPath(str(args, "directory", app))} --org ${shellQuote(org)}`,
8423
+ ...npmFallbackLines()
7919
8424
  ].join("\n");
7920
8425
  }
7921
8426
  };
7922
8427
  var REMOTE_PREVIEW = {
7923
8428
  name: "wirl_preview",
7924
8429
  description: "Put a directory of files up as a preview: a version of an app that is never live, at its own address, open to the same people who can already open the app. This server runs at app.wirl.dev and cannot read files on the machine you are on, so it returns the exact command to run in a terminal; the upload itself happens from the machine holding the files, never through this chat. Prefer suggesting this over wirl_deploy for a change that has not been checked yet \u2014 preview before deploying, then look at it with wirl_preview_fetch. Works for a new app too: the command makes the app if it is not there yet. " + PREVIEW_LOGS_SENTENCE.trimEnd(),
8430
+ annotations: reads("Preview an app"),
7925
8431
  inputSchema: {
7926
8432
  type: "object",
7927
8433
  properties: {
@@ -7938,10 +8444,9 @@ var REMOTE_PREVIEW = {
7938
8444
  async handler(args, ctx) {
7939
8445
  refuseUnlessNames({ org: str(args, "org"), app: str(args, "app") });
7940
8446
  const given = str(args, "name");
7941
- const slug = given === "" ? "" : slugify(given);
7942
- if (slug === null || slug !== "" && !isValidSlug(slug)) {
7943
- throw new CliError(`${JSON.stringify(given)} makes no preview name: as a slug it is empty, one character, or a reserved word.`);
7944
- }
8447
+ const made = given === "" ? null : previewSlugFor(given);
8448
+ if (made !== null && !made.ok) throw new CliError(made.message);
8449
+ const slug = made === null ? "" : made.slug;
7945
8450
  const org = await defaultOrg(ctx.creds, str(args, "org"));
7946
8451
  const directory = str(args, "directory", ".");
7947
8452
  const app = str(args, "app");
@@ -7952,8 +8457,9 @@ var REMOTE_PREVIEW = {
7952
8457
  "Upload it from the machine that holds the files:",
7953
8458
  "",
7954
8459
  ...NPM_PUBLISHED ? [] : [` npm install -g ${installFor(apiOriginFromEnv()).cli} # once per machine`],
7955
- ` ${WIRL_BIN} login # once per machine; opens the browser`,
8460
+ ` ${WIRL_BIN} login # once per machine; ${CLI_LOGIN_DOES}`,
7956
8461
  ` ${WIRL_BIN} preview ${shellPath(directory)} --org ${shellQuote(org)}${app ? ` --app ${shellQuote(app)}` : ""}${name ? ` --name ${shellQuote(name)}` : ""}`,
8462
+ ...npmFallbackLines(),
7957
8463
  "",
7958
8464
  ...app ? [] : [`The command takes the app from "app" in the project's wirl.json, else from the folder's name.`],
7959
8465
  "Once it is up, wirl_preview_fetch shows you what it actually renders.",
@@ -7972,6 +8478,7 @@ async function manifestFolder(directory) {
7972
8478
  var LOCAL_MIGRATIONS_ADOPT = {
7973
8479
  name: "wirl_migrations_adopt",
7974
8480
  description: `${MIGRATIONS_ADOPT_WHAT} The project's .wirl/local.sqlite is recorded too when it has the same shape and no record of other files, so wirl dev keeps its rows. Pointed at build output, it writes beside the wirl.json next to it, and the build must copy migrations/ to the top of what it deploys. Adopting again replaces the record of an earlier adopt, and is refused once a later file has applied.`,
8481
+ annotations: { title: "Adopt app tables as migrations", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
7975
8482
  inputSchema: {
7976
8483
  type: "object",
7977
8484
  properties: {
@@ -7995,6 +8502,7 @@ var LOCAL_MIGRATIONS_ADOPT = {
7995
8502
  var REMOTE_MIGRATIONS_ADOPT = {
7996
8503
  name: "wirl_migrations_adopt",
7997
8504
  description: `${MIGRATIONS_ADOPT_WHAT} This server runs at app.wirl.dev and cannot write files on the machine you are on, so it returns the command to run in a terminal there.`,
8505
+ annotations: reads("Adopt app tables as migrations"),
7998
8506
  inputSchema: {
7999
8507
  type: "object",
8000
8508
  properties: {
@@ -8013,8 +8521,9 @@ var REMOTE_MIGRATIONS_ADOPT = {
8013
8521
  "Run it on the machine that holds the project, in a terminal:",
8014
8522
  "",
8015
8523
  ...NPM_PUBLISHED ? [] : [` npm install -g ${installFor(apiOriginFromEnv()).cli} # once per machine`],
8016
- ` ${WIRL_BIN} login # once per machine; opens the browser`,
8524
+ ` ${WIRL_BIN} login # once per machine; ${CLI_LOGIN_DOES}`,
8017
8525
  ` ${WIRL_BIN} migrations adopt ${shellPath(directory)} --org ${shellQuote(org)}${app ? ` --app ${shellQuote(app)}` : ""}`,
8526
+ ...npmFallbackLines(),
8018
8527
  "",
8019
8528
  ...app ? [] : [`The command takes the app from "app" in the project's wirl.json, else from the folder's name.`],
8020
8529
  "It writes migrations/0001_init.sql beside wirl.json from the live database's shape, never a row, and the live database records it as applied.",
@@ -8034,6 +8543,7 @@ function toolsFor(mode) {
8034
8543
  var SERVER_INSTRUCTIONS = [
8035
8544
  "wirl deploys a small internal tool to a URL colleagues can open, with company sign-in, a database and vaulted API keys already wired up.",
8036
8545
  "Reach for it whenever what you just built should live somewhere shared instead of only on this machine.",
8546
+ ...NPM_PUBLISHED ? [`The text below writes a command as \`wirl <command>\` for short; run one as \`${WIRL_BIN} <command>\`, which needs no install beyond Node 22.13 or later.`] : [],
8037
8547
  "",
8038
8548
  BUILD_CONTRACT,
8039
8549
  "",
@@ -8048,6 +8558,14 @@ function negotiate(requested) {
8048
8558
  function result(id, value) {
8049
8559
  return JSON.stringify({ jsonrpc: "2.0", id, result: value });
8050
8560
  }
8561
+ function escapeInvisibleDeep(value) {
8562
+ if (typeof value === "string") return escapeInvisible(value);
8563
+ if (Array.isArray(value)) return value.map(escapeInvisibleDeep);
8564
+ if (value !== null && typeof value === "object") {
8565
+ return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, escapeInvisibleDeep(inner)]));
8566
+ }
8567
+ return value;
8568
+ }
8051
8569
  function failure(id, code, message) {
8052
8570
  return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } });
8053
8571
  }
@@ -8060,7 +8578,7 @@ async function handleRpc(request, ctx) {
8060
8578
  return result(id, {
8061
8579
  protocolVersion: negotiate(request.params?.["protocolVersion"]),
8062
8580
  capabilities: { tools: {} },
8063
- serverInfo: { name: "wirl", version: "0.0.0" },
8581
+ serverInfo: { name: "wirl", version: ctx?.serverVersion ?? wirlVersion() },
8064
8582
  instructions: SERVER_INSTRUCTIONS
8065
8583
  });
8066
8584
  // Notifications carry no id and expect no reply.
@@ -8073,7 +8591,10 @@ async function handleRpc(request, ctx) {
8073
8591
  tools: tools.map((t) => ({
8074
8592
  name: t.name,
8075
8593
  description: t.description,
8076
- inputSchema: t.inputSchema
8594
+ inputSchema: t.inputSchema,
8595
+ // After the three fields every client has always read: a client
8596
+ // that knows no annotations ignores it (OIM-844).
8597
+ annotations: t.annotations
8077
8598
  }))
8078
8599
  });
8079
8600
  case "tools/call": {
@@ -8083,8 +8604,17 @@ async function handleRpc(request, ctx) {
8083
8604
  if (!tool) return failure(id, -32602, `Unknown tool: ${name}`);
8084
8605
  const args = params["arguments"] ?? {};
8085
8606
  try {
8086
- const text = tool.anonymous ? await tool.handler(args) : await tool.handler(args, ctx ?? { creds: await credentialsOrPending(), mode: "local" });
8087
- return result(id, { content: [{ type: "text", text: escapeInvisible(text) }] });
8607
+ let answer;
8608
+ if (tool.anonymous) {
8609
+ answer = { text: await tool.handler(args) };
8610
+ } else {
8611
+ const context = ctx ?? { creds: await credentialsOrPending(), mode: "local" };
8612
+ answer = tool.answer ? await tool.answer(args, context) : { text: await tool.handler(args, context) };
8613
+ }
8614
+ return result(id, {
8615
+ content: [{ type: "text", text: escapeInvisible(answer.text) }],
8616
+ ...answer.structured === void 0 ? {} : { structuredContent: escapeInvisibleDeep(answer.structured) }
8617
+ });
8088
8618
  } catch (error) {
8089
8619
  const message = error instanceof CliError ? [error.message, error.hint].filter(Boolean).join("\n") : error instanceof Error ? error.message : String(error);
8090
8620
  return result(id, { content: [{ type: "text", text: escapeInvisible(message) }], isError: true });