@wirl/mcp 0.2.1 → 0.2.3

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 +5 -3
  2. package/bin.js +1055 -117
  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).
@@ -2332,6 +2363,9 @@ function parseManifest(input) {
2332
2363
  }
2333
2364
  accent = rawAccent;
2334
2365
  }
2366
+ const rawVisibility = raw["visibility"];
2367
+ if (rawVisibility !== void 0 && rawVisibility !== "private") return refuse(VISIBILITY_REFUSED);
2368
+ const visibility = rawVisibility === "private" ? { visibility: "private" } : {};
2335
2369
  const flowResult = parseFlows(raw);
2336
2370
  if (!flowResult.ok) return refuse(flowResult.message);
2337
2371
  const flows = flowResult.flows;
@@ -2350,7 +2384,7 @@ function parseManifest(input) {
2350
2384
  if (!publicPathsResult2.ok) return refuse(publicPathsResult2.message);
2351
2385
  return {
2352
2386
  ok: true,
2353
- manifest: { runtime, connections: [], description, accent, schedules, flows, publicPaths: publicPathsResult2.publicPaths, static: staticFiles, migrations },
2387
+ manifest: { runtime, connections: [], description, accent, schedules, flows, publicPaths: publicPathsResult2.publicPaths, static: staticFiles, migrations, ...visibility },
2354
2388
  warnings
2355
2389
  };
2356
2390
  }
@@ -2409,11 +2443,11 @@ function parseManifest(input) {
2409
2443
  if (!publicPathsResult.ok) return refuse(publicPathsResult.message);
2410
2444
  return {
2411
2445
  ok: true,
2412
- manifest: { runtime, connections, description, accent, schedules, flows, publicPaths: publicPathsResult.publicPaths, static: staticFiles, migrations },
2446
+ manifest: { runtime, connections, description, accent, schedules, flows, publicPaths: publicPathsResult.publicPaths, static: staticFiles, migrations, ...visibility },
2413
2447
  warnings
2414
2448
  };
2415
2449
  }
2416
- var CONNECTION_KINDS, UNDELIVERED_KINDS, CREATABLE_CONNECTION_KINDS, needsNoKey, ACCENTS, SCHEDULE_LIMITS, MANIFEST_KEYS, QUIET_KEYS, UNKNOWN_KEYS_NAMED, KEY_SHOWN, squash, ONE_EDIT_MIN_LETTERS, pluralOf, SLUG, LABEL, HOST_PATTERN, refuse, asHint, SCHEDULE_NAME, TIMEOUT_PATTERN, TIMEOUT_MESSAGE, FLOW_KEYS, MIGRATIONS_KEYS;
2450
+ var CONNECTION_KINDS, UNDELIVERED_KINDS, CREATABLE_CONNECTION_KINDS, needsNoKey, ACCENTS, SCHEDULE_LIMITS, MANIFEST_KEYS, QUIET_KEYS, UNKNOWN_KEYS_NAMED, KEY_SHOWN, squash, ONE_EDIT_MIN_LETTERS, pluralOf, SLUG, LABEL, HOST_PATTERN, refuse, asHint, SCHEDULE_NAME, TIMEOUT_PATTERN, TIMEOUT_MESSAGE, FLOW_KEYS, MIGRATIONS_KEYS, VISIBILITY_REFUSED;
2417
2451
  var init_manifest = __esm({
2418
2452
  "packages/core/src/manifest.ts"() {
2419
2453
  "use strict";
@@ -2453,7 +2487,8 @@ var init_manifest = __esm({
2453
2487
  "server",
2454
2488
  "spa",
2455
2489
  "start",
2456
- "static"
2490
+ "static",
2491
+ "visibility"
2457
2492
  ]);
2458
2493
  QUIET_KEYS = Object.freeze(["$schema"]);
2459
2494
  UNKNOWN_KEYS_NAMED = 10;
@@ -2475,6 +2510,7 @@ var init_manifest = __esm({
2475
2510
  TIMEOUT_MESSAGE = (name) => `timeout for ${name} must be between 30s and 15m; a run longer than that should be split into resumable chunks.`;
2476
2511
  FLOW_KEYS = ["name", "step_timeout", "retries"];
2477
2512
  MIGRATIONS_KEYS = ["destructive"];
2513
+ VISIBILITY_REFUSED = `visibility can only be "private": it sets who can open a new app when a deploy creates it. Change an existing app's visibility with wirl visibility.`;
2478
2514
  }
2479
2515
  });
2480
2516
 
@@ -2695,7 +2731,7 @@ var init_runtime = __esm({
2695
2731
  });
2696
2732
 
2697
2733
  // packages/core/src/broker-stream.ts
2698
- var BROKER_LIMITS, STREAMED_MEDIA_TYPES;
2734
+ var BROKER_LIMITS, STREAMED_MEDIA_TYPES, MIB;
2699
2735
  var init_broker_stream = __esm({
2700
2736
  "packages/core/src/broker-stream.ts"() {
2701
2737
  "use strict";
@@ -2712,15 +2748,132 @@ var init_broker_stream = __esm({
2712
2748
  streamIdleMs: 4 * 6e4,
2713
2749
  /** Bytes in any one answer, streamed or read whole. */
2714
2750
  answerBytes: 25 * 1024 * 1024,
2751
+ /**
2752
+ * Bytes in the body of any one call an app makes. The body is read whole
2753
+ * before the call is sent, and before its grant is looked up, so a body
2754
+ * past this never opens a key and never reaches a vendor (OIM-931).
2755
+ * 100 MiB: Cloudflare publishes 100 MB as the request body its Free and
2756
+ * Pro plans take, and a deployed app's call is first held whole by the
2757
+ * egress Worker, in an isolate Cloudflare gives 128 MB of memory. Nothing
2758
+ * else bounds a caller that holds a broker token and reaches the control
2759
+ * plane itself: Railway sets no size on a body, only five minutes to send it.
2760
+ */
2761
+ requestBytes: 100 * 1024 * 1024,
2762
+ /**
2763
+ * Bytes of call bodies one app's calls hold at once, its previews' with
2764
+ * them (OIM-951): counted as each chunk arrives, from a call's first byte
2765
+ * until the broker has answered it (broker-bodies.ts). At least
2766
+ * `requestBytes`, so one call at the cap always fits an app that holds
2767
+ * nothing else; a quarter of `heldBodyBytes`, so four apps of four owners
2768
+ * at their share fill the whole (one owner's apps cannot: `ownerHeldBodyBytes`).
2769
+ */
2770
+ appHeldBodyBytes: 128 * 1024 * 1024,
2771
+ /**
2772
+ * Bytes of call bodies the calls of every app one owner has hold at once,
2773
+ * their previews' with them (OIM-967): the person `apps.owner_user_id`
2774
+ * names, whichever organisations their apps are in. Apps and
2775
+ * organisations cost nothing to create (no cap on either), so a share per
2776
+ * app let one person with four apps fill the whole, and one per
2777
+ * organisation would let them do it with four organisations; a person
2778
+ * costs a sign-in. Two apps at their share, so one app that runs
2779
+ * away still leaves its owner's other apps room, and half of
2780
+ * `heldBodyBytes`, so one owner can never take more than half, and a
2781
+ * second sign-in is what it takes to fill it. At least `requestBytes`, so
2782
+ * one call at the cap always fits an owner whose apps hold nothing else.
2783
+ */
2784
+ ownerHeldBodyBytes: 256 * 1024 * 1024,
2785
+ /**
2786
+ * Bytes of call bodies every app's calls hold at once, in this process.
2787
+ * A call's reader starts at 16 KiB and doubles its buffer as the body
2788
+ * arrives, so the buffers behind this hold at most twice it, 1 GiB, and
2789
+ * 16 KiB a call beside (the ruling in OIM-951).
2790
+ */
2791
+ heldBodyBytes: 512 * 1024 * 1024,
2792
+ /** The `Retry-After` of a call refused for either. */
2793
+ heldBodyRetryAfterSeconds: 10,
2794
+ /**
2795
+ * How long a call's body may send nothing before its read is stopped:
2796
+ * an upload's (OIM-935). A deployed app's body comes from the egress
2797
+ * Worker, which holds it whole before it sends it, and `wirl dev` reads it
2798
+ * whole first, so no ordinary sender ever pauses (fix round 1, R1-F2).
2799
+ */
2800
+ heldBodyStallMs: 3e4,
2801
+ /**
2802
+ * How long a call's body may take to arrive whole: 100 MiB in 120 seconds
2803
+ * is about 7 Mbit/s, which a body between Cloudflare and the control plane
2804
+ * passes many times over, and well under Node's 300 seconds a request,
2805
+ * after which the body would be cut anyway (fix round 1, R1-F2).
2806
+ */
2807
+ heldBodyArrivalMs: 12e4,
2808
+ /**
2809
+ * Bytes of answers read whole that one app's calls hold at once, its
2810
+ * previews' with them (OIM-966): counted as each chunk arrives from the
2811
+ * vendor, from an answer's first byte until wirl's server has taken the
2812
+ * last of it, or the app has gone (broker-bodies.ts). A vendor's event
2813
+ * stream is not counted: it is passed on as the app reads it, and holds
2814
+ * about 285 to 441 KiB at a time, measured end to end through the control
2815
+ * plane's server (`passOn` in broker.ts; OIM-966 fix round 1, R1-F3,
2816
+ * R2-F2). More than two `answerBytes`, so two answers at the cap fit an
2817
+ * app that holds nothing else; a quarter of `heldAnswerBytes`, so four apps
2818
+ * of four owners at their share fill the whole (one owner's apps cannot:
2819
+ * `ownerHeldAnswerBytes`).
2820
+ */
2821
+ appHeldAnswerBytes: 64 * 1024 * 1024,
2822
+ /**
2823
+ * Bytes of answers read whole that the calls of every app one owner has
2824
+ * hold at once, their previews' with them (OIM-967), for the reasons
2825
+ * `ownerHeldBodyBytes` gives: two apps at their share, half of
2826
+ * `heldAnswerBytes`, and more than two `answerBytes` and than the room a
2827
+ * call needs, so an owner whose apps hold nothing else can always send.
2828
+ */
2829
+ ownerHeldAnswerBytes: 128 * 1024 * 1024,
2830
+ /**
2831
+ * Bytes of answers read whole that every app's calls hold at once, in this
2832
+ * process. An answer's reader doubles its buffer as it fills, as a body's
2833
+ * does, and never past `answerBytes`, so while answers arrive the buffers
2834
+ * behind this hold at most twice it, 512 MiB, and 16 KiB an answer beside;
2835
+ * an answer read whole is copied to exactly its size before it is handed
2836
+ * on, so from then its count is its memory (the ruling in OIM-966, and its
2837
+ * fix round 1, R1-F2).
2838
+ */
2839
+ heldAnswerBytes: 256 * 1024 * 1024,
2840
+ /**
2841
+ * The room an app's share of answers, and the whole, must each have left
2842
+ * for a call to be sent at all: one answer at the cap. With less, the call
2843
+ * is refused with a 503 before it leaves wirl, rather than run at its
2844
+ * vendor and its answer cut (OIM-966 fix round 1, R1-F1).
2845
+ */
2846
+ heldAnswerRoomBytes: 25 * 1024 * 1024,
2847
+ /** The `Retry-After` of a call refused for that room, before it was sent. */
2848
+ heldAnswerRetryAfterSeconds: 10,
2849
+ /**
2850
+ * How long an answer read whole may wait for wirl's server to take the
2851
+ * last of it once it is handed back: past this it is cut, and its count
2852
+ * goes back, so no app holds its share, or the whole, by not reading.
2853
+ * Under Cloudflare's measured 125 seconds with nothing moving, which ends
2854
+ * a deployed app's unread answer anyway (OIM-966 fix round 1, R1-F1).
2855
+ */
2856
+ heldAnswerHandOutMs: 12e4,
2715
2857
  /**
2716
2858
  * Streamed answers one app may have open at once. Each holds a vendor
2717
2859
  * socket and an inbound connection on one control-plane instance for up
2718
2860
  * to fourteen minutes, so one app cannot be allowed an unbounded number.
2719
2861
  * Counted in memory, per instance, like the control plane's other limits.
2720
2862
  */
2721
- openStreamsPerApp: 20
2863
+ openStreamsPerApp: 20,
2864
+ /**
2865
+ * How long an event stream wirl cut waits for its closing event to leave
2866
+ * the server before it ends the stream with its error anyway (OIM-837 fix
2867
+ * round 1). The event is on its way when the server's queue of what it has
2868
+ * to write has emptied; a reader that is slow but reading empties a full
2869
+ * kernel buffer, a few hundred kilobytes, well inside this. Past it the
2870
+ * reader is not reading, and the stream ends as it always did; the write
2871
+ * stall that holds a connection longer than this is OIM-968's.
2872
+ */
2873
+ cutEventSentMs: 5e3
2722
2874
  };
2723
2875
  STREAMED_MEDIA_TYPES = Object.freeze(["text/event-stream", "application/x-ndjson"]);
2876
+ MIB = 1024 * 1024;
2724
2877
  }
2725
2878
  });
2726
2879
 
@@ -2789,7 +2942,7 @@ arrives: pass \`response.body\` straight on. There are no WebSockets. A front en
2789
2942
  answers everything else; add \`"spa": true\` for a client-side router. Nothing outside that folder is served.
2790
2943
 
2791
2944
  Preview before you deploy: \`wirl preview\` gives a folder its own address and database (at first only what
2792
- \`migrations/\` makes). \`wirl fetch <path> --preview <name>\` or \`wirl_preview_fetch\` shows what it answers.
2945
+ \`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.
2793
2946
 
2794
2947
  npm packages work: install them in the project and the deploy bundles them in. Nothing is installed on the
2795
2948
  server: native code will not run. Node built-ins work (\`node:crypto\`, \`node:path\`, ...; \`Buffer\` and
@@ -2855,7 +3008,7 @@ var init_version = __esm({
2855
3008
  "packages/core/src/version.ts"() {
2856
3009
  "use strict";
2857
3010
  WORKSPACE_VERSION = "0.0.0";
2858
- BUILT_VERSION = true ? "0.2.1" : null;
3011
+ BUILT_VERSION = true ? "0.2.3" : null;
2859
3012
  }
2860
3013
  });
2861
3014
 
@@ -2883,6 +3036,33 @@ var init_install = __esm({
2883
3036
  }
2884
3037
  });
2885
3038
 
3039
+ // packages/core/src/share-role.ts
3040
+ function readShareRole(value) {
3041
+ if (value === void 0 || value === null) return { ok: true, role: void 0 };
3042
+ if (value === AppRole.VIEWER || value === AppRole.EDITOR) return { ok: true, role: value };
3043
+ return { ok: false, message: SHARE_ROLE_REFUSAL };
3044
+ }
3045
+ var SHARE_ROLE_REFUSAL;
3046
+ var init_share_role = __esm({
3047
+ "packages/core/src/share-role.ts"() {
3048
+ "use strict";
3049
+ init_types();
3050
+ SHARE_ROLE_REFUSAL = "role must be viewer or editor.";
3051
+ }
3052
+ });
3053
+
3054
+ // packages/core/src/connect.ts
3055
+ var CLAUDE_CODE_SIGN_IN, CLAUDE_CODE_LOCAL_SIGN_IN, CLI_LOGIN_DOES;
3056
+ var init_connect = __esm({
3057
+ "packages/core/src/connect.ts"() {
3058
+ "use strict";
3059
+ init_install();
3060
+ 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.";
3061
+ 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.`;
3062
+ CLI_LOGIN_DOES = "prints a link and a code to approve in the browser";
3063
+ }
3064
+ });
3065
+
2886
3066
  // packages/core/src/porting.ts
2887
3067
  function dependencyNames(packageJson) {
2888
3068
  if (packageJson === void 0) return [];
@@ -3103,6 +3283,66 @@ var init_porting = __esm({
3103
3283
  }
3104
3284
  });
3105
3285
 
3286
+ // packages/core/src/throttle-report.ts
3287
+ var THROTTLE_REPORT, EDGE_REFUSALS_NOTE;
3288
+ var init_throttle_report = __esm({
3289
+ "packages/core/src/throttle-report.ts"() {
3290
+ "use strict";
3291
+ init_public_path();
3292
+ THROTTLE_REPORT = {
3293
+ /**
3294
+ * One report per app a minute at most from each gate isolate, and what
3295
+ * one report counts spans a minute at most.
3296
+ */
3297
+ windowMs: 6e4,
3298
+ /**
3299
+ * How long the refusal that opens a window waits for more before the
3300
+ * report goes, so a burst is one report with its count rather than a
3301
+ * report of its first refusal.
3302
+ */
3303
+ gatherMs: 15e3,
3304
+ /**
3305
+ * The longest a refused request is asked to stay open, after its 429 has
3306
+ * gone, for the report to be sent. Cloudflare keeps a request's
3307
+ * `waitUntil` work for 30 seconds after the response, shared by all of
3308
+ * it, and the send itself has `sendTimeoutMs` of what is left.
3309
+ */
3310
+ maxWaitMs: 2e4,
3311
+ sendTimeoutMs: 5e3,
3312
+ /**
3313
+ * Reports one request carries for windows no refusal could wait for
3314
+ * (`flushDue`). A request that was never refused must not queue its own
3315
+ * reads behind posts that are not its own: Cloudflare runs six of a
3316
+ * request's connections at once and queues the rest.
3317
+ */
3318
+ flushPerRequest: 2,
3319
+ /** Apps one gate isolate counts for at once; a refusal for another app past this is not counted. */
3320
+ maxApps: 1e3,
3321
+ /** Lines in one report: each of an app's public paths, by each ceiling. */
3322
+ maxEntries: 2 * PUBLIC_PATH_LIMITS.perApp,
3323
+ /** A path is cut to this many characters, as `public.refused`'s is. */
3324
+ maxPathChars: 256,
3325
+ /**
3326
+ * The most of a report's body the control plane reads. A full report is
3327
+ * under 8 KiB: a path a request matched is ASCII, as a URL's path is.
3328
+ */
3329
+ bodyBytes: 16384,
3330
+ /**
3331
+ * Reports the control plane reads for one app a minute, from every isolate
3332
+ * and location together. Of those, the first writes the app's one
3333
+ * `public.throttled` row for that minute and the rest write nothing.
3334
+ */
3335
+ perAppPerMinute: 10,
3336
+ /** The org's Activity page summarises this many apps' latest `public.throttled` lines, a line an app, from the last `logsPageDays` days. */
3337
+ activityLines: 5,
3338
+ /** The app's Logs page shows its latest this many `public.throttled` lines, from the last `logsPageDays` days. */
3339
+ logsPageLines: 20,
3340
+ logsPageDays: 7
3341
+ };
3342
+ 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.";
3343
+ }
3344
+ });
3345
+
3106
3346
  // packages/core/src/supplied-bundle.ts
3107
3347
  var SUPPLIED_BUNDLE_PATH, MAX_SUPPLIED_BUNDLE_BYTES;
3108
3348
  var init_supplied_bundle = __esm({
@@ -3127,6 +3367,9 @@ var init_manifest_transport = __esm({
3127
3367
  });
3128
3368
 
3129
3369
  // packages/core/src/preview.ts
3370
+ function versionNumbersSentence(listing) {
3371
+ 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.`;
3372
+ }
3130
3373
  var PREVIEW_TTL_DAYS, MAX_PREVIEWS_PER_APP;
3131
3374
  var init_preview = __esm({
3132
3375
  "packages/core/src/preview.ts"() {
@@ -3166,7 +3409,10 @@ var init_src = __esm({
3166
3409
  init_contract();
3167
3410
  init_version();
3168
3411
  init_install();
3412
+ init_share_role();
3413
+ init_connect();
3169
3414
  init_porting();
3415
+ init_throttle_report();
3170
3416
  init_supplied_bundle();
3171
3417
  init_manifest_transport();
3172
3418
  init_preview();
@@ -4812,7 +5058,7 @@ init_src();
4812
5058
  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";
4813
5059
  import { homedir } from "node:os";
4814
5060
  import { basename as basename2, dirname as dirname3, isAbsolute, join as join5, relative, resolve as resolve4, sep } from "node:path";
4815
- import { create as tarCreate, extract as tarExtract } from "tar";
5061
+ import { create as tarCreate, extract as tarExtract, Parser as TarParser } from "tar";
4816
5062
  import { gzipSync } from "node:zlib";
4817
5063
 
4818
5064
  // packages/core/src/node/preview-name.ts
@@ -4820,10 +5066,9 @@ init_slug();
4820
5066
  import { createHash } from "node:crypto";
4821
5067
  var HASH_SUFFIX_LENGTH = 5;
4822
5068
  function previewNameFor(raw, orgSlug, appSlug) {
4823
- const slug = slugify(raw);
4824
- if (!slug) {
4825
- return { ok: false, message: "That name has no usable characters, or is reserved." };
4826
- }
5069
+ const made = previewSlugFor(raw);
5070
+ if (!made.ok) return { ok: false, message: made.message };
5071
+ const slug = made.slug;
4827
5072
  const budget = MAX_HOST_LABEL - (appLabel(orgSlug, appSlug).length + HOST_SEPARATOR.length);
4828
5073
  if (budget < 2) {
4829
5074
  return {
@@ -4943,6 +5188,486 @@ function shellPath(value) {
4943
5188
  return shellQuote(value);
4944
5189
  }
4945
5190
 
5191
+ // packages/cli/src/skill.ts
5192
+ init_src();
5193
+ var SKILL_INTRO = `A new app is visible to everyone in the org when the org came from a
5194
+ company email domain; in any other org, including a personal one or one
5195
+ made with \`wirl orgs create\`, it starts private. Deploys are one
5196
+ command; sharing is by email; a third-party API is declared, never pasted.`;
5197
+ 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.";
5198
+ 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).
5199
+ 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.
5200
+ 3. If a command names several organisations and asks which, ask the person; pass \`--org\` or put \`"org"\` in \`wirl.json\`.
5201
+ 4. To keep a file out of the deploy, list it in a \`.wirlignore\` at the project root (gitignore-style lines; no \`!\` negation).
5202
+ 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.`;
5203
+ var SKILL_MANIFEST_EXAMPLE = `\`\`\`json
5204
+ { "app": "crm-sync", "server": "server.mjs", "connections": [{ "slug": "hubspot", "hosts": ["api.hubspot.com"] }] }
5205
+ \`\`\``;
5206
+ 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.";
5207
+ 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.';
5208
+ function composeSkill(parts) {
5209
+ if (parts.description.trim() === "" || /[\r\n]/.test(parts.description)) {
5210
+ throw new Error("a skill description is one non-empty line");
5211
+ }
5212
+ return `---
5213
+ name: wirl
5214
+ description: ${parts.description}
5215
+ ---
5216
+
5217
+ # wirl
5218
+
5219
+ ${SKILL_INTRO}
5220
+
5221
+ ${SKILL_DOCS_LINE}
5222
+
5223
+ ## Commands
5224
+
5225
+ ${parts.commands.trimEnd()}
5226
+
5227
+ ## Rules
5228
+
5229
+ ${SKILL_RULES}
5230
+
5231
+ ## wirl.json
5232
+
5233
+ ${SKILL_MANIFEST_EXAMPLE}
5234
+
5235
+ ## What runs on wirl
5236
+
5237
+ ${BUILD_CONTRACT}
5238
+
5239
+ ${SKILL_FILES_PARAGRAPH}
5240
+
5241
+ ${SKILL_STATIC_PARAGRAPH}
5242
+
5243
+ ## Flows
5244
+
5245
+ ${FLOWS_CONTRACT}
5246
+ `;
5247
+ }
5248
+
5249
+ // packages/cli/src/starter.ts
5250
+ init_src();
5251
+ var SUGGESTIONS = [
5252
+ {
5253
+ title: "Rename it to something we'd actually use",
5254
+ prompt: `Rename this noticeboard to something my team would actually use. Ask me what we would use it for, suggest three names, and when I pick one, change the page's title and heading and the description in wirl.json to match. Keep "app" in wirl.json as it is: a new name there would make a second app at a new address. Then deploy it with wirl deploy.`
5255
+ },
5256
+ {
5257
+ title: "Try a new look in a preview first",
5258
+ prompt: "Give the noticeboard a warmer look: pinned notes as coloured cards, and tags as small coloured labels. Put it up with wirl preview first and give me the preview's address to check. Deploy it with wirl deploy only after I say it looks right."
5259
+ },
5260
+ {
5261
+ title: "Add a due date to notes",
5262
+ prompt: "Add an optional due date to notes on the noticeboard. Add the column in a new file, migrations/0002_due_date.sql, and never edit 0001. Let people set the date when they post or edit a note, show it on each note, and put notes due soonest at the top. Try it with wirl dev, then deploy it with wirl deploy."
5263
+ },
5264
+ {
5265
+ title: "Show the weather from a public API",
5266
+ prompt: `Show today's weather for our office at the top of the noticeboard, from Open-Meteo, a public weather API that needs no key. Ask me which city our office is in. Add this entry to "connections" in wirl.json: {"slug": "open-meteo", "kind": "none", "hosts": ["api.open-meteo.com"]}. Fetch Open-Meteo's own URL from server.mjs, and deploy it with wirl deploy. If the deploy says open-meteo is waiting, the connection needs approving, which your sign-in cannot do: tell me to approve it on the app's Connections page on app.wirl.dev, or to ask an admin of our organisation to. The weather shows once it is approved.`
5267
+ },
5268
+ {
5269
+ title: "Change when the tidy-up runs",
5270
+ prompt: "Change the noticeboard's nightly tidy-up: archive notes after 14 days instead of 30, and run it at 6pm on weekdays in my time zone (ask me which). Change the schedule in wirl.json and the number in server.mjs, deploy it with wirl deploy, then run it once with wirl run tidy and tell me what it said."
5271
+ },
5272
+ {
5273
+ title: "Find out what happened from the logs",
5274
+ prompt: "Read this app's logs with wirl logs and tell me, in a few lines, what people did on the noticeboard today and whether anything failed."
5275
+ },
5276
+ {
5277
+ title: "Share it with a colleague",
5278
+ prompt: `Help me share this noticeboard with a colleague. Sharing is done by me, not by a deploy: tell me to share it from the app's Access page on app.wirl.dev (the "Who can open it" row on the noticeboard links to it), where I add their email address. Then tell me what they will see when they open the address.`
5279
+ }
5280
+ ];
5281
+ var SUGGESTIONS_SOURCE = [
5282
+ "const SUGGESTIONS = [",
5283
+ ...SUGGESTIONS.map(({ title, prompt }) => ` { title: ${JSON.stringify(title)}, prompt: ${JSON.stringify(prompt)} },`),
5284
+ "];"
5285
+ ].join("\n");
5286
+ var SERVER_HEAD = String.raw`/**
5287
+ * Team noticeboard: your first app on wirl.
5288
+ *
5289
+ * Your team posts short notes here, tags them, pins the ones everyone should
5290
+ * see, and edits or removes their own. Every night a schedule archives old
5291
+ * notes nobody pinned. The page also explains itself: a "How this works on
5292
+ * wirl" panel, one row per feature, each linking to that page of the app on
5293
+ * app.wirl.dev, and a few changes to ask your coding agent for next.
5294
+ *
5295
+ * The whole app is this one file:
5296
+ * - The page is HTML this module returns: plain forms, no script.
5297
+ * - Notes live in env.DB, a SQLite database that belongs to this app alone.
5298
+ * Its tables are in migrations/0001_init.sql, and wirl applies each new
5299
+ * file in migrations/ before a version goes live. Put the next change in
5300
+ * migrations/0002_<what>.sql rather than editing that one.
5301
+ * - Who is asking arrives in headers wirl sets after signing them in, which
5302
+ * the browser cannot forge. Who may change a note is a line of code here:
5303
+ * a check in the page is only a suggestion to whoever holds the browser.
5304
+ * - The nightly schedule is declared in wirl.json. wirl sends it here as a
5305
+ * POST to /__wirl/schedule/tidy, and what it answers becomes the run's note.
5306
+ * - console.log lines appear on the app's Logs page, and in wirl logs.
5307
+ */
5308
+
5309
+ const TITLE = "Team noticeboard";
5310
+ const DASHBOARD = "https://app.wirl.dev";
5311
+ const ARCHIVE_AFTER_DAYS = 30;
5312
+ const LONGEST_NOTE = 2000;
5313
+
5314
+ export default {
5315
+ async fetch(request, env) {
5316
+ const url = new URL(request.url);
5317
+ // One line per request, on the Logs page.
5318
+ console.log(request.method, url.pathname);
5319
+
5320
+ // The nightly tidy-up, sent by wirl at the time wirl.json gives. wirl
5321
+ // names the schedule in x-wirl-schedule as well as in the path, and a
5322
+ // request without that header is not one wirl sent.
5323
+ if (url.pathname.startsWith("/__wirl/schedule/")) {
5324
+ const name = url.pathname.slice("/__wirl/schedule/".length);
5325
+ const fromWirl = request.headers.get("x-wirl-schedule") === name;
5326
+ if (name === "tidy" && request.method === "POST" && fromWirl) return tidy(env);
5327
+ return text("No such schedule", 404);
5328
+ }
5329
+
5330
+ const me = visitor(request);
5331
+
5332
+ if (request.method === "POST") {
5333
+ // A form posted from another site is never one of yours.
5334
+ if (request.headers.get("sec-fetch-site") === "cross-site") return text("Refused: this form came from another site.", 403);
5335
+ if (me.id === "") return text("Sign in to change the board.", 403);
5336
+ let form;
5337
+ try {
5338
+ form = await request.formData();
5339
+ } catch {
5340
+ return text("Send this as a form.", 400);
5341
+ }
5342
+ return change(url.pathname, form, me, env);
5343
+ }
5344
+
5345
+ if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) {
5346
+ return html(await page(request, url, me, env));
5347
+ }
5348
+ return text("Not found", 404);
5349
+ },
5350
+ };
5351
+
5352
+ // Set by wirl after it signed this person in. Never take identity from a form
5353
+ // or the query string: those say whatever the browser wants them to say. A
5354
+ // header carries only Latin-1 and a name can be anything, so wirl
5355
+ // percent-encodes the name; it is decoded here.
5356
+ function visitor(request) {
5357
+ const header = (name) => decode(request.headers.get(name) || "");
5358
+ return {
5359
+ id: header("x-wirl-user-id"),
5360
+ name: header("x-wirl-user-name") || header("x-wirl-user-email") || "someone",
5361
+ role: header("x-wirl-role"),
5362
+ };
5363
+ }
5364
+
5365
+ // POST /notes, and POST /notes/<id>/<edit|pin|remove|restore>.
5366
+ async function change(path, form, me, env) {
5367
+ const [, first, id, action] = path.split("/");
5368
+ if (first !== "notes") return text("Not found", 404);
5369
+
5370
+ if (id === undefined) {
5371
+ const body = clean(form.get("body"), LONGEST_NOTE);
5372
+ if (body === "") return text("A note needs some words.", 400);
5373
+ const { meta } = await env.DB.prepare("insert into notes (body, tags, author_id, author_name) values (?, ?, ?, ?)")
5374
+ .bind(body, tags(form.get("tags")), me.id, me.name)
5375
+ .run();
5376
+ console.log("note", meta.last_row_id, "posted by", me.id);
5377
+ return home();
5378
+ }
5379
+
5380
+ const number = Number.parseInt(id, 10);
5381
+ if (String(number) !== id) return text("Not found", 404);
5382
+ const note = await env.DB.prepare("select id, author_id from notes where id = ?").bind(number).first();
5383
+ if (!note) return text("No such note", 404);
5384
+
5385
+ // The rule that matters, in code: authors change their own notes, and the
5386
+ // app's owner can change any. Anyone on the board can pin.
5387
+ const mine = note.author_id === me.id || me.role === "owner";
5388
+
5389
+ if (action === "pin") {
5390
+ await env.DB.prepare("update notes set pinned = 1 - pinned where id = ?").bind(number).run();
5391
+ console.log("note", number, "pin changed by", me.id);
5392
+ return home();
5393
+ }
5394
+ if (!mine) return text("Only the person who posted this note, or the app's owner, can change it.", 403);
5395
+
5396
+ if (action === "edit") {
5397
+ const body = clean(form.get("body"), LONGEST_NOTE);
5398
+ if (body === "") return text("A note needs some words.", 400);
5399
+ await env.DB.prepare("update notes set body = ?, tags = ? where id = ?").bind(body, tags(form.get("tags")), number).run();
5400
+ console.log("note", number, "edited by", me.id);
5401
+ return home();
5402
+ }
5403
+ if (action === "remove") {
5404
+ await env.DB.prepare("delete from notes where id = ?").bind(number).run();
5405
+ console.log("note", number, "removed by", me.id);
5406
+ return home();
5407
+ }
5408
+ if (action === "restore") {
5409
+ // Back on the board as if posted today, so tonight's tidy-up leaves it be.
5410
+ await env.DB.prepare("update notes set archived_at = null, created_at = datetime('now') where id = ?").bind(number).run();
5411
+ console.log("note", number, "put back by", me.id);
5412
+ return home("/?view=archived");
5413
+ }
5414
+ return text("Not found", 404);
5415
+ }
5416
+
5417
+ // The schedule: archive notes older than ARCHIVE_AFTER_DAYS that nobody
5418
+ // pinned, and keep a line saying so, which the page shows as the last run.
5419
+ async function tidy(env) {
5420
+ const { meta } = await env.DB.prepare(
5421
+ "update notes set archived_at = datetime('now') where archived_at is null and pinned = 0 and created_at < datetime('now', ?)",
5422
+ )
5423
+ .bind("-" + ARCHIVE_AFTER_DAYS + " days")
5424
+ .run();
5425
+ const note = "Archived " + meta.changes + (meta.changes === 1 ? " note" : " notes") + " older than " + ARCHIVE_AFTER_DAYS + " days.";
5426
+ await env.DB.prepare("insert into runs (schedule, note) values (?, ?)").bind("tidy", note).run();
5427
+ console.log("tidy:", note);
5428
+ return text(note);
5429
+ }
5430
+
5431
+ async function page(request, url, me, env) {
5432
+ const tag = tags(url.searchParams.get("tag")).split(",")[0];
5433
+ const archived = url.searchParams.get("view") === "archived";
5434
+ let sql = "select id, body, tags, pinned, author_id, author_name, created_at from notes where archived_at is " + (archived ? "not null" : "null");
5435
+ const binds = [];
5436
+ if (tag !== "") {
5437
+ sql += " and (',' || tags || ',') like ?";
5438
+ binds.push("%," + tag + ",%");
5439
+ }
5440
+ sql += " order by pinned desc, id desc limit 200";
5441
+ const notes = (await env.DB.prepare(sql).bind(...binds).all()).results;
5442
+ const archivedCount = await env.DB.prepare("select count(*) as n from notes where archived_at is not null").first("n");
5443
+ const lastRun = await env.DB.prepare("select note, ran_at from runs where schedule = 'tidy' order by id desc limit 1").first();
5444
+
5445
+ const shown = archived
5446
+ ? "<p class='dim'>Notes the nightly tidy-up moved off the board. <a href='/'>Back to the board</a></p>"
5447
+ : form() +
5448
+ "<p class='dim filters'>" +
5449
+ (tag === "" ? "All notes" : "Tagged <b>" + escape(tag) + "</b> · <a href='/'>show all</a>") +
5450
+ " · <a href='/?view=archived'>Archived (" + archivedCount + ")</a></p>";
5451
+
5452
+ return [
5453
+ "<!doctype html>",
5454
+ "<html lang='en'>",
5455
+ "<meta charset='utf-8'>",
5456
+ "<meta name='viewport' content='width=device-width, initial-scale=1'>",
5457
+ "<title>" + TITLE + "</title>",
5458
+ "<style>" + STYLE + "</style>",
5459
+ "<main>",
5460
+ "<header><h1>" + TITLE + "</h1>",
5461
+ "<p class='dim'>Your first app on wirl. Signed in as <b>" + escape(me.name) + "</b>" + (me.role ? " (" + escape(me.role) + ")" : "") + ".</p></header>",
5462
+ shown,
5463
+ "<ul class='notes'>" + (notes.length === 0 ? "<li class='dim'>Nothing here yet.</li>" : notes.map((note) => card(note, me, archived)).join("")) + "</ul>",
5464
+ panel(request, me, lastRun),
5465
+ suggestions(),
5466
+ "</main>",
5467
+ "</html>",
5468
+ ].join("\n");
5469
+ }
5470
+
5471
+ function form() {
5472
+ return [
5473
+ "<form class='add' method='post' action='/notes'>",
5474
+ "<textarea name='body' rows='3' maxlength='" + LONGEST_NOTE + "' required placeholder='Post a note for the team'></textarea>",
5475
+ "<input name='tags' placeholder='Tags, separated by commas'>",
5476
+ "<button class='primary'>Post</button>",
5477
+ "</form>",
5478
+ ].join("");
5479
+ }
5480
+
5481
+ function card(note, me, archived) {
5482
+ const mine = note.author_id === me.id || me.role === "owner";
5483
+ const action = (what, label) =>
5484
+ "<form method='post' action='/notes/" + note.id + "/" + what + "'><button>" + label + "</button></form>";
5485
+ const tagLinks = note.tags
5486
+ .split(",")
5487
+ .filter((tag) => tag !== "")
5488
+ .map((tag) => "<a class='tag' href='/?tag=" + encodeURIComponent(tag) + "'>#" + escape(tag) + "</a>")
5489
+ .join(" ");
5490
+ const controls = archived
5491
+ ? (mine ? action("restore", "Put back") : "")
5492
+ : action("pin", note.pinned ? "Unpin" : "Pin") +
5493
+ (mine
5494
+ ? "<details><summary>Edit</summary><form class='edit' method='post' action='/notes/" + note.id + "/edit'>" +
5495
+ "<textarea name='body' rows='3' maxlength='" + LONGEST_NOTE + "' required>" + escape(note.body) + "</textarea>" +
5496
+ "<input name='tags' value='" + escape(note.tags.split(",").join(", ")) + "'>" +
5497
+ "<button class='primary'>Save</button></form></details>" +
5498
+ action("remove", "Remove")
5499
+ : "");
5500
+ return (
5501
+ "<li class='note" + (note.pinned && !archived ? " pinned" : "") + "'>" +
5502
+ "<p>" + escape(note.body) + "</p>" +
5503
+ "<div class='meta dim'>" + (note.pinned && !archived ? "<b>Pinned</b> · " : "") +
5504
+ escape(note.author_name || "someone") + " · " + escape(note.created_at.slice(0, 10)) + (tagLinks ? " · " + tagLinks : "") + "</div>" +
5505
+ "<div class='actions'>" + controls + "</div>" +
5506
+ "</li>"
5507
+ );
5508
+ }
5509
+
5510
+ // The app's page on the dashboard, from the address it was opened at:
5511
+ // <org>--<app>.wirl.run, or <org>--<app>--<preview>.wirl.run for a preview.
5512
+ // Under wirl dev the address is 127.0.0.1, which names no app: null.
5513
+ function dashboard(request) {
5514
+ const label = new URL(request.url).hostname.split(".")[0];
5515
+ const parts = label.split("--");
5516
+ if (parts.length < 2) return null;
5517
+ const org = request.headers.get("x-wirl-org") || parts[0];
5518
+ return DASHBOARD + "/apps/" + encodeURIComponent(org) + "/" + encodeURIComponent(parts[1]);
5519
+ }
5520
+
5521
+ function panel(request, me, lastRun) {
5522
+ const base = dashboard(request);
5523
+ const last = lastRun ? "Last run " + lastRun.ran_at.slice(0, 16) + " UTC: " + lastRun.note : "Not run yet.";
5524
+ // One row per feature: what it is, what it does in this app, and its page.
5525
+ const rows = [
5526
+ ["Who can open it", "wirl signed you in before this page loaded and tells the app who you are, in headers: here you are " + me.name + (me.role ? ", " + me.role : "") + ". The app has no sign-in code of its own. It started private to you; choose who else can open it on Access.", "/access", "Access"],
5527
+ ["Your data", "Notes are rows in this app's own SQLite database. Its tables are in migrations/0001_init.sql, and a deploy applies each new file in migrations/ before it goes live.", "/deployments", "Deployments"],
5528
+ ["A schedule", "tidy runs every night at 02:00 UTC and archives notes older than " + ARCHIVE_AFTER_DAYS + " days that nobody pinned. " + last + " Run it now from Overview.", "", "Overview"],
5529
+ ["Logs", "Each request writes a line with console.log, and so does each change to a note. Read them on Logs, or ask your agent to run wirl logs.", "/logs", "Logs"],
5530
+ ["Versions", "Each wirl deploy is a new version, and the earlier ones stay: going back to one takes a click.", "/deployments", "Deployments"],
5531
+ ["Previews", "wirl preview puts a change up at an address of its own, with its own database, and nothing goes live until you deploy. Open previews are listed on Overview.", "", "Overview"],
5532
+ ["Outside APIs", "Declare a connection in wirl.json and fetch the service's own URL: wirl adds the key on the way out, so it never reaches this code or a chat.", "/connections", "Connections"],
5533
+ ["Settings", "Describe the app for your team's list of tools, hand it to someone else, or delete it.", "/settings", "Settings"],
5534
+ ];
5535
+ return [
5536
+ "<section><h2>How this works on wirl</h2>",
5537
+ "<table>",
5538
+ ...rows.map(([feature, what, path, label]) =>
5539
+ "<tr><td>" + feature + "</td><td>" + escape(what) + "</td><td>" +
5540
+ (base === null ? "<span class='dim'>" + label + "</span>" : "<a href='" + escape(base + path) + "'>" + label + "</a>") +
5541
+ "</td></tr>",
5542
+ ),
5543
+ "</table>",
5544
+ base === null ? "<p class='dim'>Running under wirl dev, so these pages are not linked here: they open on app.wirl.dev once it is deployed.</p>" : "",
5545
+ "</section>",
5546
+ ].join("\n");
5547
+ }
5548
+
5549
+ function suggestions() {
5550
+ return [
5551
+ "<section><h2>Ask your agent for a first change</h2>",
5552
+ "<p class='dim'>Each of these is a whole prompt. Open one, copy it, and paste it to the coding agent that made this app.</p>",
5553
+ ...SUGGESTIONS.map((each) => "<details><summary>" + escape(each.title) + "</summary><pre>" + escape(each.prompt) + "</pre></details>"),
5554
+ "</section>",
5555
+ ].join("\n");
5556
+ }
5557
+
5558
+ `;
5559
+ var SERVER_TAIL = String.raw`
5560
+
5561
+ const STYLE = [
5562
+ ":root { color-scheme: light dark; --ink: #1d1d1b; --dim: #66655f; --line: #e2e0d9; --card: #ffffff; --bg: #f7f6f2; --pin: #fff3bf; --accent: #2b6b5a; }",
5563
+ "@media (prefers-color-scheme: dark) { :root { --ink: #ecebe6; --dim: #a6a59f; --line: #36352f; --card: #1f1f1c; --bg: #161614; --pin: #3b3420; --accent: #8fd1bd; } }",
5564
+ "body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, sans-serif; }",
5565
+ "main { max-width: 760px; margin: 0 auto; padding: 24px 16px 48px; }",
5566
+ "h1 { font-size: 24px; margin: 0 0 4px; } h2 { font-size: 17px; margin: 36px 0 8px; }",
5567
+ "a { color: var(--accent); } .dim { color: var(--dim); }",
5568
+ "form.add, form.edit { display: grid; gap: 8px; margin: 16px 0 8px; }",
5569
+ "textarea, input, button { font: inherit; color: inherit; padding: 7px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--card); }",
5570
+ "button { cursor: pointer; } button.primary { justify-self: start; background: var(--accent); border-color: var(--accent); color: var(--bg); }",
5571
+ "ul.notes { list-style: none; padding: 0; display: grid; gap: 10px; }",
5572
+ ".note { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }",
5573
+ ".note.pinned { background: var(--pin); }",
5574
+ ".note p { margin: 0 0 6px; white-space: pre-wrap; overflow-wrap: anywhere; }",
5575
+ ".meta, .actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 13px; }",
5576
+ ".actions { margin-top: 8px; } .actions form { margin: 0; } .actions details { flex-basis: 100%; order: 3; }",
5577
+ ".tag { text-decoration: none; }",
5578
+ "table { width: 100%; border-collapse: collapse; font-size: 14px; }",
5579
+ "td { border-top: 1px solid var(--line); padding: 8px 6px; vertical-align: top; }",
5580
+ "td:first-child { font-weight: 600; }",
5581
+ "pre { white-space: pre-wrap; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 10px; font-size: 13px; }",
5582
+ "details { margin: 6px 0; } summary { cursor: pointer; }",
5583
+ ].join("\n");
5584
+
5585
+ const home = (to = "/") => new Response(null, { status: 303, headers: { location: to } });
5586
+
5587
+ const text = (body, status = 200) => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
5588
+
5589
+ const html = (markup) => new Response(markup, { headers: { "content-type": "text/html; charset=utf-8" } });
5590
+
5591
+ // Escapes text for HTML, in content and in quoted attribute values alike.
5592
+ const escape = (value) =>
5593
+ String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
5594
+
5595
+ const decode = (value) => {
5596
+ try {
5597
+ return decodeURIComponent(value);
5598
+ } catch {
5599
+ return value;
5600
+ }
5601
+ };
5602
+
5603
+ // A form field as trimmed text, at most max characters.
5604
+ const clean = (value, max) => (typeof value === "string" ? value.trim().slice(0, max) : "");
5605
+
5606
+ // "Team, Stand-up" becomes "team,stand-up": lowercase words, letters, digits
5607
+ // and hyphens only, so a tag is safe in a link and in the LIKE above.
5608
+ const tags = (value) =>
5609
+ clean(value, 200)
5610
+ .split(",")
5611
+ .map((tag) => tag.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((word) => word !== "").join("-"))
5612
+ .filter((tag, i, all) => tag !== "" && all.indexOf(tag) === i)
5613
+ .slice(0, 8)
5614
+ .join(",");
5615
+ `;
5616
+ var SERVER_MODULE = `${SERVER_HEAD}${SUGGESTIONS_SOURCE}${SERVER_TAIL}`;
5617
+ var STARTER_MIGRATION = [
5618
+ "-- The noticeboard's tables. wirl applies each new file here, in order, before a version goes live.",
5619
+ "-- Make the next change in 0002_<what>.sql; never edit a file that has been deployed.",
5620
+ "create table notes (id integer primary key, body text not null, tags text not null default '', pinned integer not null default 0, author_id text not null, author_name text not null default '', created_at text not null default (datetime('now')), archived_at text)",
5621
+ "create table runs (id integer primary key, schedule text not null, note text not null, ran_at text not null default (datetime('now')))",
5622
+ "insert into notes (body, tags, pinned, author_id, author_name) values ('Welcome to the noticeboard. Post a note below, tag it, and pin the ones everyone should see.', 'welcome', 1, '', 'wirl')",
5623
+ ""
5624
+ ].join("\n");
5625
+ function starterManifest(app, org) {
5626
+ const manifest = {
5627
+ app,
5628
+ ...org ? { org } : {},
5629
+ dir: ".",
5630
+ server: "server.mjs",
5631
+ description: "A team noticeboard: your first app on wirl",
5632
+ visibility: "private",
5633
+ schedules: [{ name: "tidy", cron: "0 2 * * *", timezone: "UTC" }]
5634
+ };
5635
+ return `${JSON.stringify(manifest, null, 2)}
5636
+ `;
5637
+ }
5638
+ var README = [
5639
+ "# Team noticeboard",
5640
+ "",
5641
+ "Your first app on wirl: a board where your team posts short notes, tags them, pins the ones everyone should see, and edits or removes their own. Every night a schedule archives old notes nobody pinned.",
5642
+ "",
5643
+ 'Open it at the address `wirl deploy` prints. It starts private to you. Its page has a "How this works on wirl" panel: one row per thing wirl does for this app, each linking to that page of the app on app.wirl.dev.',
5644
+ "",
5645
+ "## What is where",
5646
+ "",
5647
+ "- `server.mjs`: the whole app, one module with one `fetch`. The page, the forms, who may change a note, and the nightly tidy-up.",
5648
+ "- `migrations/0001_init.sql`: the tables, one statement a line. Put the next change in `migrations/0002_<what>.sql`.",
5649
+ "- `wirl.json`: the app's name, its module, its nightly schedule, and that it is created private.",
5650
+ "- `.claude/skills/wirl/SKILL.md`: how a coding agent works with wirl.",
5651
+ "",
5652
+ "## Run it on this computer",
5653
+ "",
5654
+ ` ${WIRL_BIN} dev`,
5655
+ "",
5656
+ "It prints the address to open, and a `curl` line that runs the nightly tidy-up now.",
5657
+ "",
5658
+ "## Changes to ask your agent for",
5659
+ "",
5660
+ "Each is a whole prompt: paste it to your coding agent as it is.",
5661
+ "",
5662
+ ...SUGGESTIONS.flatMap(({ title, prompt }) => [`### ${title}`, "", `> ${prompt}`, ""])
5663
+ ].join("\n");
5664
+ var STARTER_FILES = {
5665
+ "server.mjs": SERVER_MODULE,
5666
+ "migrations/0001_init.sql": STARTER_MIGRATION,
5667
+ "README.md": README,
5668
+ "wirl.json": starterManifest("starter")
5669
+ };
5670
+
4946
5671
  // packages/cli/src/core.ts
4947
5672
  function credentialsPath(env = process.env) {
4948
5673
  return env["WIRL_CREDENTIALS_PATH"] || join5(homedir(), ".config", "wirl", "credentials.json");
@@ -5233,23 +5958,9 @@ var SKILL_RUN = NPM_PUBLISHED ? `Run the CLI as \`${WIRL_BIN} <command>\`, which
5233
5958
  ` : "";
5234
5959
  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}\`.
5235
5960
  ` : "";
5236
- var SKILL_MARKDOWN = `---
5237
- name: wirl
5238
- 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.
5239
- ---
5240
-
5241
- # wirl
5242
-
5243
- A new app is visible to everyone in the org when the org came from a
5244
- company email domain; in any other org, including a personal one or one
5245
- made with \`wirl orgs create\`, it starts private. Deploys are one
5246
- command; sharing is by email; a third-party API is declared, never pasted.
5247
-
5248
- 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.
5249
-
5250
- ## Commands
5251
-
5252
- ${SKILL_RUN}- Connect first, if the agent supports MCP: \`claude mcp add --transport http wirl https://app.wirl.dev/mcp\` opens the browser once to sign in, and needs no install. No HTTP MCP support? \`claude mcp add wirl -- npx -y ${SKILL_INSTALL.mcp}\` does the same, running locally. Neither available: \`${WIRL_BIN} login\` prints a link and a code; the person approves in the browser. Do this once, when any command says you are not signed in.
5961
+ var SKILL_MARKDOWN = composeSkill({
5962
+ 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.`,
5963
+ 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.
5253
5964
  - \`${WIRL_BIN} deploy\` \u2014 deploys this directory. Prints the URL and who can open it.
5254
5965
  - \`${WIRL_BIN} share someone@company.com\` \u2014 that person can open it. Add \`--role editor\` so they can deploy too.
5255
5966
  - \`${WIRL_BIN} dev\` \u2014 runs the app here as it will run deployed, signed in as a stub user.
@@ -5258,33 +5969,8 @@ ${SKILL_RUN}- Connect first, if the agent supports MCP: \`claude mcp add --trans
5258
5969
  - \`${WIRL_BIN} migrations\` \u2014 what this app's database has applied, read only.
5259
5970
  - \`${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.
5260
5971
  - \`${WIRL_BIN} --help\` \u2014 everything else: orgs, members, connections, move, transfer, rollback, flows and runs.
5261
- ${SKILL_FALLBACK}
5262
- ## Rules
5263
-
5264
- 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).
5265
- 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.
5266
- 3. If a command names several organisations and asks which, ask the person; pass \`--org\` or put \`"org"\` in \`wirl.json\`.
5267
- 4. To keep a file out of the deploy, list it in a \`.wirlignore\` at the project root (gitignore-style lines; no \`!\` negation).
5268
- 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.
5269
-
5270
- ## wirl.json
5271
-
5272
- \`\`\`json
5273
- { "app": "crm-sync", "server": "server.mjs", "connections": [{ "slug": "hubspot", "hosts": ["api.hubspot.com"] }] }
5274
- \`\`\`
5275
-
5276
- ## What runs on wirl
5277
-
5278
- ${BUILD_CONTRACT}
5279
-
5280
- 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.
5281
-
5282
- 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.
5283
-
5284
- ## Flows
5285
-
5286
- ${FLOWS_CONTRACT}
5287
- `;
5972
+ ${SKILL_FALLBACK}`
5973
+ });
5288
5974
  async function pullNextStep(dir) {
5289
5975
  return `cd ${shellPath(dir)} && ${await pulledDevCommand(dir)}`;
5290
5976
  }
@@ -5550,7 +6236,7 @@ async function callApi(creds, path, options = {}) {
5550
6236
  const body = parsed ?? {};
5551
6237
  const refusal = apiRefusal(parsed, response);
5552
6238
  if (response.status === 403 && body.error === "insufficient_scope" && body.message) {
5553
- throw new CliError(body.message, "Ask the person to do it in the dashboard, or reconnect with full access.", refusal);
6239
+ throw new CliError(body.message, `Ask the person to do it in the dashboard, or run ${WIRL_BIN} login and tick full access on the approval page.`, refusal);
5554
6240
  }
5555
6241
  if (response.status === 403 && (body.error === "person_only" || body.error === "bad_confirmation") && body.message) {
5556
6242
  throw new CliError(body.message, void 0, refusal);
@@ -5595,23 +6281,51 @@ async function callApiBytes(creds, path, options = {}) {
5595
6281
  }
5596
6282
  const declared = Number(response.headers.get("content-length") ?? 0);
5597
6283
  if (declared > MAX_PULL_BYTES) throw new CliError(`That is ${Math.round(declared / 1048576)} MB, more than wirl will pull.`);
5598
- const bytes = new Uint8Array(await response.arrayBuffer());
6284
+ let bytes;
6285
+ try {
6286
+ bytes = new Uint8Array(await response.arrayBuffer());
6287
+ } catch {
6288
+ throw new CliError(PULL_CUT, void 0, { code: "pull_cut" });
6289
+ }
5599
6290
  if (bytes.byteLength > MAX_PULL_BYTES) throw new CliError("The download was larger than wirl will pull.");
5600
6291
  return bytes;
5601
6292
  }
6293
+ var PULL_CUT = "The pull was cut before it finished, so nothing was written. Run it again.";
6294
+ var pullShort = (dir) => `The pull's archive is cut short or damaged, so nothing was written to ${dir}. Run it again.`;
5602
6295
  var MAX_PULL_BYTES = 256 * 1048576;
5603
6296
  var pullApp = (creds, org, app, options = {}) => callApiBytes(creds, `/api/v1/apps/${seg(org)}/${seg(app)}/pull`, options.fetchImpl ? { fetchImpl: options.fetchImpl } : {});
6297
+ function isWholeArchive(archive) {
6298
+ return new Promise((resolve6, reject) => {
6299
+ let sound = true;
6300
+ let ended2 = false;
6301
+ const parser = new TarParser({ onReadEntry: (entry) => void entry.resume() });
6302
+ parser.on("warn", (code) => {
6303
+ if (code === "TAR_BAD_ARCHIVE" || code === "TAR_ENTRY_INVALID") sound = false;
6304
+ });
6305
+ parser.on("eof", () => {
6306
+ ended2 = true;
6307
+ });
6308
+ parser.on("error", (error) => typeof error.code === "string" && error.code.startsWith("Z_") ? resolve6(false) : reject(error));
6309
+ parser.on("end", () => resolve6(sound && ended2));
6310
+ parser.end(archive);
6311
+ });
6312
+ }
5604
6313
  async function unpackPull(bytes, dir, options = {}) {
5605
- await mkdir3(dir, { recursive: true });
5606
- const existing = await readdir(dir);
6314
+ const existing = await readdir(dir).catch((error) => {
6315
+ if (error.code === "ENOENT") return [];
6316
+ throw error;
6317
+ });
5607
6318
  if (existing.length > 0 && !options.force) {
5608
6319
  throw new CliError(`${dir} is not empty.`, "Pull into a new directory, or pass --force to overwrite.");
5609
6320
  }
6321
+ const archive = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
6322
+ if (!await isWholeArchive(archive)) throw new CliError(pullShort(dir), void 0, { code: "pull_cut" });
6323
+ await mkdir3(dir, { recursive: true });
5610
6324
  await new Promise((resolve6, reject) => {
5611
6325
  const extractor = tarExtract({ cwd: dir });
5612
6326
  extractor.on("error", reject);
5613
6327
  extractor.on("finish", resolve6);
5614
- extractor.end(Buffer.from(bytes));
6328
+ extractor.end(archive);
5615
6329
  });
5616
6330
  const written = [];
5617
6331
  await collect(dir, dir, written);
@@ -5703,6 +6417,11 @@ function manifestWarningLines(manifest) {
5703
6417
  const parsed = parseManifest(manifest);
5704
6418
  return parsed.ok ? parsed.warnings.map((warning) => `Warning: ${warning}`) : [];
5705
6419
  }
6420
+ function manifestVisibility(manifest) {
6421
+ if (manifest === null || manifest === void 0) return void 0;
6422
+ const parsed = parseManifest(manifest);
6423
+ return parsed.ok ? parsed.manifest.visibility : void 0;
6424
+ }
5706
6425
  async function prepareDeployBundle(dir, manifest, door, projectDir) {
5707
6426
  let server;
5708
6427
  let rules = NO_MANIFEST;
@@ -5911,13 +6630,25 @@ function chooseOrg(orgs, given, hint = 'Pass --org <slug>, or add "org" to wirl.
5911
6630
  throw new CliError(`Which organisation? You belong to ${orgs.map((o) => o.slug).join(", ")}.`, hint, { code: "missing_argument" });
5912
6631
  }
5913
6632
  var listApps = (creds, org) => callApi(creds, `/api/v1/orgs/${seg(org)}/apps`);
5914
- var createRemoteApp = (creds, org, slug, name) => callApi(creds, `/api/v1/orgs/${seg(org)}/apps`, {
6633
+ var createRemoteApp = (creds, org, slug, name, visibility) => callApi(creds, `/api/v1/orgs/${seg(org)}/apps`, {
5915
6634
  method: "POST",
5916
- body: JSON.stringify({ slug, name: name ?? slug }),
6635
+ // `visibility` rides along only when wirl.json said it: absent, the
6636
+ // org's default holds. It applies to the app this create makes and to
6637
+ // no other: an app that exists answers 409 and is left as it is.
6638
+ body: JSON.stringify({ slug, name: name ?? slug, ...visibility === void 0 ? {} : { visibility } }),
5917
6639
  contentType: "application/json"
5918
6640
  });
5919
- async function ensureRemoteApp(creds, org, app) {
5920
- return createRemoteApp(creds, org, app).then(() => true, (error) => {
6641
+ async function ensureRemoteApp(creds, org, app, visibility) {
6642
+ return createRemoteApp(creds, org, app, void 0, visibility).then((created) => {
6643
+ const made = created.app?.visibility;
6644
+ if (visibility === "private" && typeof made === "string" && made !== "private") {
6645
+ process.stderr.write(
6646
+ `${terminalSafe(`wirl: asked for a private app, but ${org}/${app} was created visible to everyone in the organisation (an older control plane, or someone created it first). Change it with: wirl visibility private --org ${org} --app ${app}`)}
6647
+ `
6648
+ );
6649
+ }
6650
+ return true;
6651
+ }, (error) => {
5921
6652
  if (!(error instanceof CliError)) throw error;
5922
6653
  if (error.fields["code"] === "app_deleted") throw deletedNameError(error, app);
5923
6654
  if (error.fields["code"] === "app_exists" || error.fields["code"] === "app_moved") throw takenNameError(error, app);
@@ -5969,10 +6700,13 @@ function describeVisibility(level, org) {
5969
6700
  }
5970
6701
  }
5971
6702
  var readVisibility = (creds, org, app) => callApi(creds, `/api/v1/apps/${seg(org)}/${seg(app)}`).then((r) => r.app.visibility ?? "unknown");
5972
- async function changeVisibility(creds, options) {
5973
- if (!VISIBILITY_LEVELS.includes(options.level)) {
5974
- throw new CliError(`Visibility is private, org or public, not "${String(options.level)}".`);
6703
+ function refuseVisibilityLevel(level) {
6704
+ if (!VISIBILITY_LEVELS.includes(level)) {
6705
+ throw new CliError(`Visibility is private, org or public, not "${String(level)}".`);
5975
6706
  }
6707
+ }
6708
+ async function changeVisibility(creds, options) {
6709
+ refuseVisibilityLevel(options.level);
5976
6710
  if (options.level === "public") {
5977
6711
  throw new CliError(
5978
6712
  "Public means anyone on the internet with the link can open it, without signing in, so a person decides that with their own click.",
@@ -5987,11 +6721,59 @@ async function changeVisibility(creds, options) {
5987
6721
  return result2.visibility;
5988
6722
  }
5989
6723
  var unmailedNote = (result2) => result2.mailed === false && result2.message ? ` ${result2.message}` : "";
6724
+ function refuseShareRole(value) {
6725
+ const read = readShareRole(value);
6726
+ if (read.ok) return read.role;
6727
+ const shown2 = typeof value === "string" ? terminalSafe(value.slice(0, 40)) : typeof value;
6728
+ throw new CliError(`Role is viewer or editor, not "${shown2}".`);
6729
+ }
5990
6730
  var shareApp = (creds, org, app, email, role) => callApi(creds, `/api/v1/apps/${seg(org)}/${seg(app)}/share`, {
5991
6731
  method: "POST",
5992
- body: JSON.stringify({ email, role }),
6732
+ // No role is no `role`: the control plane then keeps the one a person has.
6733
+ body: JSON.stringify(role === void 0 ? { email } : { email, role }),
5993
6734
  contentType: "application/json"
5994
6735
  });
6736
+ function shareSentence(result2, email, asked, voice) {
6737
+ const roleOf = (value) => value === "viewer" || value === "editor" ? value : void 0;
6738
+ const outcome = ["added", "kept", "unchanged", "changed"].includes(String(result2.outcome)) ? result2.outcome : void 0;
6739
+ const role = roleOf(result2.role);
6740
+ const old = outcome === void 0 || role === void 0;
6741
+ const now = role ?? asked ?? "viewer";
6742
+ const was = roleOf(result2.previousRole);
6743
+ const article = (name) => name === "editor" ? "an editor" : "a viewer";
6744
+ const mail = unmailedNote(result2);
6745
+ const signIn = voice === "cli" ? "the moment they first sign in" : "when they first sign in";
6746
+ if (result2.pending) {
6747
+ const wait = `They will have access ${signIn}.`;
6748
+ if (!old && outcome === "kept") return `${email} was already invited as ${now}, and no role was given, so it stays ${now}. ${wait}${mail}`;
6749
+ const lead2 = `Invited ${email} as ${now}. ${wait}`;
6750
+ if (old || outcome === "added") return `${lead2}${mail}`;
6751
+ if (outcome === "unchanged") return `${lead2} The invitation already said that.${mail}`;
6752
+ if (was === void 0) return `${lead2} The invitation's role was changed.${mail}`;
6753
+ return `${lead2} The invitation said ${was} before.${mail}`;
6754
+ }
6755
+ if (!old && outcome === "kept") {
6756
+ const change = voice === "cli" ? "Pass --role viewer or --role editor" : 'Pass role "viewer" or "editor"';
6757
+ return `${email} already had access as ${now}, and no role was given, so it stays ${now}. ${change} to change it.`;
6758
+ }
6759
+ const lead = voice === "cli" ? `Shared with ${email} as ${now}.` : `${email} can now open the app as ${now}.`;
6760
+ if (old || outcome === "added") return lead;
6761
+ if (outcome === "unchanged") return `${lead} They already had that role.`;
6762
+ if (was === void 0) return `${lead} Their role was changed.`;
6763
+ return `${lead} They were ${article(was)} before.`;
6764
+ }
6765
+ var unshareApp = (creds, org, app, email) => callApi(creds, `/api/v1/apps/${seg(org)}/${seg(app)}/share`, {
6766
+ method: "DELETE",
6767
+ body: JSON.stringify({ email }),
6768
+ contentType: "application/json"
6769
+ });
6770
+ function unshareSentence(result2, email) {
6771
+ const share = result2.shareRevoked ?? result2.revoked;
6772
+ const invitation = result2.invitationWithdrawn ?? false;
6773
+ 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.`;
6774
+ const wasDeleted = result2.appDeleted === true && (share || invitation) ? " The app was deleted when this was asked, so a restore will not bring it back." : "";
6775
+ 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.`;
6776
+ }
5995
6777
  var listDeployments = (creds, org, app) => callApi(
5996
6778
  creds,
5997
6779
  `/api/v1/apps/${seg(org)}/${seg(app)}/deployments`
@@ -6119,7 +6901,7 @@ var fetchLogs = (creds, org, app, filters) => callApi(
6119
6901
  );
6120
6902
  var LINE_INDENT = " ";
6121
6903
  function terminalSafe(text) {
6122
- return escapeInvisible(text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`));
6904
+ 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")}`));
6123
6905
  }
6124
6906
  function utcSecond(at2) {
6125
6907
  const ms = Date.parse(at2);
@@ -6155,7 +6937,8 @@ function formatLogPage(page, since) {
6155
6937
  // queryable. Without this an empty answer right after a request
6156
6938
  // reads as "nothing happened".
6157
6939
  " A request made in the last minute may not have arrived yet: ask again shortly."
6158
- )}` : page.invocations.map((entry) => formatInvocation(entry)).join("\n\n");
6940
+ )}
6941
+ ${EDGE_REFUSALS_NOTE}` : page.invocations.map((entry) => formatInvocation(entry)).join("\n\n");
6159
6942
  return page.cursor === null ? body : `${body}
6160
6943
 
6161
6944
  More before this: pass cursor=${page.cursor}.`;
@@ -6555,20 +7338,22 @@ async function previewFetch(creds, org, app, name, path, options) {
6555
7338
  const headers = callerHeaders(options.headers);
6556
7339
  headers.set(VISIT_HEADER, mint.token);
6557
7340
  const networkFetch = options.networkFetchImpl ?? fetch;
6558
- const sentAt = Date.now();
7341
+ const clock = options.now ?? (() => performance.now());
7342
+ const sentAt = clock();
7343
+ const elapsed = () => Math.round(clock() - sentAt);
6559
7344
  const response = await networkFetch(target.toString(), {
6560
7345
  method: options.method ?? (options.body !== void 0 ? "POST" : "GET"),
6561
7346
  headers,
6562
7347
  redirect: "manual",
6563
7348
  ...options.body === void 0 ? {} : { body: options.body }
6564
7349
  });
6565
- const headersMs = Date.now() - sentAt;
7350
+ const headersMs = elapsed();
6566
7351
  const contentType = response.headers.get("content-type") ?? "";
6567
7352
  const location = response.headers.get("location");
6568
7353
  const text = isTextBody(contentType);
6569
7354
  const streamWaitMs = Math.min(options.streamWaitMs ?? FETCH_STREAM_WAIT_MS, FETCH_WAIT_MAX_SECONDS * 1e3);
6570
7355
  const keep = options.keepBodyBytes === void 0 ? null : Math.min(options.keepBodyBytes, FETCH_FILE_MAX_BYTES);
6571
- const read = await readBounded(response, streamWaitMs, sentAt, keep ?? FETCH_READ_CEILING_BYTES);
7356
+ const read = await readBounded(response, streamWaitMs, elapsed, keep ?? FETCH_READ_CEILING_BYTES);
6572
7357
  const bytes = read.bytes;
6573
7358
  const truncated = text && (read.cut || bytes.byteLength > FETCH_BODY_CAP_BYTES);
6574
7359
  const media = (contentType.split(";")[0] ?? "").trim().toLowerCase();
@@ -6644,7 +7429,7 @@ var FETCH_STREAM_WAIT_MS = 1e4;
6644
7429
  var FETCH_WAIT_MAX_SECONDS = 300;
6645
7430
  var FETCH_READ_CEILING_BYTES = 8 * FETCH_BODY_CAP_BYTES;
6646
7431
  var FETCH_FILE_MAX_BYTES = 1e8;
6647
- async function readBounded(response, waitMs, sentAt, ceilingBytes) {
7432
+ async function readBounded(response, waitMs, elapsed, ceilingBytes) {
6648
7433
  if (response.body === null) return { bytes: new Uint8Array(0), chunks: [], stillStreaming: false, cut: false };
6649
7434
  const reader = response.body.getReader();
6650
7435
  const chunks = [];
@@ -6663,7 +7448,7 @@ async function readBounded(response, waitMs, sentAt, ceilingBytes) {
6663
7448
  break;
6664
7449
  }
6665
7450
  if (next.done) break;
6666
- chunks.push({ atMs: Date.now() - sentAt, bytes: next.value });
7451
+ chunks.push({ atMs: elapsed(), bytes: next.value });
6667
7452
  total += next.value.byteLength;
6668
7453
  if (total > ceilingBytes) {
6669
7454
  cut = true;
@@ -6708,9 +7493,9 @@ function formatFetchResult(result2, saved) {
6708
7493
  0,
6709
7494
  `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)`
6710
7495
  );
7496
+ const unfinished = lastLineUnfinished(result2);
6711
7497
  timed.forEach((line, i) => {
6712
- const unfinished = i === timed.length - 1 && !result2.timed.lastComplete && (!result2.bodyComplete || result2.truncated);
6713
- lines.push(line.text === "" ? "" : `+${seconds(line.atMs)}s ${line.text}${unfinished ? " (the line had not ended)" : ""}`);
7498
+ lines.push(line.text === "" ? "" : `+${seconds(line.atMs)}s ${line.text}${unfinished && i === timed.length - 1 ? " (the line had not ended)" : ""}`);
6714
7499
  });
6715
7500
  } else {
6716
7501
  lines.push(result2.bodyText);
@@ -6723,6 +7508,44 @@ function formatFetchResult(result2, saved) {
6723
7508
  }
6724
7509
  return lines.join("\n");
6725
7510
  }
7511
+ function lastLineUnfinished(result2) {
7512
+ return result2.timed !== null && !result2.timed.lastComplete && (!result2.bodyComplete || result2.truncated);
7513
+ }
7514
+ var STREAMED_DATA_MAX_LINES = 2e3;
7515
+ var STREAMED_DATA_MAX_TEXT_BYTES = 16 * 1024;
7516
+ function streamedFetchData(result2) {
7517
+ if (result2.bodyText === null || result2.timed === null) return null;
7518
+ const listed2 = listedLines(result2.timed.lines);
7519
+ return {
7520
+ status: result2.status,
7521
+ statusText: result2.statusText,
7522
+ contentType: result2.contentType,
7523
+ location: result2.location,
7524
+ refused: result2.refused,
7525
+ answeredAfterMs: result2.headersMs,
7526
+ streamWaitMs: result2.streamWaitMs,
7527
+ stillStreaming: result2.stillStreaming,
7528
+ bodyComplete: result2.bodyComplete,
7529
+ truncated: result2.truncated,
7530
+ bodyBytes: result2.bodyBytes,
7531
+ pieces: result2.timed.pieces,
7532
+ lastLineEnded: !lastLineUnfinished(result2),
7533
+ lines: listed2,
7534
+ linesLeftOut: result2.timed.lines.length - listed2.length
7535
+ };
7536
+ }
7537
+ function listedLines(all) {
7538
+ const listed2 = [];
7539
+ let bytes = 0;
7540
+ for (const line of all) {
7541
+ if (listed2.length >= STREAMED_DATA_MAX_LINES) break;
7542
+ const size = Buffer.byteLength(line.text);
7543
+ if (listed2.length > 0 && bytes + size > STREAMED_DATA_MAX_TEXT_BYTES) break;
7544
+ bytes += size;
7545
+ listed2.push({ atMs: line.atMs, text: line.text });
7546
+ }
7547
+ return listed2;
7548
+ }
6726
7549
  function seconds(ms) {
6727
7550
  return (ms / 1e3).toFixed(2);
6728
7551
  }
@@ -7073,6 +7896,8 @@ function formatMigrationsListing(listing, app) {
7073
7896
  init_src();
7074
7897
  import { hostname } from "node:os";
7075
7898
  import { basename as basename3, resolve as resolve5 } from "node:path";
7899
+ var reads = (title) => ({ title, readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false });
7900
+ var readsTheirText = (title) => ({ ...reads(title), openWorldHint: true });
7076
7901
  var APPS_DOMAIN = "wirl.run";
7077
7902
  var pendingLogin = null;
7078
7903
  function loginInstructions(login) {
@@ -7082,9 +7907,16 @@ function loginInstructions(login) {
7082
7907
  ` ${login.verificationUriComplete}`,
7083
7908
  ` Code: ${login.userCode}`,
7084
7909
  "",
7910
+ "Approving gives build-and-update access: deploy, preview, logs, roll back and pull. If you will need to share, delete or manage connections, ask the person to tick full access on the approval page.",
7911
+ "",
7085
7912
  `Once they have approved, call the wirl tool again. The code expires in ${Math.round(login.expiresIn / 60)} minutes.`
7086
7913
  ].join("\n");
7087
7914
  }
7915
+ function alreadySignedIn(me) {
7916
+ const orgs = `Organisations: ${me.orgs.map((o) => o.slug).join(", ") || "none yet"}.`;
7917
+ if ((me.scope ?? "full") === "full") return `Already signed in, with full access. ${orgs}`;
7918
+ return `Already signed in, with build-and-update access. ${orgs} To share, delete or manage connections, the person runs ${WIRL_BIN} login in a terminal and ticks full access on the approval page.`;
7919
+ }
7088
7920
  async function beginLogin() {
7089
7921
  const apiOrigin = apiOriginFromEnv();
7090
7922
  const login = await startDeviceLogin(apiOrigin, { client: `Claude Code on ${hostname()}` });
@@ -7155,6 +7987,7 @@ var SHARED = [
7155
7987
  {
7156
7988
  name: "wirl_list_connections",
7157
7989
  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.`,
7990
+ annotations: reads("List connections"),
7158
7991
  inputSchema: {
7159
7992
  type: "object",
7160
7993
  properties: {
@@ -7177,27 +8010,55 @@ var SHARED = [
7177
8010
  },
7178
8011
  {
7179
8012
  name: "wirl_share",
7180
- 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.`,
8013
+ 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.`,
8014
+ annotations: { title: "Share an app with a person", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
7181
8015
  inputSchema: {
7182
8016
  type: "object",
7183
8017
  properties: {
7184
8018
  app: { type: "string", description: 'The app slug, e.g. "quarterly-dashboard".' },
7185
8019
  email: { type: "string", description: "Work email address of the person." },
7186
- role: { type: "string", enum: ["viewer", "editor"], description: "Defaults to viewer." },
8020
+ role: {
8021
+ type: "string",
8022
+ enum: ["viewer", "editor"],
8023
+ 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."
8024
+ },
7187
8025
  org: { type: "string", description: "Organisation slug. Optional when the person belongs to only one; otherwise the tool says which ones exist and asks." }
7188
8026
  },
7189
8027
  required: ["app", "email"]
7190
8028
  },
7191
8029
  async handler(args, ctx) {
8030
+ const role = refuseShareRole(args["role"]);
7192
8031
  const org = await defaultOrg(ctx.creds, str(args, "org"));
7193
- const role = str(args, "role") === "editor" ? "editor" : "viewer";
7194
- const result2 = await shareApp(ctx.creds, org, str(args, "app"), str(args, "email"), role);
7195
- 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}.`;
8032
+ const email = str(args, "email");
8033
+ const result2 = await shareApp(ctx.creds, org, str(args, "app"), email, role);
8034
+ return shareSentence(result2, email, role, "mcp");
8035
+ }
8036
+ },
8037
+ {
8038
+ name: "wirl_unshare",
8039
+ 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.`,
8040
+ annotations: { title: "Take back a share", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
8041
+ inputSchema: {
8042
+ type: "object",
8043
+ properties: {
8044
+ app: { type: "string", description: 'The app slug, e.g. "quarterly-dashboard".' },
8045
+ email: { type: "string", description: "The email address the app was shared with." },
8046
+ org: { type: "string", description: "Organisation slug. Optional when the person belongs to only one; otherwise the tool says which ones exist and asks." }
8047
+ },
8048
+ required: ["app", "email"]
8049
+ },
8050
+ async handler(args, ctx) {
8051
+ refuseUnshareArgs(args);
8052
+ const org = await defaultOrg(ctx.creds, str(args, "org"));
8053
+ const email = str(args, "email");
8054
+ const result2 = await unshareApp(ctx.creds, org, str(args, "app"), email);
8055
+ return unshareSentence(result2, email);
7196
8056
  }
7197
8057
  },
7198
8058
  {
7199
8059
  name: "wirl_visibility",
7200
8060
  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.`,
8061
+ annotations: { title: "See or set who can open an app", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
7201
8062
  inputSchema: {
7202
8063
  type: "object",
7203
8064
  properties: {
@@ -7221,6 +8082,7 @@ var SHARED = [
7221
8082
  {
7222
8083
  name: "wirl_list_apps",
7223
8084
  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.",
8085
+ annotations: reads("List apps"),
7224
8086
  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." } } },
7225
8087
  async handler(args, ctx) {
7226
8088
  const org = await defaultOrg(ctx.creds, str(args, "org"));
@@ -7232,6 +8094,7 @@ var SHARED = [
7232
8094
  {
7233
8095
  name: "wirl_list_versions",
7234
8096
  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.",
8097
+ annotations: reads("List app versions"),
7235
8098
  inputSchema: {
7236
8099
  type: "object",
7237
8100
  properties: {
@@ -7250,6 +8113,7 @@ var SHARED = [
7250
8113
  {
7251
8114
  name: "wirl_rollback",
7252
8115
  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.",
8116
+ annotations: { title: "Roll an app back", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
7253
8117
  inputSchema: {
7254
8118
  type: "object",
7255
8119
  properties: {
@@ -7271,6 +8135,7 @@ var SHARED = [
7271
8135
  {
7272
8136
  name: "wirl_migrations",
7273
8137
  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.",
8138
+ annotations: reads("Show applied migrations"),
7274
8139
  inputSchema: {
7275
8140
  type: "object",
7276
8141
  properties: {
@@ -7291,6 +8156,7 @@ var SHARED = [
7291
8156
  {
7292
8157
  name: "wirl_list_schedules",
7293
8158
  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.",
8159
+ annotations: reads("List schedules"),
7294
8160
  inputSchema: {
7295
8161
  type: "object",
7296
8162
  properties: {
@@ -7309,6 +8175,7 @@ var SHARED = [
7309
8175
  {
7310
8176
  name: "wirl_run",
7311
8177
  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.",
8178
+ annotations: { title: "Run a schedule now", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
7312
8179
  inputSchema: {
7313
8180
  type: "object",
7314
8181
  properties: {
@@ -7342,6 +8209,7 @@ var SHARED = [
7342
8209
  {
7343
8210
  name: "wirl_list_flows",
7344
8211
  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.`,
8212
+ annotations: reads("List flows"),
7345
8213
  inputSchema: {
7346
8214
  type: "object",
7347
8215
  properties: {
@@ -7362,6 +8230,7 @@ var SHARED = [
7362
8230
  {
7363
8231
  name: "wirl_start_flow",
7364
8232
  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.",
8233
+ annotations: { title: "Start a flow run", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
7365
8234
  inputSchema: {
7366
8235
  type: "object",
7367
8236
  properties: {
@@ -7407,6 +8276,7 @@ var SHARED = [
7407
8276
  {
7408
8277
  name: "wirl_list_runs",
7409
8278
  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.",
8279
+ annotations: reads("List flow runs"),
7410
8280
  inputSchema: {
7411
8281
  type: "object",
7412
8282
  properties: {
@@ -7431,6 +8301,7 @@ var SHARED = [
7431
8301
  {
7432
8302
  name: "wirl_show_run",
7433
8303
  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.`,
8304
+ annotations: readsTheirText("Show a flow run"),
7434
8305
  inputSchema: {
7435
8306
  type: "object",
7436
8307
  properties: {
@@ -7451,6 +8322,7 @@ var SHARED = [
7451
8322
  {
7452
8323
  name: "wirl_send_event",
7453
8324
  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.",
8325
+ annotations: { title: "Answer a wait on a preview run", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
7454
8326
  inputSchema: {
7455
8327
  type: "object",
7456
8328
  properties: {
@@ -7492,6 +8364,7 @@ var SHARED = [
7492
8364
  {
7493
8365
  name: "wirl_cancel_run",
7494
8366
  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.",
8367
+ annotations: { title: "Cancel a flow run", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
7495
8368
  inputSchema: {
7496
8369
  type: "object",
7497
8370
  properties: {
@@ -7512,6 +8385,7 @@ var SHARED = [
7512
8385
  {
7513
8386
  name: "wirl_retry_run",
7514
8387
  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.",
8388
+ annotations: { title: "Retry a failed flow run", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
7515
8389
  inputSchema: {
7516
8390
  type: "object",
7517
8391
  properties: {
@@ -7541,6 +8415,7 @@ var SHARED = [
7541
8415
  {
7542
8416
  name: "wirl_logs",
7543
8417
  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.",
8418
+ annotations: readsTheirText("Read app logs"),
7544
8419
  inputSchema: {
7545
8420
  type: "object",
7546
8421
  properties: {
@@ -7593,6 +8468,7 @@ var SHARED = [
7593
8468
  {
7594
8469
  name: "wirl_egress",
7595
8470
  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.",
8471
+ annotations: readsTheirText("Read app outgoing calls"),
7596
8472
  inputSchema: {
7597
8473
  type: "object",
7598
8474
  properties: {
@@ -7624,6 +8500,7 @@ var SHARED = [
7624
8500
  {
7625
8501
  name: "wirl_list_previews",
7626
8502
  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.`,
8503
+ annotations: reads("List previews"),
7627
8504
  inputSchema: {
7628
8505
  type: "object",
7629
8506
  properties: {
@@ -7641,7 +8518,8 @@ var SHARED = [
7641
8518
  },
7642
8519
  {
7643
8520
  name: "wirl_preview_fetch",
7644
- 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.`,
8521
+ 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.`,
8522
+ annotations: { title: "Request a page from a preview", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
7645
8523
  inputSchema: {
7646
8524
  type: "object",
7647
8525
  properties: {
@@ -7659,24 +8537,15 @@ var SHARED = [
7659
8537
  },
7660
8538
  required: ["app", "name", "path"]
7661
8539
  },
8540
+ answer: previewFetchAnswer,
7662
8541
  async handler(args, ctx) {
7663
- const org = await defaultOrg(ctx.creds, str(args, "org"));
7664
- const headersArg = args["headers"];
7665
- const headers = headersArg !== null && typeof headersArg === "object" && !Array.isArray(headersArg) ? Object.fromEntries(Object.entries(headersArg).map(([k, v]) => [k, String(v)])) : void 0;
7666
- const appsDomain = ctx.appsDomain ?? (await whoami(ctx.creds)).appsDomain;
7667
- const result2 = await previewFetch(ctx.creds, org, str(args, "app"), str(args, "name"), str(args, "path", "/"), {
7668
- appsDomain,
7669
- ...ctx.networkFetch ? { networkFetchImpl: ctx.networkFetch } : {},
7670
- ...str(args, "method") ? { method: str(args, "method") } : {},
7671
- ...typeof args["body"] === "string" ? { body: args["body"] } : {},
7672
- ...headers ? { headers } : {}
7673
- });
7674
- return formatFetchResult(result2);
8542
+ return (await previewFetchAnswer(args, ctx)).text;
7675
8543
  }
7676
8544
  },
7677
8545
  {
7678
8546
  name: "wirl_delete_preview",
7679
8547
  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.`,
8548
+ annotations: { title: "Delete a preview", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
7680
8549
  inputSchema: {
7681
8550
  type: "object",
7682
8551
  properties: {
@@ -7699,6 +8568,7 @@ var SHARED = [
7699
8568
  {
7700
8569
  name: "wirl_delete_app",
7701
8570
  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.`,
8571
+ annotations: { title: "Delete an app", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
7702
8572
  inputSchema: {
7703
8573
  type: "object",
7704
8574
  properties: {
@@ -7719,6 +8589,7 @@ var SHARED = [
7719
8589
  {
7720
8590
  name: "wirl_whoami",
7721
8591
  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.",
8592
+ annotations: reads("Show who is signed in"),
7722
8593
  inputSchema: { type: "object", properties: {} },
7723
8594
  async handler(_args, ctx) {
7724
8595
  const me = await whoami(ctx.creds);
@@ -7726,16 +8597,32 @@ var SHARED = [
7726
8597
  }
7727
8598
  }
7728
8599
  ];
8600
+ async function previewFetchAnswer(args, ctx) {
8601
+ const org = await defaultOrg(ctx.creds, str(args, "org"));
8602
+ const headersArg = args["headers"];
8603
+ const headers = headersArg !== null && typeof headersArg === "object" && !Array.isArray(headersArg) ? Object.fromEntries(Object.entries(headersArg).map(([k, v]) => [k, String(v)])) : void 0;
8604
+ const appsDomain = ctx.appsDomain ?? (await whoami(ctx.creds)).appsDomain;
8605
+ const result2 = await previewFetch(ctx.creds, org, str(args, "app"), str(args, "name"), str(args, "path", "/"), {
8606
+ appsDomain,
8607
+ ...ctx.networkFetch ? { networkFetchImpl: ctx.networkFetch } : {},
8608
+ ...ctx.now ? { now: ctx.now } : {},
8609
+ ...str(args, "method") ? { method: str(args, "method") } : {},
8610
+ ...typeof args["body"] === "string" ? { body: args["body"] } : {},
8611
+ ...headers ? { headers } : {}
8612
+ });
8613
+ const data = streamedFetchData(result2);
8614
+ return { text: formatFetchResult(result2), ...data === null ? {} : { structured: { ...data } } };
8615
+ }
7729
8616
  var LOGIN = {
7730
8617
  name: "wirl_login",
7731
8618
  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.",
8619
+ annotations: { title: "Sign in to wirl", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
7732
8620
  inputSchema: { type: "object", properties: {} },
7733
8621
  anonymous: true,
7734
8622
  async handler() {
7735
8623
  try {
7736
8624
  const creds = await loadCredentials();
7737
- const me = await whoami(creds);
7738
- return `Already signed in. Organisations: ${me.orgs.map((o) => o.slug).join(", ") || "none yet"}.`;
8625
+ return alreadySignedIn(await whoami(creds));
7739
8626
  } catch {
7740
8627
  }
7741
8628
  const pending = pendingLogin;
@@ -7746,7 +8633,8 @@ var LOGIN = {
7746
8633
  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. ";
7747
8634
  var LOCAL_DEPLOY = {
7748
8635
  name: "wirl_deploy",
7749
- 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.",
8636
+ 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"),
8637
+ annotations: { title: "Deploy an app", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
7750
8638
  inputSchema: {
7751
8639
  type: "object",
7752
8640
  properties: {
@@ -7771,7 +8659,7 @@ var LOCAL_DEPLOY = {
7771
8659
  throw withManifestWarnings(error, manifestWarnings);
7772
8660
  });
7773
8661
  const warnings = [...manifestWarnings, ...prepared.notices];
7774
- const made = await ensureRemoteApp(ctx.creds, org, app);
8662
+ const made = await ensureRemoteApp(ctx.creds, org, app, manifestVisibility(manifest));
7775
8663
  const result2 = await deploy(ctx.creds, { ...upload, prepared }).catch((error) => {
7776
8664
  throw withManifestWarnings(withMadeApp(error, made, "wirl_deploy", app), warnings);
7777
8665
  });
@@ -7815,6 +8703,7 @@ function connectionLines(connections) {
7815
8703
  var LOCAL_PULL = {
7816
8704
  name: "wirl_pull",
7817
8705
  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.",
8706
+ annotations: { title: "Pull an app source", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
7818
8707
  inputSchema: {
7819
8708
  type: "object",
7820
8709
  properties: {
@@ -7843,6 +8732,7 @@ var PREVIEW_LOGS_SENTENCE = "Deploying a preview again under its name replaces i
7843
8732
  var LOCAL_PREVIEW = {
7844
8733
  name: "wirl_preview",
7845
8734
  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.",
8735
+ annotations: { title: "Preview an app", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
7846
8736
  inputSchema: {
7847
8737
  type: "object",
7848
8738
  properties: {
@@ -7866,7 +8756,7 @@ var LOCAL_PREVIEW = {
7866
8756
  throw withManifestWarnings(error, manifestWarnings);
7867
8757
  });
7868
8758
  const warnings = [...manifestWarnings, ...prepared.notices];
7869
- const made = await ensureRemoteApp(ctx.creds, org, app);
8759
+ const made = await ensureRemoteApp(ctx.creds, org, app, manifestVisibility(manifest));
7870
8760
  const result2 = await deployPreview(ctx.creds, { ...upload, prepared }).catch((error) => {
7871
8761
  throw withManifestWarnings(withMadeApp(error, made, "wirl_preview", app), warnings);
7872
8762
  });
@@ -7896,6 +8786,30 @@ function refuseUnlessNames(names) {
7896
8786
  throw new CliError(`${JSON.stringify(value)} is not ${what} name. ${validateSlug(value).message ?? ""}`.trimEnd());
7897
8787
  }
7898
8788
  }
8789
+ 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.`;
8790
+ function refuseUnshareArgs(args) {
8791
+ const allowed = ["app", "email", "org"];
8792
+ const extra = Object.keys(args).filter((key) => !allowed.includes(key));
8793
+ if (extra.length > 0) {
8794
+ const listed2 = extra.map((key) => JSON.stringify(key));
8795
+ const said = listed2.length === 1 ? listed2[0] : `${listed2.slice(0, -1).join(", ")} or ${listed2[listed2.length - 1]}`;
8796
+ throw new CliError(`wirl_unshare takes "app", "email" and "org", not ${said}. ${UNSHARE_TAKES}`);
8797
+ }
8798
+ const app = args["app"];
8799
+ if (app === void 0 || app === "") throw new CliError('Which app? Pass its slug as "app".');
8800
+ if (typeof app !== "string") throw new CliError(`"app" is an app's slug, as text.`);
8801
+ if (!isWellFormedSlug(app)) throw new CliError(`${JSON.stringify(app)} is not an app's name, so nothing was changed.`);
8802
+ const email = args["email"];
8803
+ if (email === void 0 || typeof email === "string" && email.trim() === "") {
8804
+ throw new CliError(`Whose access? Pass the person's email address as "email".`);
8805
+ }
8806
+ if (typeof email !== "string") throw new CliError('"email" is one address, as text: run this once for each person.');
8807
+ const org = args["org"];
8808
+ if (org !== void 0 && typeof org !== "string") throw new CliError(`"org" is an organisation's name, as text.`);
8809
+ if (typeof org === "string" && org !== "" && !isWellFormedSlug(org)) {
8810
+ throw new CliError(`${JSON.stringify(org)} is not an organisation's name, so nothing was changed.`);
8811
+ }
8812
+ }
7899
8813
  function npmFallbackLines() {
7900
8814
  const origin = apiOriginFromEnv();
7901
8815
  const install = installFor(origin);
@@ -7903,7 +8817,8 @@ function npmFallbackLines() {
7903
8817
  }
7904
8818
  var REMOTE_DEPLOY = {
7905
8819
  name: "wirl_deploy",
7906
- 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.",
8820
+ 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. A "visibility": "private" in wirl.json does not apply when this tool creates the app; the wirl CLI's deploy honours it. Returns the URL it will get. ` + versionNumbersSentence("wirl_list_versions"),
8821
+ annotations: { title: "Deploy an app", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
7907
8822
  inputSchema: {
7908
8823
  type: "object",
7909
8824
  properties: {
@@ -7923,7 +8838,7 @@ var REMOTE_DEPLOY = {
7923
8838
  app ? `App ${app} exists in ${org}. Upload it from the machine that holds the files:` : "Upload it from the machine that holds the files:",
7924
8839
  "",
7925
8840
  ...NPM_PUBLISHED ? [] : [` npm install -g ${installFor(apiOriginFromEnv()).cli} # once per machine`],
7926
- ` ${WIRL_BIN} login # once per machine; opens the browser`,
8841
+ ` ${WIRL_BIN} login # once per machine; ${CLI_LOGIN_DOES}`,
7927
8842
  ` ${WIRL_BIN} deploy ${shellPath(directory)} --org ${shellQuote(org)}${app ? ` --app ${shellQuote(app)}` : ""}`,
7928
8843
  ...npmFallbackLines(),
7929
8844
  "",
@@ -7936,6 +8851,7 @@ var REMOTE_DEPLOY = {
7936
8851
  var REMOTE_PULL = {
7937
8852
  name: "wirl_pull",
7938
8853
  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.",
8854
+ annotations: reads("Pull an app source"),
7939
8855
  inputSchema: {
7940
8856
  type: "object",
7941
8857
  properties: {
@@ -7961,6 +8877,7 @@ var REMOTE_PULL = {
7961
8877
  var REMOTE_PREVIEW = {
7962
8878
  name: "wirl_preview",
7963
8879
  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(),
8880
+ annotations: reads("Preview an app"),
7964
8881
  inputSchema: {
7965
8882
  type: "object",
7966
8883
  properties: {
@@ -7977,10 +8894,9 @@ var REMOTE_PREVIEW = {
7977
8894
  async handler(args, ctx) {
7978
8895
  refuseUnlessNames({ org: str(args, "org"), app: str(args, "app") });
7979
8896
  const given = str(args, "name");
7980
- const slug = given === "" ? "" : slugify(given);
7981
- if (slug === null || slug !== "" && !isValidSlug(slug)) {
7982
- throw new CliError(`${JSON.stringify(given)} makes no preview name: as a slug it is empty, one character, or a reserved word.`);
7983
- }
8897
+ const made = given === "" ? null : previewSlugFor(given);
8898
+ if (made !== null && !made.ok) throw new CliError(made.message);
8899
+ const slug = made === null ? "" : made.slug;
7984
8900
  const org = await defaultOrg(ctx.creds, str(args, "org"));
7985
8901
  const directory = str(args, "directory", ".");
7986
8902
  const app = str(args, "app");
@@ -7991,7 +8907,7 @@ var REMOTE_PREVIEW = {
7991
8907
  "Upload it from the machine that holds the files:",
7992
8908
  "",
7993
8909
  ...NPM_PUBLISHED ? [] : [` npm install -g ${installFor(apiOriginFromEnv()).cli} # once per machine`],
7994
- ` ${WIRL_BIN} login # once per machine; opens the browser`,
8910
+ ` ${WIRL_BIN} login # once per machine; ${CLI_LOGIN_DOES}`,
7995
8911
  ` ${WIRL_BIN} preview ${shellPath(directory)} --org ${shellQuote(org)}${app ? ` --app ${shellQuote(app)}` : ""}${name ? ` --name ${shellQuote(name)}` : ""}`,
7996
8912
  ...npmFallbackLines(),
7997
8913
  "",
@@ -8012,6 +8928,7 @@ async function manifestFolder(directory) {
8012
8928
  var LOCAL_MIGRATIONS_ADOPT = {
8013
8929
  name: "wirl_migrations_adopt",
8014
8930
  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.`,
8931
+ annotations: { title: "Adopt app tables as migrations", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
8015
8932
  inputSchema: {
8016
8933
  type: "object",
8017
8934
  properties: {
@@ -8035,6 +8952,7 @@ var LOCAL_MIGRATIONS_ADOPT = {
8035
8952
  var REMOTE_MIGRATIONS_ADOPT = {
8036
8953
  name: "wirl_migrations_adopt",
8037
8954
  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.`,
8955
+ annotations: reads("Adopt app tables as migrations"),
8038
8956
  inputSchema: {
8039
8957
  type: "object",
8040
8958
  properties: {
@@ -8053,7 +8971,7 @@ var REMOTE_MIGRATIONS_ADOPT = {
8053
8971
  "Run it on the machine that holds the project, in a terminal:",
8054
8972
  "",
8055
8973
  ...NPM_PUBLISHED ? [] : [` npm install -g ${installFor(apiOriginFromEnv()).cli} # once per machine`],
8056
- ` ${WIRL_BIN} login # once per machine; opens the browser`,
8974
+ ` ${WIRL_BIN} login # once per machine; ${CLI_LOGIN_DOES}`,
8057
8975
  ` ${WIRL_BIN} migrations adopt ${shellPath(directory)} --org ${shellQuote(org)}${app ? ` --app ${shellQuote(app)}` : ""}`,
8058
8976
  ...npmFallbackLines(),
8059
8977
  "",
@@ -8090,6 +9008,14 @@ function negotiate(requested) {
8090
9008
  function result(id, value) {
8091
9009
  return JSON.stringify({ jsonrpc: "2.0", id, result: value });
8092
9010
  }
9011
+ function escapeInvisibleDeep(value) {
9012
+ if (typeof value === "string") return escapeInvisible(value);
9013
+ if (Array.isArray(value)) return value.map(escapeInvisibleDeep);
9014
+ if (value !== null && typeof value === "object") {
9015
+ return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, escapeInvisibleDeep(inner)]));
9016
+ }
9017
+ return value;
9018
+ }
8093
9019
  function failure(id, code, message) {
8094
9020
  return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } });
8095
9021
  }
@@ -8115,7 +9041,10 @@ async function handleRpc(request, ctx) {
8115
9041
  tools: tools.map((t) => ({
8116
9042
  name: t.name,
8117
9043
  description: t.description,
8118
- inputSchema: t.inputSchema
9044
+ inputSchema: t.inputSchema,
9045
+ // After the three fields every client has always read: a client
9046
+ // that knows no annotations ignores it (OIM-844).
9047
+ annotations: t.annotations
8119
9048
  }))
8120
9049
  });
8121
9050
  case "tools/call": {
@@ -8125,8 +9054,17 @@ async function handleRpc(request, ctx) {
8125
9054
  if (!tool) return failure(id, -32602, `Unknown tool: ${name}`);
8126
9055
  const args = params["arguments"] ?? {};
8127
9056
  try {
8128
- const text = tool.anonymous ? await tool.handler(args) : await tool.handler(args, ctx ?? { creds: await credentialsOrPending(), mode: "local" });
8129
- return result(id, { content: [{ type: "text", text: escapeInvisible(text) }] });
9057
+ let answer;
9058
+ if (tool.anonymous) {
9059
+ answer = { text: await tool.handler(args) };
9060
+ } else {
9061
+ const context = ctx ?? { creds: await credentialsOrPending(), mode: "local" };
9062
+ answer = tool.answer ? await tool.answer(args, context) : { text: await tool.handler(args, context) };
9063
+ }
9064
+ return result(id, {
9065
+ content: [{ type: "text", text: escapeInvisible(answer.text) }],
9066
+ ...answer.structured === void 0 ? {} : { structuredContent: escapeInvisibleDeep(answer.structured) }
9067
+ });
8130
9068
  } catch (error) {
8131
9069
  const message = error instanceof CliError ? [error.message, error.hint].filter(Boolean).join("\n") : error instanceof Error ? error.message : String(error);
8132
9070
  return result(id, { content: [{ type: "text", text: escapeInvisible(message) }], isError: true });