@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.
- package/README.md +5 -3
- package/bin.js +1055 -117
- 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
|
|
193
|
-
|
|
194
|
-
|
|
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.
|
|
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
|
|
4824
|
-
if (!
|
|
4825
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
-
|
|
5238
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
5606
|
-
|
|
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(
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
5973
|
-
if (!VISIBILITY_LEVELS.includes(
|
|
5974
|
-
throw new CliError(`Visibility is private, org or public, not "${String(
|
|
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
|
-
|
|
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
|
-
)}
|
|
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
|
|
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 =
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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: {
|
|
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
|
|
7194
|
-
const result2 = await shareApp(ctx.creds, org, str(args, "app"),
|
|
7195
|
-
return result2
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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;
|
|
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
|
|
7981
|
-
if (
|
|
7982
|
-
|
|
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;
|
|
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;
|
|
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
|
-
|
|
8129
|
-
|
|
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 });
|