wirl 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 +1 -1
- package/bin.js +1794 -223
- 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}`;
|
|
@@ -384,13 +412,14 @@ async function readSigned(key, token) {
|
|
|
384
412
|
}
|
|
385
413
|
return { ok: true, claims };
|
|
386
414
|
}
|
|
387
|
-
var encoder, decoder2, PREFIX, SKEW_SECONDS;
|
|
415
|
+
var encoder, decoder2, PREFIX, GRANT_TTL_DEFAULT_SECONDS, SKEW_SECONDS;
|
|
388
416
|
var init_token = __esm({
|
|
389
417
|
"packages/core/src/token.ts"() {
|
|
390
418
|
"use strict";
|
|
391
419
|
encoder = new TextEncoder();
|
|
392
420
|
decoder2 = new TextDecoder();
|
|
393
421
|
PREFIX = "w1";
|
|
422
|
+
GRANT_TTL_DEFAULT_SECONDS = 12 * 60;
|
|
394
423
|
SKEW_SECONDS = 60;
|
|
395
424
|
}
|
|
396
425
|
});
|
|
@@ -569,10 +598,19 @@ var init_egress = __esm({
|
|
|
569
598
|
PinnedFetchError = class extends Error {
|
|
570
599
|
reason;
|
|
571
600
|
host;
|
|
572
|
-
|
|
601
|
+
/**
|
|
602
|
+
* Set only on a timeout that ended an answer already under way, whose body
|
|
603
|
+
* the fetch bounds itself (the broker's streams): which bound it was, the
|
|
604
|
+
* time since the headers or the time with nothing moving, and its length in
|
|
605
|
+
* milliseconds. The broker reads it to say what it cut a stream for.
|
|
606
|
+
* Undefined on every other failure.
|
|
607
|
+
*/
|
|
608
|
+
streamBound;
|
|
609
|
+
constructor(reason, host, message, streamBound) {
|
|
573
610
|
super(message);
|
|
574
611
|
this.reason = reason;
|
|
575
612
|
this.host = host;
|
|
613
|
+
this.streamBound = streamBound;
|
|
576
614
|
}
|
|
577
615
|
};
|
|
578
616
|
PRIVATE_V4 = /^(0\.|10\.|127\.|169\.254\.|192\.0\.0\.|192\.168\.|198\.1[89]\.|172\.(1[6-9]|2[0-9]|3[01])\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|(22[4-9]|23[0-9]|24[0-9]|25[0-5])\.)/;
|
|
@@ -2301,6 +2339,9 @@ var init_migrations = __esm({
|
|
|
2301
2339
|
leaseMs: 6e5,
|
|
2302
2340
|
orgApplies: 2,
|
|
2303
2341
|
// per org: the limit one tenant's applies meet
|
|
2342
|
+
// Per app, its live database and its previews' together, inside the org's
|
|
2343
|
+
// two, so one app's deployers can never take both (OIM-927).
|
|
2344
|
+
appApplies: 1,
|
|
2304
2345
|
// Per control-plane process. The lease holds no pooled connection, so this
|
|
2305
2346
|
// bounds the process's work, not its pool; at `orgApplies` two each,
|
|
2306
2347
|
// filling it takes eight orgs (T5b-6).
|
|
@@ -2936,6 +2977,9 @@ function parseManifest(input) {
|
|
|
2936
2977
|
}
|
|
2937
2978
|
accent = rawAccent;
|
|
2938
2979
|
}
|
|
2980
|
+
const rawVisibility = raw["visibility"];
|
|
2981
|
+
if (rawVisibility !== void 0 && rawVisibility !== "private") return refuse(VISIBILITY_REFUSED);
|
|
2982
|
+
const visibility = rawVisibility === "private" ? { visibility: "private" } : {};
|
|
2939
2983
|
const flowResult = parseFlows(raw);
|
|
2940
2984
|
if (!flowResult.ok) return refuse(flowResult.message);
|
|
2941
2985
|
const flows = flowResult.flows;
|
|
@@ -2954,7 +2998,7 @@ function parseManifest(input) {
|
|
|
2954
2998
|
if (!publicPathsResult2.ok) return refuse(publicPathsResult2.message);
|
|
2955
2999
|
return {
|
|
2956
3000
|
ok: true,
|
|
2957
|
-
manifest: { runtime, connections: [], description, accent, schedules, flows, publicPaths: publicPathsResult2.publicPaths, static: staticFiles, migrations },
|
|
3001
|
+
manifest: { runtime, connections: [], description, accent, schedules, flows, publicPaths: publicPathsResult2.publicPaths, static: staticFiles, migrations, ...visibility },
|
|
2958
3002
|
warnings
|
|
2959
3003
|
};
|
|
2960
3004
|
}
|
|
@@ -3013,11 +3057,11 @@ function parseManifest(input) {
|
|
|
3013
3057
|
if (!publicPathsResult.ok) return refuse(publicPathsResult.message);
|
|
3014
3058
|
return {
|
|
3015
3059
|
ok: true,
|
|
3016
|
-
manifest: { runtime, connections, description, accent, schedules, flows, publicPaths: publicPathsResult.publicPaths, static: staticFiles, migrations },
|
|
3060
|
+
manifest: { runtime, connections, description, accent, schedules, flows, publicPaths: publicPathsResult.publicPaths, static: staticFiles, migrations, ...visibility },
|
|
3017
3061
|
warnings
|
|
3018
3062
|
};
|
|
3019
3063
|
}
|
|
3020
|
-
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;
|
|
3064
|
+
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;
|
|
3021
3065
|
var init_manifest = __esm({
|
|
3022
3066
|
"packages/core/src/manifest.ts"() {
|
|
3023
3067
|
"use strict";
|
|
@@ -3057,7 +3101,8 @@ var init_manifest = __esm({
|
|
|
3057
3101
|
"server",
|
|
3058
3102
|
"spa",
|
|
3059
3103
|
"start",
|
|
3060
|
-
"static"
|
|
3104
|
+
"static",
|
|
3105
|
+
"visibility"
|
|
3061
3106
|
]);
|
|
3062
3107
|
QUIET_KEYS = Object.freeze(["$schema"]);
|
|
3063
3108
|
UNKNOWN_KEYS_NAMED = 10;
|
|
@@ -3079,6 +3124,7 @@ var init_manifest = __esm({
|
|
|
3079
3124
|
TIMEOUT_MESSAGE = (name) => `timeout for ${name} must be between 30s and 15m; a run longer than that should be split into resumable chunks.`;
|
|
3080
3125
|
FLOW_KEYS = ["name", "step_timeout", "retries"];
|
|
3081
3126
|
MIGRATIONS_KEYS = ["destructive"];
|
|
3127
|
+
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.`;
|
|
3082
3128
|
}
|
|
3083
3129
|
});
|
|
3084
3130
|
|
|
@@ -3299,12 +3345,50 @@ var init_runtime = __esm({
|
|
|
3299
3345
|
});
|
|
3300
3346
|
|
|
3301
3347
|
// packages/core/src/broker-stream.ts
|
|
3348
|
+
function mediaTypeOf(headers) {
|
|
3349
|
+
const declared = headers.get("content-type");
|
|
3350
|
+
if (declared === null) return null;
|
|
3351
|
+
return (declared.split(";")[0] ?? "").trim().toLowerCase();
|
|
3352
|
+
}
|
|
3302
3353
|
function isStreamedAnswer(status, headers) {
|
|
3303
3354
|
if (status < 200 || status > 299) return false;
|
|
3304
|
-
const
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3355
|
+
const media = mediaTypeOf(headers);
|
|
3356
|
+
return media !== null && STREAMED_MEDIA_TYPES.includes(media);
|
|
3357
|
+
}
|
|
3358
|
+
function isEventStream(headers) {
|
|
3359
|
+
return mediaTypeOf(headers) === "text/event-stream";
|
|
3360
|
+
}
|
|
3361
|
+
function closesWithEvent(headers) {
|
|
3362
|
+
if (!isEventStream(headers)) return false;
|
|
3363
|
+
const encoding = headers.get("content-encoding");
|
|
3364
|
+
return encoding === null || encoding.trim().toLowerCase() === "identity";
|
|
3365
|
+
}
|
|
3366
|
+
function lengthOf(ms) {
|
|
3367
|
+
const unit = (count, name) => `${count} ${name}${count === 1 ? "" : "s"}`;
|
|
3368
|
+
if (ms % 6e4 === 0) return unit(ms / 6e4, "minute");
|
|
3369
|
+
if (ms % 1e3 === 0) return unit(ms / 1e3, "second");
|
|
3370
|
+
return `${ms} ms`;
|
|
3371
|
+
}
|
|
3372
|
+
function sayCut(cut2) {
|
|
3373
|
+
switch (cut2.cause) {
|
|
3374
|
+
case "size":
|
|
3375
|
+
return `it reached wirl's limit of ${cut2.bytes % MIB === 0 ? `${cut2.bytes / MIB} MiB` : `${cut2.bytes} bytes`} for one answer.`;
|
|
3376
|
+
case "time":
|
|
3377
|
+
return `it was still open ${lengthOf(cut2.ms)} after the vendor began answering, the longest wirl allows.`;
|
|
3378
|
+
case "idle":
|
|
3379
|
+
return `nothing moved on it for ${lengthOf(cut2.ms)}, the longest wirl allows.`;
|
|
3380
|
+
case "held":
|
|
3381
|
+
return `it was still open ${lengthOf(cut2.ms)} after wirl began passing it on, the longest wirl allows.`;
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
function streamCutEvent(cut2) {
|
|
3385
|
+
const data = JSON.stringify({ type: "error", error: { type: "api_error", message: `wirl cut this stream: ${sayCut(cut2)}` } });
|
|
3386
|
+
return new TextEncoder().encode(`
|
|
3387
|
+
|
|
3388
|
+
event: error
|
|
3389
|
+
data: ${data}
|
|
3390
|
+
|
|
3391
|
+
`);
|
|
3308
3392
|
}
|
|
3309
3393
|
function createStreamSlots(perApp = BROKER_LIMITS.openStreamsPerApp) {
|
|
3310
3394
|
const held = /* @__PURE__ */ new Map();
|
|
@@ -3329,7 +3413,7 @@ function createStreamSlots(perApp = BROKER_LIMITS.openStreamsPerApp) {
|
|
|
3329
3413
|
}
|
|
3330
3414
|
};
|
|
3331
3415
|
}
|
|
3332
|
-
var BROKER_LIMITS, STREAMED_MEDIA_TYPES;
|
|
3416
|
+
var BROKER_LIMITS, STREAMED_MEDIA_TYPES, CUT_EVENT_RESERVE_BYTES, MIB;
|
|
3333
3417
|
var init_broker_stream = __esm({
|
|
3334
3418
|
"packages/core/src/broker-stream.ts"() {
|
|
3335
3419
|
"use strict";
|
|
@@ -3346,20 +3430,369 @@ var init_broker_stream = __esm({
|
|
|
3346
3430
|
streamIdleMs: 4 * 6e4,
|
|
3347
3431
|
/** Bytes in any one answer, streamed or read whole. */
|
|
3348
3432
|
answerBytes: 25 * 1024 * 1024,
|
|
3433
|
+
/**
|
|
3434
|
+
* Bytes in the body of any one call an app makes. The body is read whole
|
|
3435
|
+
* before the call is sent, and before its grant is looked up, so a body
|
|
3436
|
+
* past this never opens a key and never reaches a vendor (OIM-931).
|
|
3437
|
+
* 100 MiB: Cloudflare publishes 100 MB as the request body its Free and
|
|
3438
|
+
* Pro plans take, and a deployed app's call is first held whole by the
|
|
3439
|
+
* egress Worker, in an isolate Cloudflare gives 128 MB of memory. Nothing
|
|
3440
|
+
* else bounds a caller that holds a broker token and reaches the control
|
|
3441
|
+
* plane itself: Railway sets no size on a body, only five minutes to send it.
|
|
3442
|
+
*/
|
|
3443
|
+
requestBytes: 100 * 1024 * 1024,
|
|
3444
|
+
/**
|
|
3445
|
+
* Bytes of call bodies one app's calls hold at once, its previews' with
|
|
3446
|
+
* them (OIM-951): counted as each chunk arrives, from a call's first byte
|
|
3447
|
+
* until the broker has answered it (broker-bodies.ts). At least
|
|
3448
|
+
* `requestBytes`, so one call at the cap always fits an app that holds
|
|
3449
|
+
* nothing else; a quarter of `heldBodyBytes`, so four apps of four owners
|
|
3450
|
+
* at their share fill the whole (one owner's apps cannot: `ownerHeldBodyBytes`).
|
|
3451
|
+
*/
|
|
3452
|
+
appHeldBodyBytes: 128 * 1024 * 1024,
|
|
3453
|
+
/**
|
|
3454
|
+
* Bytes of call bodies the calls of every app one owner has hold at once,
|
|
3455
|
+
* their previews' with them (OIM-967): the person `apps.owner_user_id`
|
|
3456
|
+
* names, whichever organisations their apps are in. Apps and
|
|
3457
|
+
* organisations cost nothing to create (no cap on either), so a share per
|
|
3458
|
+
* app let one person with four apps fill the whole, and one per
|
|
3459
|
+
* organisation would let them do it with four organisations; a person
|
|
3460
|
+
* costs a sign-in. Two apps at their share, so one app that runs
|
|
3461
|
+
* away still leaves its owner's other apps room, and half of
|
|
3462
|
+
* `heldBodyBytes`, so one owner can never take more than half, and a
|
|
3463
|
+
* second sign-in is what it takes to fill it. At least `requestBytes`, so
|
|
3464
|
+
* one call at the cap always fits an owner whose apps hold nothing else.
|
|
3465
|
+
*/
|
|
3466
|
+
ownerHeldBodyBytes: 256 * 1024 * 1024,
|
|
3467
|
+
/**
|
|
3468
|
+
* Bytes of call bodies every app's calls hold at once, in this process.
|
|
3469
|
+
* A call's reader starts at 16 KiB and doubles its buffer as the body
|
|
3470
|
+
* arrives, so the buffers behind this hold at most twice it, 1 GiB, and
|
|
3471
|
+
* 16 KiB a call beside (the ruling in OIM-951).
|
|
3472
|
+
*/
|
|
3473
|
+
heldBodyBytes: 512 * 1024 * 1024,
|
|
3474
|
+
/** The `Retry-After` of a call refused for either. */
|
|
3475
|
+
heldBodyRetryAfterSeconds: 10,
|
|
3476
|
+
/**
|
|
3477
|
+
* How long a call's body may send nothing before its read is stopped:
|
|
3478
|
+
* an upload's (OIM-935). A deployed app's body comes from the egress
|
|
3479
|
+
* Worker, which holds it whole before it sends it, and `wirl dev` reads it
|
|
3480
|
+
* whole first, so no ordinary sender ever pauses (fix round 1, R1-F2).
|
|
3481
|
+
*/
|
|
3482
|
+
heldBodyStallMs: 3e4,
|
|
3483
|
+
/**
|
|
3484
|
+
* How long a call's body may take to arrive whole: 100 MiB in 120 seconds
|
|
3485
|
+
* is about 7 Mbit/s, which a body between Cloudflare and the control plane
|
|
3486
|
+
* passes many times over, and well under Node's 300 seconds a request,
|
|
3487
|
+
* after which the body would be cut anyway (fix round 1, R1-F2).
|
|
3488
|
+
*/
|
|
3489
|
+
heldBodyArrivalMs: 12e4,
|
|
3490
|
+
/**
|
|
3491
|
+
* Bytes of answers read whole that one app's calls hold at once, its
|
|
3492
|
+
* previews' with them (OIM-966): counted as each chunk arrives from the
|
|
3493
|
+
* vendor, from an answer's first byte until wirl's server has taken the
|
|
3494
|
+
* last of it, or the app has gone (broker-bodies.ts). A vendor's event
|
|
3495
|
+
* stream is not counted: it is passed on as the app reads it, and holds
|
|
3496
|
+
* about 285 to 441 KiB at a time, measured end to end through the control
|
|
3497
|
+
* plane's server (`passOn` in broker.ts; OIM-966 fix round 1, R1-F3,
|
|
3498
|
+
* R2-F2). More than two `answerBytes`, so two answers at the cap fit an
|
|
3499
|
+
* app that holds nothing else; a quarter of `heldAnswerBytes`, so four apps
|
|
3500
|
+
* of four owners at their share fill the whole (one owner's apps cannot:
|
|
3501
|
+
* `ownerHeldAnswerBytes`).
|
|
3502
|
+
*/
|
|
3503
|
+
appHeldAnswerBytes: 64 * 1024 * 1024,
|
|
3504
|
+
/**
|
|
3505
|
+
* Bytes of answers read whole that the calls of every app one owner has
|
|
3506
|
+
* hold at once, their previews' with them (OIM-967), for the reasons
|
|
3507
|
+
* `ownerHeldBodyBytes` gives: two apps at their share, half of
|
|
3508
|
+
* `heldAnswerBytes`, and more than two `answerBytes` and than the room a
|
|
3509
|
+
* call needs, so an owner whose apps hold nothing else can always send.
|
|
3510
|
+
*/
|
|
3511
|
+
ownerHeldAnswerBytes: 128 * 1024 * 1024,
|
|
3512
|
+
/**
|
|
3513
|
+
* Bytes of answers read whole that every app's calls hold at once, in this
|
|
3514
|
+
* process. An answer's reader doubles its buffer as it fills, as a body's
|
|
3515
|
+
* does, and never past `answerBytes`, so while answers arrive the buffers
|
|
3516
|
+
* behind this hold at most twice it, 512 MiB, and 16 KiB an answer beside;
|
|
3517
|
+
* an answer read whole is copied to exactly its size before it is handed
|
|
3518
|
+
* on, so from then its count is its memory (the ruling in OIM-966, and its
|
|
3519
|
+
* fix round 1, R1-F2).
|
|
3520
|
+
*/
|
|
3521
|
+
heldAnswerBytes: 256 * 1024 * 1024,
|
|
3522
|
+
/**
|
|
3523
|
+
* The room an app's share of answers, and the whole, must each have left
|
|
3524
|
+
* for a call to be sent at all: one answer at the cap. With less, the call
|
|
3525
|
+
* is refused with a 503 before it leaves wirl, rather than run at its
|
|
3526
|
+
* vendor and its answer cut (OIM-966 fix round 1, R1-F1).
|
|
3527
|
+
*/
|
|
3528
|
+
heldAnswerRoomBytes: 25 * 1024 * 1024,
|
|
3529
|
+
/** The `Retry-After` of a call refused for that room, before it was sent. */
|
|
3530
|
+
heldAnswerRetryAfterSeconds: 10,
|
|
3531
|
+
/**
|
|
3532
|
+
* How long an answer read whole may wait for wirl's server to take the
|
|
3533
|
+
* last of it once it is handed back: past this it is cut, and its count
|
|
3534
|
+
* goes back, so no app holds its share, or the whole, by not reading.
|
|
3535
|
+
* Under Cloudflare's measured 125 seconds with nothing moving, which ends
|
|
3536
|
+
* a deployed app's unread answer anyway (OIM-966 fix round 1, R1-F1).
|
|
3537
|
+
*/
|
|
3538
|
+
heldAnswerHandOutMs: 12e4,
|
|
3349
3539
|
/**
|
|
3350
3540
|
* Streamed answers one app may have open at once. Each holds a vendor
|
|
3351
3541
|
* socket and an inbound connection on one control-plane instance for up
|
|
3352
3542
|
* to fourteen minutes, so one app cannot be allowed an unbounded number.
|
|
3353
3543
|
* Counted in memory, per instance, like the control plane's other limits.
|
|
3354
3544
|
*/
|
|
3355
|
-
openStreamsPerApp: 20
|
|
3545
|
+
openStreamsPerApp: 20,
|
|
3546
|
+
/**
|
|
3547
|
+
* How long an event stream wirl cut waits for its closing event to leave
|
|
3548
|
+
* the server before it ends the stream with its error anyway (OIM-837 fix
|
|
3549
|
+
* round 1). The event is on its way when the server's queue of what it has
|
|
3550
|
+
* to write has emptied; a reader that is slow but reading empties a full
|
|
3551
|
+
* kernel buffer, a few hundred kilobytes, well inside this. Past it the
|
|
3552
|
+
* reader is not reading, and the stream ends as it always did; the write
|
|
3553
|
+
* stall that holds a connection longer than this is OIM-968's.
|
|
3554
|
+
*/
|
|
3555
|
+
cutEventSentMs: 5e3
|
|
3356
3556
|
};
|
|
3357
3557
|
STREAMED_MEDIA_TYPES = Object.freeze(["text/event-stream", "application/x-ndjson"]);
|
|
3558
|
+
CUT_EVENT_RESERVE_BYTES = 512;
|
|
3559
|
+
MIB = 1024 * 1024;
|
|
3560
|
+
}
|
|
3561
|
+
});
|
|
3562
|
+
|
|
3563
|
+
// packages/core/src/refusals.ts
|
|
3564
|
+
function sayAtMostEvery(log, now, everyMs = SAY_EVERY_MS) {
|
|
3565
|
+
let since = 0;
|
|
3566
|
+
let saidAt = Number.NEGATIVE_INFINITY;
|
|
3567
|
+
return (line) => {
|
|
3568
|
+
since += 1;
|
|
3569
|
+
const at2 = now();
|
|
3570
|
+
if (at2 - saidAt < everyMs) return;
|
|
3571
|
+
const text = line(since, at2);
|
|
3572
|
+
since = 0;
|
|
3573
|
+
saidAt = at2;
|
|
3574
|
+
try {
|
|
3575
|
+
log(text);
|
|
3576
|
+
} catch {
|
|
3577
|
+
}
|
|
3578
|
+
};
|
|
3579
|
+
}
|
|
3580
|
+
var SAY_EVERY_MS, MiB, sizeOf;
|
|
3581
|
+
var init_refusals = __esm({
|
|
3582
|
+
"packages/core/src/refusals.ts"() {
|
|
3583
|
+
"use strict";
|
|
3584
|
+
SAY_EVERY_MS = 3e4;
|
|
3585
|
+
MiB = 1024 * 1024;
|
|
3586
|
+
sizeOf = (bytes) => bytes >= MiB ? `${String(Math.floor(bytes / MiB))} MiB` : `${String(Math.ceil(bytes / 1024))} KiB`;
|
|
3587
|
+
}
|
|
3588
|
+
});
|
|
3589
|
+
|
|
3590
|
+
// packages/core/src/wait-clock.ts
|
|
3591
|
+
var systemWaitClock;
|
|
3592
|
+
var init_wait_clock = __esm({
|
|
3593
|
+
"packages/core/src/wait-clock.ts"() {
|
|
3594
|
+
"use strict";
|
|
3595
|
+
systemWaitClock = {
|
|
3596
|
+
wait(ms) {
|
|
3597
|
+
let timer;
|
|
3598
|
+
const done = new Promise((resolve7) => {
|
|
3599
|
+
timer = setTimeout(resolve7, ms);
|
|
3600
|
+
timer.unref?.();
|
|
3601
|
+
});
|
|
3602
|
+
return { done, cancel: () => clearTimeout(timer) };
|
|
3603
|
+
}
|
|
3604
|
+
};
|
|
3605
|
+
}
|
|
3606
|
+
});
|
|
3607
|
+
|
|
3608
|
+
// packages/core/src/broker-bodies.ts
|
|
3609
|
+
function heldBodyRefusal(limits, why) {
|
|
3610
|
+
const again = `Send it again in ${String(limits.heldBodyRetryAfterSeconds)} seconds.`;
|
|
3611
|
+
if (why === "app") {
|
|
3612
|
+
return {
|
|
3613
|
+
error: "app_bodies_full",
|
|
3614
|
+
message: `This call's body would take the bodies this app's calls hold at once past ${sizeOf(limits.appHeldBodyBytes)}, the most one app's calls may hold, so wirl stopped the call there and sent nothing to the vendor. ${again}`
|
|
3615
|
+
};
|
|
3616
|
+
}
|
|
3617
|
+
if (why === "owner") {
|
|
3618
|
+
return {
|
|
3619
|
+
error: "owner_bodies_full",
|
|
3620
|
+
message: `This call's body would take the bodies held at once for the calls of all the apps this app's owner has past ${sizeOf(limits.ownerHeldBodyBytes)}, the most one owner's apps may hold together, so wirl stopped the call there and sent nothing to the vendor. ${again}`
|
|
3621
|
+
};
|
|
3622
|
+
}
|
|
3623
|
+
return {
|
|
3624
|
+
error: "bodies_full",
|
|
3625
|
+
message: `This call's body would take the bodies wirl holds for all apps' calls at once past ${sizeOf(limits.heldBodyBytes)}, the most it holds, so wirl stopped the call there and sent nothing to the vendor. ${again}`
|
|
3626
|
+
};
|
|
3627
|
+
}
|
|
3628
|
+
function heldBodyCut(limits, why) {
|
|
3629
|
+
if (why === "stalled") {
|
|
3630
|
+
return {
|
|
3631
|
+
error: "body_stalled",
|
|
3632
|
+
message: `Nothing of this call's body arrived for ${String(limits.heldBodyStallMs / 1e3)} seconds, so wirl stopped the call there and sent nothing to the vendor. Send it again.`
|
|
3633
|
+
};
|
|
3634
|
+
}
|
|
3635
|
+
return {
|
|
3636
|
+
error: "body_too_slow",
|
|
3637
|
+
message: `This call's body had not all arrived after ${String(limits.heldBodyArrivalMs / 1e3)} seconds, the longest wirl waits for one, so wirl stopped the call there and sent nothing to the vendor. Send it again.`
|
|
3638
|
+
};
|
|
3639
|
+
}
|
|
3640
|
+
function heldAnswerRefusal(limits, why) {
|
|
3641
|
+
const acted = "The vendor may have acted on the call: send it again only if repeating it is safe.";
|
|
3642
|
+
if (why === "app") {
|
|
3643
|
+
return {
|
|
3644
|
+
error: "app_answers_full",
|
|
3645
|
+
message: `The vendor received this call and answered it, but its answer would take the answers this app's calls hold at once past ${sizeOf(limits.appHeldAnswerBytes)}, the most one app's calls may hold, so wirl stopped reading the answer there and passed none of it on. ${acted}`
|
|
3646
|
+
};
|
|
3647
|
+
}
|
|
3648
|
+
if (why === "owner") {
|
|
3649
|
+
return {
|
|
3650
|
+
error: "owner_answers_full",
|
|
3651
|
+
message: `The vendor received this call and answered it, but its answer would take the answers held at once for the calls of all the apps this app's owner has past ${sizeOf(limits.ownerHeldAnswerBytes)}, the most one owner's apps may hold together, so wirl stopped reading the answer there and passed none of it on. ${acted}`
|
|
3652
|
+
};
|
|
3653
|
+
}
|
|
3654
|
+
return {
|
|
3655
|
+
error: "answers_full",
|
|
3656
|
+
message: `The vendor received this call and answered it, but its answer would take the answers wirl holds for all apps' calls at once past ${sizeOf(limits.heldAnswerBytes)}, the most it holds, so wirl stopped reading the answer there and passed none of it on. ${acted}`
|
|
3657
|
+
};
|
|
3658
|
+
}
|
|
3659
|
+
function heldAnswerNoRoom(limits, why) {
|
|
3660
|
+
const again = `Send it again in ${String(limits.heldAnswerRetryAfterSeconds)} seconds.`;
|
|
3661
|
+
if (why === "app") {
|
|
3662
|
+
return {
|
|
3663
|
+
error: "app_no_answer_room",
|
|
3664
|
+
message: `The answers this app's calls hold at once leave less than ${sizeOf(limits.heldAnswerRoomBytes)} of the ${sizeOf(limits.appHeldAnswerBytes)} one app's calls may hold, the room wirl keeps for one more answer, so wirl did not send this call, and nothing of it reached the vendor. ${again}`
|
|
3665
|
+
};
|
|
3666
|
+
}
|
|
3667
|
+
if (why === "owner") {
|
|
3668
|
+
return {
|
|
3669
|
+
error: "owner_no_answer_room",
|
|
3670
|
+
message: `The answers held at once for the calls of all the apps this app's owner has leave less than ${sizeOf(limits.heldAnswerRoomBytes)} of the ${sizeOf(limits.ownerHeldAnswerBytes)} one owner's apps may hold together, the room wirl keeps for one more answer, so wirl did not send this call, and nothing of it reached the vendor. ${again}`
|
|
3671
|
+
};
|
|
3672
|
+
}
|
|
3673
|
+
return {
|
|
3674
|
+
error: "no_answer_room",
|
|
3675
|
+
message: `The answers wirl holds for all apps' calls at once leave less than ${sizeOf(limits.heldAnswerRoomBytes)} of the ${sizeOf(limits.heldAnswerBytes)} it holds, the room it keeps for one more answer, so wirl did not send this call, and nothing of it reached the vendor. ${again}`
|
|
3676
|
+
};
|
|
3677
|
+
}
|
|
3678
|
+
function createHeldBodies(options = {}) {
|
|
3679
|
+
const limits = options.limits ?? {
|
|
3680
|
+
appHeldBodyBytes: BROKER_LIMITS.appHeldBodyBytes,
|
|
3681
|
+
ownerHeldBodyBytes: BROKER_LIMITS.ownerHeldBodyBytes,
|
|
3682
|
+
heldBodyBytes: BROKER_LIMITS.heldBodyBytes,
|
|
3683
|
+
heldBodyRetryAfterSeconds: BROKER_LIMITS.heldBodyRetryAfterSeconds,
|
|
3684
|
+
heldBodyStallMs: BROKER_LIMITS.heldBodyStallMs,
|
|
3685
|
+
heldBodyArrivalMs: BROKER_LIMITS.heldBodyArrivalMs
|
|
3686
|
+
};
|
|
3687
|
+
return {
|
|
3688
|
+
limits,
|
|
3689
|
+
clock: options.clock ?? systemWaitClock,
|
|
3690
|
+
...countHeld("bodies", limits.appHeldBodyBytes, limits.ownerHeldBodyBytes, limits.heldBodyBytes, options)
|
|
3691
|
+
};
|
|
3692
|
+
}
|
|
3693
|
+
function createHeldAnswers(options = {}) {
|
|
3694
|
+
const limits = options.limits ?? {
|
|
3695
|
+
appHeldAnswerBytes: BROKER_LIMITS.appHeldAnswerBytes,
|
|
3696
|
+
ownerHeldAnswerBytes: BROKER_LIMITS.ownerHeldAnswerBytes,
|
|
3697
|
+
heldAnswerBytes: BROKER_LIMITS.heldAnswerBytes,
|
|
3698
|
+
heldAnswerRoomBytes: BROKER_LIMITS.heldAnswerRoomBytes,
|
|
3699
|
+
heldAnswerRetryAfterSeconds: BROKER_LIMITS.heldAnswerRetryAfterSeconds,
|
|
3700
|
+
heldAnswerHandOutMs: BROKER_LIMITS.heldAnswerHandOutMs
|
|
3701
|
+
};
|
|
3702
|
+
const { short, ...count } = countHeld("answers", limits.appHeldAnswerBytes, limits.ownerHeldAnswerBytes, limits.heldAnswerBytes, options);
|
|
3703
|
+
return {
|
|
3704
|
+
limits,
|
|
3705
|
+
clock: options.clock ?? systemWaitClock,
|
|
3706
|
+
...count,
|
|
3707
|
+
roomFor: (appId, ownerId) => short(appId, ownerId, limits.heldAnswerRoomBytes)
|
|
3708
|
+
};
|
|
3709
|
+
}
|
|
3710
|
+
function countHeld(what, appBytes, ownerBytes, allBytes, options) {
|
|
3711
|
+
const say = options.log === void 0 ? null : sayAtMostEvery(options.log, options.now ?? Date.now);
|
|
3712
|
+
const byApp = /* @__PURE__ */ new Map();
|
|
3713
|
+
const byOwner = /* @__PURE__ */ new Map();
|
|
3714
|
+
let total = 0;
|
|
3715
|
+
const named = (owner) => typeof owner === "string" && owner.length > 0 ? owner : null;
|
|
3716
|
+
const bump = (table, key, bytes) => {
|
|
3717
|
+
const next = (table.get(key) ?? 0) + bytes;
|
|
3718
|
+
if (next === 0) table.delete(key);
|
|
3719
|
+
else table.set(key, next);
|
|
3720
|
+
};
|
|
3721
|
+
const add = (appId, ownerId, bytes) => {
|
|
3722
|
+
bump(byApp, appId, bytes);
|
|
3723
|
+
if (ownerId !== null) bump(byOwner, ownerId, bytes);
|
|
3724
|
+
total += bytes;
|
|
3725
|
+
};
|
|
3726
|
+
const passes = (appId, ownerId, bytes) => (byApp.get(appId) ?? 0) + bytes > appBytes ? "app" : ownerId !== null && (byOwner.get(ownerId) ?? 0) + bytes > ownerBytes ? "owner" : total + bytes > allBytes ? "all" : null;
|
|
3727
|
+
const refused2 = (why, appId, ownerId) => {
|
|
3728
|
+
say?.((since) => {
|
|
3729
|
+
let most = ["nobody", 0];
|
|
3730
|
+
for (const entry of byApp) if (entry[1] > most[1]) most = entry;
|
|
3731
|
+
const who = why === "owner" && ownerId !== null ? `, owned by ${ownerId} (whose apps hold ${String(byOwner.get(ownerId) ?? 0)} bytes; ${String(byOwner.size)} ${byOwner.size === 1 ? "owner holds" : "owners hold"} any)` : "";
|
|
3732
|
+
return `wirl: refused ${String(since)} ${since === 1 ? "broker call" : "broker calls"} for the ${what} held at once since this line was last written (at most once every 30 seconds), the latest for ${why === "app" ? "its app's share" : why === "owner" ? "its owner's share" : "the whole"}, from ${appId}${who}; ${String(total)} of ${String(allBytes)} bytes held, by ${String(byApp.size)} ${byApp.size === 1 ? "app" : "apps"}, the most by ${most[0]} (${String(most[1])} bytes)`;
|
|
3733
|
+
});
|
|
3734
|
+
};
|
|
3735
|
+
return {
|
|
3736
|
+
open(appId, owner) {
|
|
3737
|
+
const ownerId = named(owner);
|
|
3738
|
+
let counted = 0;
|
|
3739
|
+
let refusal = null;
|
|
3740
|
+
let done = false;
|
|
3741
|
+
return {
|
|
3742
|
+
arrive(bytes) {
|
|
3743
|
+
if (done || !isSize(bytes)) return false;
|
|
3744
|
+
const why = passes(appId, ownerId, bytes);
|
|
3745
|
+
if (why !== null) {
|
|
3746
|
+
refusal = why;
|
|
3747
|
+
refused2(why, appId, ownerId);
|
|
3748
|
+
return false;
|
|
3749
|
+
}
|
|
3750
|
+
if (bytes > 0) add(appId, ownerId, bytes);
|
|
3751
|
+
counted += bytes;
|
|
3752
|
+
return true;
|
|
3753
|
+
},
|
|
3754
|
+
refusal: () => refusal,
|
|
3755
|
+
release() {
|
|
3756
|
+
if (done) return;
|
|
3757
|
+
done = true;
|
|
3758
|
+
if (counted > 0) add(appId, ownerId, -counted);
|
|
3759
|
+
counted = 0;
|
|
3760
|
+
}
|
|
3761
|
+
};
|
|
3762
|
+
},
|
|
3763
|
+
held: () => total,
|
|
3764
|
+
heldBy: (appId) => byApp.get(appId) ?? 0,
|
|
3765
|
+
heldByOwner: (ownerId) => byOwner.get(ownerId) ?? 0,
|
|
3766
|
+
short(appId, owner, bytes) {
|
|
3767
|
+
const ownerId = named(owner);
|
|
3768
|
+
const why = passes(appId, ownerId, bytes);
|
|
3769
|
+
if (why !== null) refused2(why, appId, ownerId);
|
|
3770
|
+
return why;
|
|
3771
|
+
}
|
|
3772
|
+
};
|
|
3773
|
+
}
|
|
3774
|
+
var isSize;
|
|
3775
|
+
var init_broker_bodies = __esm({
|
|
3776
|
+
"packages/core/src/broker-bodies.ts"() {
|
|
3777
|
+
"use strict";
|
|
3778
|
+
init_broker_stream();
|
|
3779
|
+
init_refusals();
|
|
3780
|
+
init_wait_clock();
|
|
3781
|
+
isSize = (bytes) => Number.isSafeInteger(bytes) && bytes >= 0;
|
|
3358
3782
|
}
|
|
3359
3783
|
});
|
|
3360
3784
|
|
|
3361
3785
|
// packages/core/src/broker.ts
|
|
3362
3786
|
async function handleBrokerRequest(request, deps) {
|
|
3787
|
+
const holding = { body: null, answer: null };
|
|
3788
|
+
try {
|
|
3789
|
+
return await brokerCall(request, deps, holding);
|
|
3790
|
+
} finally {
|
|
3791
|
+
holding.body?.release();
|
|
3792
|
+
holding.answer?.release();
|
|
3793
|
+
}
|
|
3794
|
+
}
|
|
3795
|
+
async function brokerCall(request, deps, holding) {
|
|
3363
3796
|
const url = new URL(request.url);
|
|
3364
3797
|
const clock = deps.now ? { now: deps.now() } : {};
|
|
3365
3798
|
const bearer = readBearer(request);
|
|
@@ -3404,6 +3837,55 @@ async function handleBrokerRequest(request, deps) {
|
|
|
3404
3837
|
origin
|
|
3405
3838
|
});
|
|
3406
3839
|
};
|
|
3840
|
+
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
3841
|
+
const bodyLimit = deps.maxRequestBytes ?? BROKER_LIMITS.requestBytes;
|
|
3842
|
+
const bodyDeclared = hasBody ? declaredLength(request.headers) : null;
|
|
3843
|
+
const declaredTooLarge = bodyDeclared !== null && bodyDeclared > bodyLimit;
|
|
3844
|
+
const ownerId = request.method === "HEAD" || declaredTooLarge ? null : await ownerOfApp(deps, appId);
|
|
3845
|
+
let requestBody = null;
|
|
3846
|
+
if (hasBody) {
|
|
3847
|
+
const budget = deps.heldBodies;
|
|
3848
|
+
const held = budget?.open(appId, ownerId) ?? null;
|
|
3849
|
+
holding.body = held;
|
|
3850
|
+
const bodyRead = declaredTooLarge ? { outcome: "too_large" } : await readWithin(
|
|
3851
|
+
request.body,
|
|
3852
|
+
bodyLimit,
|
|
3853
|
+
budget === void 0 || held === null ? void 0 : {
|
|
3854
|
+
arrive: (bytes) => held.arrive(bytes),
|
|
3855
|
+
time: { clock: budget.clock, stallMs: budget.limits.heldBodyStallMs, arrivalMs: budget.limits.heldBodyArrivalMs }
|
|
3856
|
+
}
|
|
3857
|
+
);
|
|
3858
|
+
if (bodyRead.outcome !== "ok") {
|
|
3859
|
+
const named = route.slug === RESOLVE_BY_HOST ? request.headers.get("x-wirl-target") : null;
|
|
3860
|
+
if (route.slug === RESOLVE_BY_HOST) slug = "";
|
|
3861
|
+
if (named) path = egressAuditPath(named);
|
|
3862
|
+
const host2 = named ? safeHost(named) : "";
|
|
3863
|
+
held?.release();
|
|
3864
|
+
if (bodyRead.outcome === "too_large") {
|
|
3865
|
+
await record("body_too_large", 413, host2);
|
|
3866
|
+
return refuse2(
|
|
3867
|
+
413,
|
|
3868
|
+
"body_too_large",
|
|
3869
|
+
`This call's body is more than ${bodyLimit} bytes, the most a call may send, so wirl sent nothing.`
|
|
3870
|
+
);
|
|
3871
|
+
}
|
|
3872
|
+
if (bodyRead.outcome === "full") {
|
|
3873
|
+
const limits = budget?.limits ?? BROKER_LIMITS;
|
|
3874
|
+
const refusal = heldBodyRefusal(limits, held?.refusal() ?? "all");
|
|
3875
|
+
await record(refusal.error, 503, host2);
|
|
3876
|
+
return refuse2(503, refusal.error, refusal.message, { "retry-after": String(limits.heldBodyRetryAfterSeconds) });
|
|
3877
|
+
}
|
|
3878
|
+
if (bodyRead.outcome === "stalled" || bodyRead.outcome === "slow") {
|
|
3879
|
+
const cut2 = heldBodyCut(budget?.limits ?? BROKER_LIMITS, bodyRead.outcome);
|
|
3880
|
+
await record(cut2.error, 408, host2);
|
|
3881
|
+
return refuse2(408, cut2.error, cut2.message);
|
|
3882
|
+
}
|
|
3883
|
+
if (request.signal.aborted) return callerLeft(record, host2);
|
|
3884
|
+
await record("upstream_error", 502, host2);
|
|
3885
|
+
return refuse2(502, "upstream_error", bodyRead.error instanceof Error ? bodyRead.error.message : String(bodyRead.error));
|
|
3886
|
+
}
|
|
3887
|
+
requestBody = bodyRead.bytes;
|
|
3888
|
+
}
|
|
3407
3889
|
const connections = await deps.connectionsFor(appId, deploymentId);
|
|
3408
3890
|
let connection;
|
|
3409
3891
|
if (route.slug === RESOLVE_BY_HOST) {
|
|
@@ -3492,11 +3974,18 @@ async function handleBrokerRequest(request, deps) {
|
|
|
3492
3974
|
headers.set(name, value);
|
|
3493
3975
|
});
|
|
3494
3976
|
attachCredential(headers, connection);
|
|
3977
|
+
const answers = deps.heldAnswers;
|
|
3978
|
+
if (answers !== void 0 && request.method !== "HEAD") {
|
|
3979
|
+
const short = answers.roomFor(appId, ownerId);
|
|
3980
|
+
if (short !== null) {
|
|
3981
|
+
const noRoom = heldAnswerNoRoom(answers.limits, short);
|
|
3982
|
+
await record(noRoom.error, 503, host);
|
|
3983
|
+
return refuse2(503, noRoom.error, noRoom.message, { "retry-after": String(answers.limits.heldAnswerRetryAfterSeconds) });
|
|
3984
|
+
}
|
|
3985
|
+
}
|
|
3495
3986
|
const doFetch = deps.fetchImpl ?? fetch;
|
|
3496
|
-
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
3497
3987
|
let upstream;
|
|
3498
3988
|
try {
|
|
3499
|
-
const requestBody = hasBody ? await request.arrayBuffer() : null;
|
|
3500
3989
|
if (pastFlowDeadline(origin, deps)) return deadlinePassed();
|
|
3501
3990
|
upstream = await doFetch(decision.ok ? decision.url : target, {
|
|
3502
3991
|
method: request.method,
|
|
@@ -3536,16 +4025,30 @@ async function handleBrokerRequest(request, deps) {
|
|
|
3536
4025
|
return refuse2(502, "too_large", `The upstream announced ${declared} bytes, more than the ${limit} a call may return.`);
|
|
3537
4026
|
}
|
|
3538
4027
|
if (upstreamBody !== null && isStreamedAnswer(upstream.status, upstream.headers)) {
|
|
4028
|
+
const body2 = holding.body;
|
|
4029
|
+
holding.body = null;
|
|
3539
4030
|
return streamAnswer(upstream, upstreamBody, {
|
|
3540
4031
|
appId,
|
|
3541
4032
|
limit,
|
|
3542
4033
|
slots: deps.streamSlots ?? null,
|
|
3543
4034
|
safetyMs: deps.streamSafetyMs ?? BROKER_LIMITS.headersMs + BROKER_LIMITS.streamBodyMs,
|
|
4035
|
+
closing: closesWithEvent(upstream.headers),
|
|
4036
|
+
sent: deps.streamSent ?? null,
|
|
4037
|
+
sentMs: deps.streamSentMs ?? BROKER_LIMITS.cutEventSentMs,
|
|
3544
4038
|
signal: request.signal,
|
|
3545
|
-
record: (outcome, status) => record(outcome, status, safeHost(target))
|
|
4039
|
+
record: (outcome, status) => record(outcome, status, safeHost(target)),
|
|
4040
|
+
held: body2
|
|
3546
4041
|
});
|
|
3547
4042
|
}
|
|
3548
|
-
const
|
|
4043
|
+
const heldAnswer = answers?.open(appId, ownerId) ?? null;
|
|
4044
|
+
holding.answer = heldAnswer;
|
|
4045
|
+
const read = heldAnswer === null ? await readWithin(upstreamBody, limit) : await readWithin(upstreamBody, limit, { arrive: (bytes) => heldAnswer.arrive(bytes) });
|
|
4046
|
+
if (read.outcome !== "ok") heldAnswer?.release();
|
|
4047
|
+
if (read.outcome === "full") {
|
|
4048
|
+
const refusal = heldAnswerRefusal(answers?.limits ?? BROKER_LIMITS, heldAnswer?.refusal() ?? "all");
|
|
4049
|
+
await record(refusal.error, 502, safeHost(target));
|
|
4050
|
+
return refuse2(502, refusal.error, refusal.message);
|
|
4051
|
+
}
|
|
3549
4052
|
if (read.outcome === "error") {
|
|
3550
4053
|
if (request.signal.aborted) return callerLeft(record, safeHost(target));
|
|
3551
4054
|
await record("upstream_error", 502, safeHost(target));
|
|
@@ -3560,7 +4063,76 @@ async function handleBrokerRequest(request, deps) {
|
|
|
3560
4063
|
const out2 = answerHeaders(upstream.headers);
|
|
3561
4064
|
const empty = upstream.status === 204 || upstream.status === 304;
|
|
3562
4065
|
if (!empty) out2.set("content-length", String(body.byteLength));
|
|
3563
|
-
|
|
4066
|
+
if (answers === void 0 || heldAnswer === null || empty || body.byteLength === 0) {
|
|
4067
|
+
return new Response(empty ? null : body, { status: upstream.status, headers: out2 });
|
|
4068
|
+
}
|
|
4069
|
+
const exact = body.byteLength < body.buffer.byteLength ? body.slice() : body;
|
|
4070
|
+
const handed = handOut(exact, heldAnswer, request.signal, answers.clock, answers.limits.heldAnswerHandOutMs);
|
|
4071
|
+
let answer;
|
|
4072
|
+
try {
|
|
4073
|
+
answer = new Response(handed, { status: upstream.status, headers: out2 });
|
|
4074
|
+
} catch (error) {
|
|
4075
|
+
await handed.cancel().catch(() => {
|
|
4076
|
+
});
|
|
4077
|
+
throw error;
|
|
4078
|
+
}
|
|
4079
|
+
holding.answer = null;
|
|
4080
|
+
return answer;
|
|
4081
|
+
}
|
|
4082
|
+
async function ownerOfApp(deps, appId) {
|
|
4083
|
+
if (deps.ownerOf === void 0 || deps.heldBodies === void 0 && deps.heldAnswers === void 0) return null;
|
|
4084
|
+
const owner = await deps.ownerOf(appId);
|
|
4085
|
+
return typeof owner === "string" && owner.length > 0 ? owner : null;
|
|
4086
|
+
}
|
|
4087
|
+
function handOut(bytes, held, signal, clock, handOutMs) {
|
|
4088
|
+
let left = bytes;
|
|
4089
|
+
let stopWait = () => {
|
|
4090
|
+
};
|
|
4091
|
+
let appLeft = () => {
|
|
4092
|
+
};
|
|
4093
|
+
const finish = () => {
|
|
4094
|
+
if (left === null) return false;
|
|
4095
|
+
left = null;
|
|
4096
|
+
stopWait();
|
|
4097
|
+
signal.removeEventListener("abort", appLeft);
|
|
4098
|
+
held.release();
|
|
4099
|
+
return true;
|
|
4100
|
+
};
|
|
4101
|
+
return new ReadableStream(
|
|
4102
|
+
{
|
|
4103
|
+
start(controller) {
|
|
4104
|
+
const wait = clock.wait(handOutMs);
|
|
4105
|
+
stopWait = () => wait.cancel();
|
|
4106
|
+
void wait.done.then(() => {
|
|
4107
|
+
if (!finish()) return;
|
|
4108
|
+
const cut2 = new Error(
|
|
4109
|
+
`The answer was not all taken within ${String(handOutMs / 1e3)} seconds of being handed back, the longest wirl holds one, and was cut there.`
|
|
4110
|
+
);
|
|
4111
|
+
cut2.stack = `Error: ${cut2.message}`;
|
|
4112
|
+
controller.error(cut2);
|
|
4113
|
+
});
|
|
4114
|
+
appLeft = () => {
|
|
4115
|
+
if (finish()) controller.close();
|
|
4116
|
+
};
|
|
4117
|
+
if (signal.aborted) appLeft();
|
|
4118
|
+
else signal.addEventListener("abort", appLeft, { once: true });
|
|
4119
|
+
},
|
|
4120
|
+
pull(controller) {
|
|
4121
|
+
if (left === null) return;
|
|
4122
|
+
const slice = left.slice(0, HAND_OUT_BYTES);
|
|
4123
|
+
left = left.subarray(slice.byteLength);
|
|
4124
|
+
controller.enqueue(slice);
|
|
4125
|
+
if (left.byteLength > 0) return;
|
|
4126
|
+
finish();
|
|
4127
|
+
controller.close();
|
|
4128
|
+
},
|
|
4129
|
+
cancel() {
|
|
4130
|
+
finish();
|
|
4131
|
+
}
|
|
4132
|
+
},
|
|
4133
|
+
// Nothing is cut ahead of the app: the server's pace is the app's.
|
|
4134
|
+
{ highWaterMark: 0 }
|
|
4135
|
+
);
|
|
3564
4136
|
}
|
|
3565
4137
|
function egressAuditPath(target) {
|
|
3566
4138
|
let pathname;
|
|
@@ -3614,13 +4186,14 @@ function declaredLength(headers) {
|
|
|
3614
4186
|
return Number(raw.trim());
|
|
3615
4187
|
}
|
|
3616
4188
|
async function streamAnswer(upstream, body, context) {
|
|
3617
|
-
|
|
3618
|
-
|
|
4189
|
+
const giveBack = () => context.held?.release();
|
|
4190
|
+
let release = giveBack;
|
|
3619
4191
|
if (context.slots !== null) {
|
|
3620
4192
|
const slot = context.slots.acquire(context.appId);
|
|
3621
4193
|
if (slot === null) {
|
|
3622
4194
|
await body.cancel().catch(() => {
|
|
3623
4195
|
});
|
|
4196
|
+
giveBack();
|
|
3624
4197
|
await context.record("busy", 429);
|
|
3625
4198
|
return refuse2(
|
|
3626
4199
|
429,
|
|
@@ -3628,25 +4201,60 @@ async function streamAnswer(upstream, body, context) {
|
|
|
3628
4201
|
`This app already has ${context.slots.perApp} streamed answers open, the most it may hold at once; try again when one of them has finished.`
|
|
3629
4202
|
);
|
|
3630
4203
|
}
|
|
3631
|
-
release =
|
|
4204
|
+
release = () => {
|
|
4205
|
+
slot();
|
|
4206
|
+
giveBack();
|
|
4207
|
+
};
|
|
3632
4208
|
}
|
|
3633
4209
|
try {
|
|
3634
4210
|
await context.record("allowed", upstream.status);
|
|
3635
4211
|
} catch (error) {
|
|
4212
|
+
await body.cancel().catch(() => {
|
|
4213
|
+
});
|
|
3636
4214
|
release();
|
|
4215
|
+
throw error;
|
|
4216
|
+
}
|
|
4217
|
+
try {
|
|
4218
|
+
return new Response(passOn(body, context.limit, release, context.safetyMs, context.signal, context.closing, context.sent, context.sentMs), {
|
|
4219
|
+
status: upstream.status,
|
|
4220
|
+
headers: answerHeaders(upstream.headers)
|
|
4221
|
+
});
|
|
4222
|
+
} catch (error) {
|
|
3637
4223
|
await body.cancel().catch(() => {
|
|
3638
4224
|
});
|
|
4225
|
+
release();
|
|
3639
4226
|
throw error;
|
|
3640
4227
|
}
|
|
3641
|
-
return new Response(passOn(body, context.limit, release, context.safetyMs, context.signal), {
|
|
3642
|
-
status: upstream.status,
|
|
3643
|
-
headers: answerHeaders(upstream.headers)
|
|
3644
|
-
});
|
|
3645
4228
|
}
|
|
3646
|
-
function
|
|
4229
|
+
async function eventSent(sent, ms) {
|
|
4230
|
+
if (sent === null) {
|
|
4231
|
+
await new Promise((resolve7) => setTimeout(resolve7, 0));
|
|
4232
|
+
return;
|
|
4233
|
+
}
|
|
4234
|
+
const stop = new AbortController();
|
|
4235
|
+
let bound;
|
|
4236
|
+
try {
|
|
4237
|
+
await Promise.race([
|
|
4238
|
+
sent(stop.signal),
|
|
4239
|
+
new Promise((resolve7) => {
|
|
4240
|
+
bound = setTimeout(resolve7, ms);
|
|
4241
|
+
bound.unref?.();
|
|
4242
|
+
})
|
|
4243
|
+
]);
|
|
4244
|
+
} catch {
|
|
4245
|
+
} finally {
|
|
4246
|
+
clearTimeout(bound);
|
|
4247
|
+
stop.abort();
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
4250
|
+
function passOn(body, limit, release, safetyMs, signal, closing, sent, sentMs) {
|
|
3647
4251
|
const reader = body.getReader();
|
|
4252
|
+
const says = closing && limit > CUT_EVENT_RESERVE_BYTES;
|
|
4253
|
+
const room = says ? limit - CUT_EVENT_RESERVE_BYTES : limit;
|
|
3648
4254
|
let total = 0;
|
|
3649
4255
|
let over = false;
|
|
4256
|
+
let parting = null;
|
|
4257
|
+
let unread;
|
|
3650
4258
|
let safety;
|
|
3651
4259
|
let appLeft = () => {
|
|
3652
4260
|
};
|
|
@@ -3658,17 +4266,40 @@ function passOn(body, limit, release, safetyMs, signal) {
|
|
|
3658
4266
|
release();
|
|
3659
4267
|
return true;
|
|
3660
4268
|
};
|
|
3661
|
-
const cut2 = (controller, error) => {
|
|
4269
|
+
const cut2 = (controller, error, why) => {
|
|
3662
4270
|
if (!finish()) return;
|
|
3663
|
-
controller.error(error);
|
|
3664
4271
|
reader.cancel(error).catch(() => {
|
|
3665
4272
|
});
|
|
4273
|
+
if (!says) {
|
|
4274
|
+
controller.error(error);
|
|
4275
|
+
return;
|
|
4276
|
+
}
|
|
4277
|
+
try {
|
|
4278
|
+
controller.enqueue(streamCutEvent(why));
|
|
4279
|
+
} catch {
|
|
4280
|
+
controller.error(error);
|
|
4281
|
+
return;
|
|
4282
|
+
}
|
|
4283
|
+
parting = error;
|
|
4284
|
+
unread = setTimeout(() => {
|
|
4285
|
+
if (parting === null) return;
|
|
4286
|
+
parting = null;
|
|
4287
|
+
try {
|
|
4288
|
+
controller.error(error);
|
|
4289
|
+
} catch {
|
|
4290
|
+
}
|
|
4291
|
+
}, sentMs);
|
|
4292
|
+
unread.unref?.();
|
|
3666
4293
|
};
|
|
3667
4294
|
return new ReadableStream(
|
|
3668
4295
|
{
|
|
3669
4296
|
start(controller) {
|
|
3670
4297
|
const timer = setTimeout(() => {
|
|
3671
|
-
cut2(
|
|
4298
|
+
cut2(
|
|
4299
|
+
controller,
|
|
4300
|
+
new Error(`The upstream's answer was still open after ${safetyMs} ms, the longest the broker holds one, and was cut there.`),
|
|
4301
|
+
{ cause: "held", ms: safetyMs }
|
|
4302
|
+
);
|
|
3672
4303
|
}, safetyMs);
|
|
3673
4304
|
timer.unref?.();
|
|
3674
4305
|
safety = timer;
|
|
@@ -3682,11 +4313,21 @@ function passOn(body, limit, release, safetyMs, signal) {
|
|
|
3682
4313
|
else signal.addEventListener("abort", appLeft, { once: true });
|
|
3683
4314
|
},
|
|
3684
4315
|
async pull(controller) {
|
|
4316
|
+
if (parting !== null) {
|
|
4317
|
+
const error = parting;
|
|
4318
|
+
parting = null;
|
|
4319
|
+
clearTimeout(unread);
|
|
4320
|
+
await eventSent(sent, sentMs);
|
|
4321
|
+
controller.error(error);
|
|
4322
|
+
return;
|
|
4323
|
+
}
|
|
3685
4324
|
let next;
|
|
3686
4325
|
try {
|
|
3687
4326
|
next = await reader.read();
|
|
3688
4327
|
} catch (error) {
|
|
3689
|
-
|
|
4328
|
+
const bound = error instanceof PinnedFetchError ? error.streamBound : void 0;
|
|
4329
|
+
if (bound !== void 0) cut2(controller, error, { cause: bound.kind, ms: bound.ms });
|
|
4330
|
+
else if (finish()) controller.error(error);
|
|
3690
4331
|
return;
|
|
3691
4332
|
}
|
|
3692
4333
|
if (over) return;
|
|
@@ -3696,14 +4337,21 @@ function passOn(body, limit, release, safetyMs, signal) {
|
|
|
3696
4337
|
return;
|
|
3697
4338
|
}
|
|
3698
4339
|
total += next.value.byteLength;
|
|
3699
|
-
if (total >
|
|
3700
|
-
cut2(
|
|
4340
|
+
if (total > room) {
|
|
4341
|
+
cut2(
|
|
4342
|
+
controller,
|
|
4343
|
+
new Error(
|
|
4344
|
+
says ? `The upstream's answer reached the ${limit} bytes a call may return, counting the event that closes it, and was cut there.` : `The upstream's answer passed ${limit} bytes, the most a call may return, and was cut there.`
|
|
4345
|
+
),
|
|
4346
|
+
{ cause: "size", bytes: limit }
|
|
4347
|
+
);
|
|
3701
4348
|
return;
|
|
3702
4349
|
}
|
|
3703
4350
|
controller.enqueue(next.value);
|
|
3704
4351
|
},
|
|
3705
4352
|
cancel(reason) {
|
|
3706
4353
|
finish();
|
|
4354
|
+
clearTimeout(unread);
|
|
3707
4355
|
return reader.cancel(reason).catch(() => {
|
|
3708
4356
|
});
|
|
3709
4357
|
}
|
|
@@ -3712,37 +4360,62 @@ function passOn(body, limit, release, safetyMs, signal) {
|
|
|
3712
4360
|
{ highWaterMark: 0 }
|
|
3713
4361
|
);
|
|
3714
4362
|
}
|
|
3715
|
-
async function readWithin(body, limit) {
|
|
3716
|
-
if (body === null) return { outcome: "ok", bytes: new
|
|
3717
|
-
|
|
3718
|
-
|
|
4363
|
+
async function readWithin(body, limit, bounds) {
|
|
4364
|
+
if (body === null) return { outcome: "ok", bytes: new Uint8Array(0) };
|
|
4365
|
+
let reader;
|
|
4366
|
+
let held = new Uint8Array(0);
|
|
3719
4367
|
let total = 0;
|
|
4368
|
+
const time = bounds?.time;
|
|
4369
|
+
const arrival = time?.clock.wait(time.arrivalMs);
|
|
4370
|
+
const slow = arrival?.done.then(() => SLOW);
|
|
3720
4371
|
try {
|
|
4372
|
+
reader = body.getReader();
|
|
3721
4373
|
for (; ; ) {
|
|
3722
|
-
const next = await reader
|
|
4374
|
+
const next = await nextWithin(reader, time, slow);
|
|
4375
|
+
if (next === STALLED || next === SLOW) {
|
|
4376
|
+
await reader.cancel().catch(() => {
|
|
4377
|
+
});
|
|
4378
|
+
return { outcome: next === STALLED ? "stalled" : "slow" };
|
|
4379
|
+
}
|
|
3723
4380
|
if (next.done) break;
|
|
3724
4381
|
const chunk = next.value;
|
|
3725
4382
|
if (chunk === void 0) continue;
|
|
3726
|
-
total
|
|
3727
|
-
if (
|
|
4383
|
+
const needed = total + chunk.byteLength;
|
|
4384
|
+
if (needed > limit) {
|
|
3728
4385
|
await reader.cancel().catch(() => {
|
|
3729
4386
|
});
|
|
3730
4387
|
return { outcome: "too_large" };
|
|
3731
4388
|
}
|
|
3732
|
-
|
|
4389
|
+
if (bounds !== void 0 && !bounds.arrive(chunk.byteLength)) {
|
|
4390
|
+
await reader.cancel().catch(() => {
|
|
4391
|
+
});
|
|
4392
|
+
return { outcome: "full" };
|
|
4393
|
+
}
|
|
4394
|
+
if (needed > held.byteLength) {
|
|
4395
|
+
const grown = new Uint8Array(Math.min(limit, Math.max(needed, held.byteLength * 2, FIRST_HOLD_BYTES)));
|
|
4396
|
+
grown.set(held.subarray(0, total));
|
|
4397
|
+
held = grown;
|
|
4398
|
+
}
|
|
4399
|
+
held.set(chunk, total);
|
|
4400
|
+
total = needed;
|
|
3733
4401
|
}
|
|
3734
4402
|
} catch (error) {
|
|
3735
|
-
await reader
|
|
4403
|
+
await reader?.cancel().catch(() => {
|
|
3736
4404
|
});
|
|
3737
4405
|
return { outcome: "error", error };
|
|
4406
|
+
} finally {
|
|
4407
|
+
arrival?.cancel();
|
|
3738
4408
|
}
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
4409
|
+
return { outcome: "ok", bytes: held.subarray(0, total) };
|
|
4410
|
+
}
|
|
4411
|
+
async function nextWithin(reader, time, slow) {
|
|
4412
|
+
if (time === void 0 || slow === void 0) return reader.read();
|
|
4413
|
+
const stall = time.clock.wait(time.stallMs);
|
|
4414
|
+
try {
|
|
4415
|
+
return await Promise.race([reader.read(), stall.done.then(() => STALLED), slow]);
|
|
4416
|
+
} finally {
|
|
4417
|
+
stall.cancel();
|
|
3744
4418
|
}
|
|
3745
|
-
return { outcome: "ok", bytes: joined2.buffer };
|
|
3746
4419
|
}
|
|
3747
4420
|
function credentialShape(kind) {
|
|
3748
4421
|
return Object.hasOwn(CREDENTIAL_SHAPES, kind) ? CREDENTIAL_SHAPES[kind] : null;
|
|
@@ -3792,19 +4465,21 @@ function readBearer(request) {
|
|
|
3792
4465
|
const value = header.slice(7).trim();
|
|
3793
4466
|
return value.length > 0 ? value : null;
|
|
3794
4467
|
}
|
|
3795
|
-
function refuse2(status, reason, message) {
|
|
4468
|
+
function refuse2(status, reason, message, extra = {}) {
|
|
3796
4469
|
return new Response(JSON.stringify({ error: reason, message }, null, 2), {
|
|
3797
4470
|
status,
|
|
3798
4471
|
headers: new Headers({
|
|
3799
4472
|
"content-type": "application/json; charset=utf-8",
|
|
3800
|
-
"cache-control": "no-store"
|
|
4473
|
+
"cache-control": "no-store",
|
|
4474
|
+
...extra
|
|
3801
4475
|
})
|
|
3802
4476
|
});
|
|
3803
4477
|
}
|
|
3804
|
-
var RESOLVE_BY_HOST, STRIPPED_PREFIXES, STRIPPED, HOP_PREFIXES, HOP_HEADERS, isHopHeader, AUDIT_PATH_CHARS, CREDENTIAL_SHAPES, isHttpKind, safeHost;
|
|
4478
|
+
var RESOLVE_BY_HOST, STRIPPED_PREFIXES, STRIPPED, HOP_PREFIXES, HOP_HEADERS, isHopHeader, HAND_OUT_BYTES, AUDIT_PATH_CHARS, STALLED, SLOW, FIRST_HOLD_BYTES, CREDENTIAL_SHAPES, isHttpKind, safeHost;
|
|
3805
4479
|
var init_broker = __esm({
|
|
3806
4480
|
"packages/core/src/broker.ts"() {
|
|
3807
4481
|
"use strict";
|
|
4482
|
+
init_broker_bodies();
|
|
3808
4483
|
init_broker_stream();
|
|
3809
4484
|
init_broker_token();
|
|
3810
4485
|
init_egress();
|
|
@@ -3856,7 +4531,11 @@ var init_broker = __esm({
|
|
|
3856
4531
|
"malicious-uploads-detection"
|
|
3857
4532
|
]);
|
|
3858
4533
|
isHopHeader = (lower) => HOP_HEADERS.has(lower) || HOP_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
|
4534
|
+
HAND_OUT_BYTES = 64 * 1024;
|
|
3859
4535
|
AUDIT_PATH_CHARS = 200;
|
|
4536
|
+
STALLED = /* @__PURE__ */ Symbol("stalled");
|
|
4537
|
+
SLOW = /* @__PURE__ */ Symbol("slow");
|
|
4538
|
+
FIRST_HOLD_BYTES = 16 * 1024;
|
|
3860
4539
|
CREDENTIAL_SHAPES = Object.freeze({
|
|
3861
4540
|
http_bearer: Object.freeze({ sends: "bearer", fields: Object.freeze(["token"]) }),
|
|
3862
4541
|
http_header: Object.freeze({ sends: "headers", fields: Object.freeze([]) }),
|
|
@@ -3973,7 +4652,7 @@ arrives: pass \`response.body\` straight on. There are no WebSockets. A front en
|
|
|
3973
4652
|
answers everything else; add \`"spa": true\` for a client-side router. Nothing outside that folder is served.
|
|
3974
4653
|
|
|
3975
4654
|
Preview before you deploy: \`wirl preview\` gives a folder its own address and database (at first only what
|
|
3976
|
-
\`migrations/\` makes). \`wirl fetch <path> --preview <name>\` or \`wirl_preview_fetch\` shows what it answers.
|
|
4655
|
+
\`migrations/\` makes). \`wirl fetch <path> --preview <name>\` or \`wirl_preview_fetch\` shows what it answers. Every request a preview's module gets names the preview in \`x-wirl-preview\`, and wirl marks its pages as previews.
|
|
3977
4656
|
|
|
3978
4657
|
npm packages work: install them in the project and the deploy bundles them in. Nothing is installed on the
|
|
3979
4658
|
server: native code will not run. Node built-ins work (\`node:crypto\`, \`node:path\`, ...; \`Buffer\` and
|
|
@@ -4039,7 +4718,7 @@ var init_version = __esm({
|
|
|
4039
4718
|
"packages/core/src/version.ts"() {
|
|
4040
4719
|
"use strict";
|
|
4041
4720
|
WORKSPACE_VERSION = "0.0.0";
|
|
4042
|
-
BUILT_VERSION = true ? "0.2.
|
|
4721
|
+
BUILT_VERSION = true ? "0.2.3" : null;
|
|
4043
4722
|
}
|
|
4044
4723
|
});
|
|
4045
4724
|
|
|
@@ -4067,6 +4746,48 @@ var init_install = __esm({
|
|
|
4067
4746
|
}
|
|
4068
4747
|
});
|
|
4069
4748
|
|
|
4749
|
+
// packages/core/src/share-role.ts
|
|
4750
|
+
function readShareRole(value) {
|
|
4751
|
+
if (value === void 0 || value === null) return { ok: true, role: void 0 };
|
|
4752
|
+
if (value === AppRole.VIEWER || value === AppRole.EDITOR) return { ok: true, role: value };
|
|
4753
|
+
return { ok: false, message: SHARE_ROLE_REFUSAL };
|
|
4754
|
+
}
|
|
4755
|
+
var SHARE_ROLE_REFUSAL;
|
|
4756
|
+
var init_share_role = __esm({
|
|
4757
|
+
"packages/core/src/share-role.ts"() {
|
|
4758
|
+
"use strict";
|
|
4759
|
+
init_types();
|
|
4760
|
+
SHARE_ROLE_REFUSAL = "role must be viewer or editor.";
|
|
4761
|
+
}
|
|
4762
|
+
});
|
|
4763
|
+
|
|
4764
|
+
// packages/core/src/connect.ts
|
|
4765
|
+
var CLAUDE_CODE_SIGN_IN, CLAUDE_CODE_LOCAL_SIGN_IN;
|
|
4766
|
+
var init_connect = __esm({
|
|
4767
|
+
"packages/core/src/connect.ts"() {
|
|
4768
|
+
"use strict";
|
|
4769
|
+
init_install();
|
|
4770
|
+
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.";
|
|
4771
|
+
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.`;
|
|
4772
|
+
}
|
|
4773
|
+
});
|
|
4774
|
+
|
|
4775
|
+
// packages/core/src/setup-prompt.ts
|
|
4776
|
+
function starterName(email) {
|
|
4777
|
+
const handle = (email ?? "").split("@")[0] ?? "";
|
|
4778
|
+
const slug = handle.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
4779
|
+
const cut2 = slug.slice(0, SLUG_MAX - STARTER_SUFFIX.length).replace(/-+$/, "");
|
|
4780
|
+
return cut2 === "" ? "starter" : `${cut2}${STARTER_SUFFIX}`;
|
|
4781
|
+
}
|
|
4782
|
+
var STARTER_SUFFIX;
|
|
4783
|
+
var init_setup_prompt = __esm({
|
|
4784
|
+
"packages/core/src/setup-prompt.ts"() {
|
|
4785
|
+
"use strict";
|
|
4786
|
+
init_slug();
|
|
4787
|
+
STARTER_SUFFIX = "-starter";
|
|
4788
|
+
}
|
|
4789
|
+
});
|
|
4790
|
+
|
|
4070
4791
|
// packages/core/src/porting.ts
|
|
4071
4792
|
function dependencyNames(packageJson) {
|
|
4072
4793
|
if (packageJson === void 0) return [];
|
|
@@ -4287,6 +5008,66 @@ var init_porting = __esm({
|
|
|
4287
5008
|
}
|
|
4288
5009
|
});
|
|
4289
5010
|
|
|
5011
|
+
// packages/core/src/throttle-report.ts
|
|
5012
|
+
var THROTTLE_REPORT, EDGE_REFUSALS_NOTE;
|
|
5013
|
+
var init_throttle_report = __esm({
|
|
5014
|
+
"packages/core/src/throttle-report.ts"() {
|
|
5015
|
+
"use strict";
|
|
5016
|
+
init_public_path();
|
|
5017
|
+
THROTTLE_REPORT = {
|
|
5018
|
+
/**
|
|
5019
|
+
* One report per app a minute at most from each gate isolate, and what
|
|
5020
|
+
* one report counts spans a minute at most.
|
|
5021
|
+
*/
|
|
5022
|
+
windowMs: 6e4,
|
|
5023
|
+
/**
|
|
5024
|
+
* How long the refusal that opens a window waits for more before the
|
|
5025
|
+
* report goes, so a burst is one report with its count rather than a
|
|
5026
|
+
* report of its first refusal.
|
|
5027
|
+
*/
|
|
5028
|
+
gatherMs: 15e3,
|
|
5029
|
+
/**
|
|
5030
|
+
* The longest a refused request is asked to stay open, after its 429 has
|
|
5031
|
+
* gone, for the report to be sent. Cloudflare keeps a request's
|
|
5032
|
+
* `waitUntil` work for 30 seconds after the response, shared by all of
|
|
5033
|
+
* it, and the send itself has `sendTimeoutMs` of what is left.
|
|
5034
|
+
*/
|
|
5035
|
+
maxWaitMs: 2e4,
|
|
5036
|
+
sendTimeoutMs: 5e3,
|
|
5037
|
+
/**
|
|
5038
|
+
* Reports one request carries for windows no refusal could wait for
|
|
5039
|
+
* (`flushDue`). A request that was never refused must not queue its own
|
|
5040
|
+
* reads behind posts that are not its own: Cloudflare runs six of a
|
|
5041
|
+
* request's connections at once and queues the rest.
|
|
5042
|
+
*/
|
|
5043
|
+
flushPerRequest: 2,
|
|
5044
|
+
/** Apps one gate isolate counts for at once; a refusal for another app past this is not counted. */
|
|
5045
|
+
maxApps: 1e3,
|
|
5046
|
+
/** Lines in one report: each of an app's public paths, by each ceiling. */
|
|
5047
|
+
maxEntries: 2 * PUBLIC_PATH_LIMITS.perApp,
|
|
5048
|
+
/** A path is cut to this many characters, as `public.refused`'s is. */
|
|
5049
|
+
maxPathChars: 256,
|
|
5050
|
+
/**
|
|
5051
|
+
* The most of a report's body the control plane reads. A full report is
|
|
5052
|
+
* under 8 KiB: a path a request matched is ASCII, as a URL's path is.
|
|
5053
|
+
*/
|
|
5054
|
+
bodyBytes: 16384,
|
|
5055
|
+
/**
|
|
5056
|
+
* Reports the control plane reads for one app a minute, from every isolate
|
|
5057
|
+
* and location together. Of those, the first writes the app's one
|
|
5058
|
+
* `public.throttled` row for that minute and the rest write nothing.
|
|
5059
|
+
*/
|
|
5060
|
+
perAppPerMinute: 10,
|
|
5061
|
+
/** The org's Activity page summarises this many apps' latest `public.throttled` lines, a line an app, from the last `logsPageDays` days. */
|
|
5062
|
+
activityLines: 5,
|
|
5063
|
+
/** The app's Logs page shows its latest this many `public.throttled` lines, from the last `logsPageDays` days. */
|
|
5064
|
+
logsPageLines: 20,
|
|
5065
|
+
logsPageDays: 7
|
|
5066
|
+
};
|
|
5067
|
+
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.";
|
|
5068
|
+
}
|
|
5069
|
+
});
|
|
5070
|
+
|
|
4290
5071
|
// packages/core/src/supplied-bundle.ts
|
|
4291
5072
|
var SUPPLIED_BUNDLE_PATH, MAX_SUPPLIED_BUNDLE_BYTES;
|
|
4292
5073
|
var init_supplied_bundle = __esm({
|
|
@@ -4310,11 +5091,22 @@ var init_manifest_transport = __esm({
|
|
|
4310
5091
|
}
|
|
4311
5092
|
});
|
|
4312
5093
|
|
|
5094
|
+
// packages/core/src/preview.ts
|
|
5095
|
+
function versionNumbersSentence(listing) {
|
|
5096
|
+
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.`;
|
|
5097
|
+
}
|
|
5098
|
+
var init_preview = __esm({
|
|
5099
|
+
"packages/core/src/preview.ts"() {
|
|
5100
|
+
"use strict";
|
|
5101
|
+
}
|
|
5102
|
+
});
|
|
5103
|
+
|
|
4313
5104
|
// packages/core/src/visit-token.ts
|
|
4314
|
-
var VISIT_QUERY_PARAM, VISIT_HEADER;
|
|
5105
|
+
var VISIT_TOKEN_TTL_SECONDS, VISIT_QUERY_PARAM, VISIT_HEADER;
|
|
4315
5106
|
var init_visit_token = __esm({
|
|
4316
5107
|
"packages/core/src/visit-token.ts"() {
|
|
4317
5108
|
"use strict";
|
|
5109
|
+
VISIT_TOKEN_TTL_SECONDS = 900;
|
|
4318
5110
|
VISIT_QUERY_PARAM = "__wirl_visit";
|
|
4319
5111
|
VISIT_HEADER = "x-wirl-visit";
|
|
4320
5112
|
}
|
|
@@ -4346,9 +5138,14 @@ var init_src = __esm({
|
|
|
4346
5138
|
init_contract();
|
|
4347
5139
|
init_version();
|
|
4348
5140
|
init_install();
|
|
5141
|
+
init_share_role();
|
|
5142
|
+
init_connect();
|
|
5143
|
+
init_setup_prompt();
|
|
4349
5144
|
init_porting();
|
|
5145
|
+
init_throttle_report();
|
|
4350
5146
|
init_supplied_bundle();
|
|
4351
5147
|
init_manifest_transport();
|
|
5148
|
+
init_preview();
|
|
4352
5149
|
init_visit_token();
|
|
4353
5150
|
}
|
|
4354
5151
|
});
|
|
@@ -5995,7 +6792,7 @@ import { constants as fsConstants, createReadStream } from "node:fs";
|
|
|
5995
6792
|
import { access, link, lstat as lstat2, mkdir as mkdir3, open, readFile as readFile3, readdir, rename as rename2, rm as rm3, stat as stat3, unlink, writeFile as writeFile2 } from "node:fs/promises";
|
|
5996
6793
|
import { homedir } from "node:os";
|
|
5997
6794
|
import { basename as basename2, dirname as dirname3, isAbsolute, join as join5, relative, resolve as resolve4, sep } from "node:path";
|
|
5998
|
-
import { create as tarCreate, extract as tarExtract } from "tar";
|
|
6795
|
+
import { create as tarCreate, extract as tarExtract, Parser as TarParser } from "tar";
|
|
5999
6796
|
import { gzipSync } from "node:zlib";
|
|
6000
6797
|
|
|
6001
6798
|
// packages/core/src/node/pinned-fetch.ts
|
|
@@ -6090,7 +6887,7 @@ function createPinnedFetch(options = {}) {
|
|
|
6090
6887
|
if (streamsBody !== void 0 && !streamsBody(status, answered)) return null;
|
|
6091
6888
|
clearTimeout(timer);
|
|
6092
6889
|
timer = setTimeout(() => {
|
|
6093
|
-
fail(new PinnedFetchError("timeout", url.hostname, `${url.hostname} did not finish its answer within ${bodyTimeoutMs} ms
|
|
6890
|
+
fail(new PinnedFetchError("timeout", url.hostname, `${url.hostname} did not finish its answer within ${bodyTimeoutMs} ms.`, { kind: "time", ms: bodyTimeoutMs }));
|
|
6094
6891
|
}, bodyTimeoutMs);
|
|
6095
6892
|
return bodyIdleMs;
|
|
6096
6893
|
};
|
|
@@ -6227,13 +7024,17 @@ function sendTo(wire, address, attempt, attemptMs) {
|
|
|
6227
7024
|
budget = idle ?? Math.max(MIN_ATTEMPT_MS, wire.remainingMs());
|
|
6228
7025
|
request.setTimeout(budget);
|
|
6229
7026
|
const bodyless = NO_BODY.has(status) || method.toUpperCase() === "HEAD";
|
|
7027
|
+
const answerOver = () => {
|
|
7028
|
+
wire.done();
|
|
7029
|
+
if (!request.writableFinished) request.destroy();
|
|
7030
|
+
};
|
|
6230
7031
|
if (bodyless) {
|
|
6231
7032
|
incoming.resume();
|
|
6232
7033
|
incoming.on("error", () => {
|
|
6233
7034
|
});
|
|
6234
|
-
|
|
7035
|
+
answerOver();
|
|
6235
7036
|
} else {
|
|
6236
|
-
incoming.once("close",
|
|
7037
|
+
incoming.once("close", answerOver);
|
|
6237
7038
|
}
|
|
6238
7039
|
resolve7(new Response(bodyless ? null : Readable.toWeb(incoming), { status, headers: out2 }));
|
|
6239
7040
|
} catch (error) {
|
|
@@ -6251,7 +7052,10 @@ function sendTo(wire, address, attempt, attemptMs) {
|
|
|
6251
7052
|
const error = new PinnedFetchError(
|
|
6252
7053
|
"timeout",
|
|
6253
7054
|
url.hostname,
|
|
6254
|
-
streaming ? `${url.hostname}'s answer stalled: nothing moved on it for ${budget} ms on ${address}, because the host sent nothing or nothing was read from it.` : `${url.hostname} did not answer within ${budget} ms on ${address}
|
|
7055
|
+
streaming ? `${url.hostname}'s answer stalled: nothing moved on it for ${budget} ms on ${address}, because the host sent nothing or nothing was read from it.` : `${url.hostname} did not answer within ${budget} ms on ${address}.`,
|
|
7056
|
+
// Which bound it was, for the broker to say what it cut a stream
|
|
7057
|
+
// for (OIM-837): only on a body on bounds of its own.
|
|
7058
|
+
streaming ? { kind: "idle", ms: budget } : void 0
|
|
6255
7059
|
);
|
|
6256
7060
|
if (delivered !== null) delivered.destroy(error);
|
|
6257
7061
|
else request.destroy(error);
|
|
@@ -6271,6 +7075,7 @@ function sendTo(wire, address, attempt, attemptMs) {
|
|
|
6271
7075
|
}
|
|
6272
7076
|
|
|
6273
7077
|
// packages/core/src/node/broker-fetch.ts
|
|
7078
|
+
init_broker_bodies();
|
|
6274
7079
|
init_broker_stream();
|
|
6275
7080
|
var BROKER_FETCH_OPTIONS = Object.freeze({
|
|
6276
7081
|
timeoutMs: BROKER_LIMITS.headersMs,
|
|
@@ -6281,10 +7086,38 @@ var BROKER_FETCH_OPTIONS = Object.freeze({
|
|
|
6281
7086
|
function createBrokerTransport(options = {}) {
|
|
6282
7087
|
return {
|
|
6283
7088
|
fetchImpl: options.fetchImpl ?? createPinnedFetch({ ...BROKER_FETCH_OPTIONS, ...options.pinning }),
|
|
6284
|
-
streamSlots: createStreamSlots()
|
|
7089
|
+
streamSlots: createStreamSlots(),
|
|
7090
|
+
heldBodies: createHeldBodies({ limits: options.heldBodyLimits, log: options.log }),
|
|
7091
|
+
heldAnswers: createHeldAnswers({ limits: options.heldAnswerLimits, log: options.log })
|
|
6285
7092
|
};
|
|
6286
7093
|
}
|
|
6287
7094
|
|
|
7095
|
+
// packages/core/src/node/sent-on.ts
|
|
7096
|
+
var LOOK_EVERY_MS = 2;
|
|
7097
|
+
function sentOn(response) {
|
|
7098
|
+
if (response === void 0) return void 0;
|
|
7099
|
+
const nothingLeft = () => {
|
|
7100
|
+
const socket = response.socket;
|
|
7101
|
+
if (response.destroyed === true || socket === null || socket?.destroyed === true) return true;
|
|
7102
|
+
return (response.writableLength ?? socket?.writableLength ?? 0) === 0;
|
|
7103
|
+
};
|
|
7104
|
+
return (stop) => new Promise((resolve7) => {
|
|
7105
|
+
let timer;
|
|
7106
|
+
const settle2 = () => {
|
|
7107
|
+
clearTimeout(timer);
|
|
7108
|
+
stop.removeEventListener("abort", settle2);
|
|
7109
|
+
resolve7();
|
|
7110
|
+
};
|
|
7111
|
+
const look = () => {
|
|
7112
|
+
if (stop.aborted || nothingLeft()) return settle2();
|
|
7113
|
+
timer = setTimeout(look, LOOK_EVERY_MS);
|
|
7114
|
+
timer.unref?.();
|
|
7115
|
+
};
|
|
7116
|
+
stop.addEventListener("abort", settle2, { once: true });
|
|
7117
|
+
look();
|
|
7118
|
+
});
|
|
7119
|
+
}
|
|
7120
|
+
|
|
6288
7121
|
// packages/core/src/node/dev-identity.ts
|
|
6289
7122
|
import { createHash } from "node:crypto";
|
|
6290
7123
|
function devUserId(email) {
|
|
@@ -6300,10 +7133,9 @@ init_slug();
|
|
|
6300
7133
|
import { createHash as createHash2 } from "node:crypto";
|
|
6301
7134
|
var HASH_SUFFIX_LENGTH = 5;
|
|
6302
7135
|
function previewNameFor(raw, orgSlug, appSlug) {
|
|
6303
|
-
const
|
|
6304
|
-
if (!
|
|
6305
|
-
|
|
6306
|
-
}
|
|
7136
|
+
const made = previewSlugFor(raw);
|
|
7137
|
+
if (!made.ok) return { ok: false, message: made.message };
|
|
7138
|
+
const slug = made.slug;
|
|
6307
7139
|
const budget = MAX_HOST_LABEL - (appLabel(orgSlug, appSlug).length + HOST_SEPARATOR.length);
|
|
6308
7140
|
if (budget < 2) {
|
|
6309
7141
|
return {
|
|
@@ -6402,26 +7234,507 @@ function parseIgnore(text) {
|
|
|
6402
7234
|
"A .wirlignore can only exclude more, never re-include a file. Remove this line."
|
|
6403
7235
|
);
|
|
6404
7236
|
}
|
|
6405
|
-
rules.push(compileRule(line, i + 1));
|
|
6406
|
-
}
|
|
6407
|
-
return {
|
|
6408
|
-
ignores(path, isDirectory) {
|
|
6409
|
-
return rules.some((rule) => (!rule.directoryOnly || isDirectory) && rule.regex.test(path));
|
|
7237
|
+
rules.push(compileRule(line, i + 1));
|
|
7238
|
+
}
|
|
7239
|
+
return {
|
|
7240
|
+
ignores(path, isDirectory) {
|
|
7241
|
+
return rules.some((rule) => (!rule.directoryOnly || isDirectory) && rule.regex.test(path));
|
|
7242
|
+
}
|
|
7243
|
+
};
|
|
7244
|
+
}
|
|
7245
|
+
|
|
7246
|
+
// packages/cli/src/shell.ts
|
|
7247
|
+
function shellQuote(value) {
|
|
7248
|
+
if (/^[A-Za-z0-9_\-./:@%+,]+$/.test(value)) return value;
|
|
7249
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
7250
|
+
}
|
|
7251
|
+
function shellPath(value) {
|
|
7252
|
+
if (value.startsWith("-")) return shellQuote(`./${value}`);
|
|
7253
|
+
if (value === "~" || value === "~/") return value;
|
|
7254
|
+
if (value.startsWith("~/")) return `~/${shellQuote(value.slice(2))}`;
|
|
7255
|
+
return shellQuote(value);
|
|
7256
|
+
}
|
|
7257
|
+
|
|
7258
|
+
// packages/cli/src/skill.ts
|
|
7259
|
+
init_src();
|
|
7260
|
+
var SKILL_INTRO = `A new app is visible to everyone in the org when the org came from a
|
|
7261
|
+
company email domain; in any other org, including a personal one or one
|
|
7262
|
+
made with \`wirl orgs create\`, it starts private. Deploys are one
|
|
7263
|
+
command; sharing is by email; a third-party API is declared, never pasted.`;
|
|
7264
|
+
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.";
|
|
7265
|
+
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).
|
|
7266
|
+
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.
|
|
7267
|
+
3. If a command names several organisations and asks which, ask the person; pass \`--org\` or put \`"org"\` in \`wirl.json\`.
|
|
7268
|
+
4. To keep a file out of the deploy, list it in a \`.wirlignore\` at the project root (gitignore-style lines; no \`!\` negation).
|
|
7269
|
+
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.`;
|
|
7270
|
+
var SKILL_MANIFEST_EXAMPLE = `\`\`\`json
|
|
7271
|
+
{ "app": "crm-sync", "server": "server.mjs", "connections": [{ "slug": "hubspot", "hosts": ["api.hubspot.com"] }] }
|
|
7272
|
+
\`\`\``;
|
|
7273
|
+
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.";
|
|
7274
|
+
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.';
|
|
7275
|
+
function composeSkill(parts) {
|
|
7276
|
+
if (parts.description.trim() === "" || /[\r\n]/.test(parts.description)) {
|
|
7277
|
+
throw new Error("a skill description is one non-empty line");
|
|
7278
|
+
}
|
|
7279
|
+
return `---
|
|
7280
|
+
name: wirl
|
|
7281
|
+
description: ${parts.description}
|
|
7282
|
+
---
|
|
7283
|
+
|
|
7284
|
+
# wirl
|
|
7285
|
+
|
|
7286
|
+
${SKILL_INTRO}
|
|
7287
|
+
|
|
7288
|
+
${SKILL_DOCS_LINE}
|
|
7289
|
+
|
|
7290
|
+
## Commands
|
|
7291
|
+
|
|
7292
|
+
${parts.commands.trimEnd()}
|
|
7293
|
+
|
|
7294
|
+
## Rules
|
|
7295
|
+
|
|
7296
|
+
${SKILL_RULES}
|
|
7297
|
+
|
|
7298
|
+
## wirl.json
|
|
7299
|
+
|
|
7300
|
+
${SKILL_MANIFEST_EXAMPLE}
|
|
7301
|
+
|
|
7302
|
+
## What runs on wirl
|
|
7303
|
+
|
|
7304
|
+
${BUILD_CONTRACT}
|
|
7305
|
+
|
|
7306
|
+
${SKILL_FILES_PARAGRAPH}
|
|
7307
|
+
|
|
7308
|
+
${SKILL_STATIC_PARAGRAPH}
|
|
7309
|
+
|
|
7310
|
+
## Flows
|
|
7311
|
+
|
|
7312
|
+
${FLOWS_CONTRACT}
|
|
7313
|
+
`;
|
|
7314
|
+
}
|
|
7315
|
+
|
|
7316
|
+
// packages/cli/src/starter.ts
|
|
7317
|
+
init_src();
|
|
7318
|
+
var STARTER_CONFLICT = "Choose one: --server or --starter.";
|
|
7319
|
+
var SUGGESTIONS = [
|
|
7320
|
+
{
|
|
7321
|
+
title: "Rename it to something we'd actually use",
|
|
7322
|
+
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.`
|
|
7323
|
+
},
|
|
7324
|
+
{
|
|
7325
|
+
title: "Try a new look in a preview first",
|
|
7326
|
+
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."
|
|
7327
|
+
},
|
|
7328
|
+
{
|
|
7329
|
+
title: "Add a due date to notes",
|
|
7330
|
+
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."
|
|
7331
|
+
},
|
|
7332
|
+
{
|
|
7333
|
+
title: "Show the weather from a public API",
|
|
7334
|
+
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.`
|
|
7335
|
+
},
|
|
7336
|
+
{
|
|
7337
|
+
title: "Change when the tidy-up runs",
|
|
7338
|
+
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."
|
|
7339
|
+
},
|
|
7340
|
+
{
|
|
7341
|
+
title: "Find out what happened from the logs",
|
|
7342
|
+
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."
|
|
7343
|
+
},
|
|
7344
|
+
{
|
|
7345
|
+
title: "Share it with a colleague",
|
|
7346
|
+
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.`
|
|
7347
|
+
}
|
|
7348
|
+
];
|
|
7349
|
+
var SUGGESTIONS_SOURCE = [
|
|
7350
|
+
"const SUGGESTIONS = [",
|
|
7351
|
+
...SUGGESTIONS.map(({ title, prompt }) => ` { title: ${JSON.stringify(title)}, prompt: ${JSON.stringify(prompt)} },`),
|
|
7352
|
+
"];"
|
|
7353
|
+
].join("\n");
|
|
7354
|
+
var SERVER_HEAD = String.raw`/**
|
|
7355
|
+
* Team noticeboard: your first app on wirl.
|
|
7356
|
+
*
|
|
7357
|
+
* Your team posts short notes here, tags them, pins the ones everyone should
|
|
7358
|
+
* see, and edits or removes their own. Every night a schedule archives old
|
|
7359
|
+
* notes nobody pinned. The page also explains itself: a "How this works on
|
|
7360
|
+
* wirl" panel, one row per feature, each linking to that page of the app on
|
|
7361
|
+
* app.wirl.dev, and a few changes to ask your coding agent for next.
|
|
7362
|
+
*
|
|
7363
|
+
* The whole app is this one file:
|
|
7364
|
+
* - The page is HTML this module returns: plain forms, no script.
|
|
7365
|
+
* - Notes live in env.DB, a SQLite database that belongs to this app alone.
|
|
7366
|
+
* Its tables are in migrations/0001_init.sql, and wirl applies each new
|
|
7367
|
+
* file in migrations/ before a version goes live. Put the next change in
|
|
7368
|
+
* migrations/0002_<what>.sql rather than editing that one.
|
|
7369
|
+
* - Who is asking arrives in headers wirl sets after signing them in, which
|
|
7370
|
+
* the browser cannot forge. Who may change a note is a line of code here:
|
|
7371
|
+
* a check in the page is only a suggestion to whoever holds the browser.
|
|
7372
|
+
* - The nightly schedule is declared in wirl.json. wirl sends it here as a
|
|
7373
|
+
* POST to /__wirl/schedule/tidy, and what it answers becomes the run's note.
|
|
7374
|
+
* - console.log lines appear on the app's Logs page, and in wirl logs.
|
|
7375
|
+
*/
|
|
7376
|
+
|
|
7377
|
+
const TITLE = "Team noticeboard";
|
|
7378
|
+
const DASHBOARD = "https://app.wirl.dev";
|
|
7379
|
+
const ARCHIVE_AFTER_DAYS = 30;
|
|
7380
|
+
const LONGEST_NOTE = 2000;
|
|
7381
|
+
|
|
7382
|
+
export default {
|
|
7383
|
+
async fetch(request, env) {
|
|
7384
|
+
const url = new URL(request.url);
|
|
7385
|
+
// One line per request, on the Logs page.
|
|
7386
|
+
console.log(request.method, url.pathname);
|
|
7387
|
+
|
|
7388
|
+
// The nightly tidy-up, sent by wirl at the time wirl.json gives. wirl
|
|
7389
|
+
// names the schedule in x-wirl-schedule as well as in the path, and a
|
|
7390
|
+
// request without that header is not one wirl sent.
|
|
7391
|
+
if (url.pathname.startsWith("/__wirl/schedule/")) {
|
|
7392
|
+
const name = url.pathname.slice("/__wirl/schedule/".length);
|
|
7393
|
+
const fromWirl = request.headers.get("x-wirl-schedule") === name;
|
|
7394
|
+
if (name === "tidy" && request.method === "POST" && fromWirl) return tidy(env);
|
|
7395
|
+
return text("No such schedule", 404);
|
|
7396
|
+
}
|
|
7397
|
+
|
|
7398
|
+
const me = visitor(request);
|
|
7399
|
+
|
|
7400
|
+
if (request.method === "POST") {
|
|
7401
|
+
// A form posted from another site is never one of yours.
|
|
7402
|
+
if (request.headers.get("sec-fetch-site") === "cross-site") return text("Refused: this form came from another site.", 403);
|
|
7403
|
+
if (me.id === "") return text("Sign in to change the board.", 403);
|
|
7404
|
+
let form;
|
|
7405
|
+
try {
|
|
7406
|
+
form = await request.formData();
|
|
7407
|
+
} catch {
|
|
7408
|
+
return text("Send this as a form.", 400);
|
|
7409
|
+
}
|
|
7410
|
+
return change(url.pathname, form, me, env);
|
|
6410
7411
|
}
|
|
7412
|
+
|
|
7413
|
+
if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) {
|
|
7414
|
+
return html(await page(request, url, me, env));
|
|
7415
|
+
}
|
|
7416
|
+
return text("Not found", 404);
|
|
7417
|
+
},
|
|
7418
|
+
};
|
|
7419
|
+
|
|
7420
|
+
// Set by wirl after it signed this person in. Never take identity from a form
|
|
7421
|
+
// or the query string: those say whatever the browser wants them to say. A
|
|
7422
|
+
// header carries only Latin-1 and a name can be anything, so wirl
|
|
7423
|
+
// percent-encodes the name; it is decoded here.
|
|
7424
|
+
function visitor(request) {
|
|
7425
|
+
const header = (name) => decode(request.headers.get(name) || "");
|
|
7426
|
+
return {
|
|
7427
|
+
id: header("x-wirl-user-id"),
|
|
7428
|
+
name: header("x-wirl-user-name") || header("x-wirl-user-email") || "someone",
|
|
7429
|
+
role: header("x-wirl-role"),
|
|
6411
7430
|
};
|
|
6412
7431
|
}
|
|
6413
7432
|
|
|
6414
|
-
//
|
|
6415
|
-
function
|
|
6416
|
-
|
|
6417
|
-
return
|
|
7433
|
+
// POST /notes, and POST /notes/<id>/<edit|pin|remove|restore>.
|
|
7434
|
+
async function change(path, form, me, env) {
|
|
7435
|
+
const [, first, id, action] = path.split("/");
|
|
7436
|
+
if (first !== "notes") return text("Not found", 404);
|
|
7437
|
+
|
|
7438
|
+
if (id === undefined) {
|
|
7439
|
+
const body = clean(form.get("body"), LONGEST_NOTE);
|
|
7440
|
+
if (body === "") return text("A note needs some words.", 400);
|
|
7441
|
+
const { meta } = await env.DB.prepare("insert into notes (body, tags, author_id, author_name) values (?, ?, ?, ?)")
|
|
7442
|
+
.bind(body, tags(form.get("tags")), me.id, me.name)
|
|
7443
|
+
.run();
|
|
7444
|
+
console.log("note", meta.last_row_id, "posted by", me.id);
|
|
7445
|
+
return home();
|
|
7446
|
+
}
|
|
7447
|
+
|
|
7448
|
+
const number = Number.parseInt(id, 10);
|
|
7449
|
+
if (String(number) !== id) return text("Not found", 404);
|
|
7450
|
+
const note = await env.DB.prepare("select id, author_id from notes where id = ?").bind(number).first();
|
|
7451
|
+
if (!note) return text("No such note", 404);
|
|
7452
|
+
|
|
7453
|
+
// The rule that matters, in code: authors change their own notes, and the
|
|
7454
|
+
// app's owner can change any. Anyone on the board can pin.
|
|
7455
|
+
const mine = note.author_id === me.id || me.role === "owner";
|
|
7456
|
+
|
|
7457
|
+
if (action === "pin") {
|
|
7458
|
+
await env.DB.prepare("update notes set pinned = 1 - pinned where id = ?").bind(number).run();
|
|
7459
|
+
console.log("note", number, "pin changed by", me.id);
|
|
7460
|
+
return home();
|
|
7461
|
+
}
|
|
7462
|
+
if (!mine) return text("Only the person who posted this note, or the app's owner, can change it.", 403);
|
|
7463
|
+
|
|
7464
|
+
if (action === "edit") {
|
|
7465
|
+
const body = clean(form.get("body"), LONGEST_NOTE);
|
|
7466
|
+
if (body === "") return text("A note needs some words.", 400);
|
|
7467
|
+
await env.DB.prepare("update notes set body = ?, tags = ? where id = ?").bind(body, tags(form.get("tags")), number).run();
|
|
7468
|
+
console.log("note", number, "edited by", me.id);
|
|
7469
|
+
return home();
|
|
7470
|
+
}
|
|
7471
|
+
if (action === "remove") {
|
|
7472
|
+
await env.DB.prepare("delete from notes where id = ?").bind(number).run();
|
|
7473
|
+
console.log("note", number, "removed by", me.id);
|
|
7474
|
+
return home();
|
|
7475
|
+
}
|
|
7476
|
+
if (action === "restore") {
|
|
7477
|
+
// Back on the board as if posted today, so tonight's tidy-up leaves it be.
|
|
7478
|
+
await env.DB.prepare("update notes set archived_at = null, created_at = datetime('now') where id = ?").bind(number).run();
|
|
7479
|
+
console.log("note", number, "put back by", me.id);
|
|
7480
|
+
return home("/?view=archived");
|
|
7481
|
+
}
|
|
7482
|
+
return text("Not found", 404);
|
|
6418
7483
|
}
|
|
6419
|
-
|
|
6420
|
-
|
|
6421
|
-
|
|
6422
|
-
|
|
6423
|
-
|
|
7484
|
+
|
|
7485
|
+
// The schedule: archive notes older than ARCHIVE_AFTER_DAYS that nobody
|
|
7486
|
+
// pinned, and keep a line saying so, which the page shows as the last run.
|
|
7487
|
+
async function tidy(env) {
|
|
7488
|
+
const { meta } = await env.DB.prepare(
|
|
7489
|
+
"update notes set archived_at = datetime('now') where archived_at is null and pinned = 0 and created_at < datetime('now', ?)",
|
|
7490
|
+
)
|
|
7491
|
+
.bind("-" + ARCHIVE_AFTER_DAYS + " days")
|
|
7492
|
+
.run();
|
|
7493
|
+
const note = "Archived " + meta.changes + (meta.changes === 1 ? " note" : " notes") + " older than " + ARCHIVE_AFTER_DAYS + " days.";
|
|
7494
|
+
await env.DB.prepare("insert into runs (schedule, note) values (?, ?)").bind("tidy", note).run();
|
|
7495
|
+
console.log("tidy:", note);
|
|
7496
|
+
return text(note);
|
|
7497
|
+
}
|
|
7498
|
+
|
|
7499
|
+
async function page(request, url, me, env) {
|
|
7500
|
+
const tag = tags(url.searchParams.get("tag")).split(",")[0];
|
|
7501
|
+
const archived = url.searchParams.get("view") === "archived";
|
|
7502
|
+
let sql = "select id, body, tags, pinned, author_id, author_name, created_at from notes where archived_at is " + (archived ? "not null" : "null");
|
|
7503
|
+
const binds = [];
|
|
7504
|
+
if (tag !== "") {
|
|
7505
|
+
sql += " and (',' || tags || ',') like ?";
|
|
7506
|
+
binds.push("%," + tag + ",%");
|
|
7507
|
+
}
|
|
7508
|
+
sql += " order by pinned desc, id desc limit 200";
|
|
7509
|
+
const notes = (await env.DB.prepare(sql).bind(...binds).all()).results;
|
|
7510
|
+
const archivedCount = await env.DB.prepare("select count(*) as n from notes where archived_at is not null").first("n");
|
|
7511
|
+
const lastRun = await env.DB.prepare("select note, ran_at from runs where schedule = 'tidy' order by id desc limit 1").first();
|
|
7512
|
+
|
|
7513
|
+
const shown = archived
|
|
7514
|
+
? "<p class='dim'>Notes the nightly tidy-up moved off the board. <a href='/'>Back to the board</a></p>"
|
|
7515
|
+
: form() +
|
|
7516
|
+
"<p class='dim filters'>" +
|
|
7517
|
+
(tag === "" ? "All notes" : "Tagged <b>" + escape(tag) + "</b> · <a href='/'>show all</a>") +
|
|
7518
|
+
" · <a href='/?view=archived'>Archived (" + archivedCount + ")</a></p>";
|
|
7519
|
+
|
|
7520
|
+
return [
|
|
7521
|
+
"<!doctype html>",
|
|
7522
|
+
"<html lang='en'>",
|
|
7523
|
+
"<meta charset='utf-8'>",
|
|
7524
|
+
"<meta name='viewport' content='width=device-width, initial-scale=1'>",
|
|
7525
|
+
"<title>" + TITLE + "</title>",
|
|
7526
|
+
"<style>" + STYLE + "</style>",
|
|
7527
|
+
"<main>",
|
|
7528
|
+
"<header><h1>" + TITLE + "</h1>",
|
|
7529
|
+
"<p class='dim'>Your first app on wirl. Signed in as <b>" + escape(me.name) + "</b>" + (me.role ? " (" + escape(me.role) + ")" : "") + ".</p></header>",
|
|
7530
|
+
shown,
|
|
7531
|
+
"<ul class='notes'>" + (notes.length === 0 ? "<li class='dim'>Nothing here yet.</li>" : notes.map((note) => card(note, me, archived)).join("")) + "</ul>",
|
|
7532
|
+
panel(request, me, lastRun),
|
|
7533
|
+
suggestions(),
|
|
7534
|
+
"</main>",
|
|
7535
|
+
"</html>",
|
|
7536
|
+
].join("\n");
|
|
7537
|
+
}
|
|
7538
|
+
|
|
7539
|
+
function form() {
|
|
7540
|
+
return [
|
|
7541
|
+
"<form class='add' method='post' action='/notes'>",
|
|
7542
|
+
"<textarea name='body' rows='3' maxlength='" + LONGEST_NOTE + "' required placeholder='Post a note for the team'></textarea>",
|
|
7543
|
+
"<input name='tags' placeholder='Tags, separated by commas'>",
|
|
7544
|
+
"<button class='primary'>Post</button>",
|
|
7545
|
+
"</form>",
|
|
7546
|
+
].join("");
|
|
7547
|
+
}
|
|
7548
|
+
|
|
7549
|
+
function card(note, me, archived) {
|
|
7550
|
+
const mine = note.author_id === me.id || me.role === "owner";
|
|
7551
|
+
const action = (what, label) =>
|
|
7552
|
+
"<form method='post' action='/notes/" + note.id + "/" + what + "'><button>" + label + "</button></form>";
|
|
7553
|
+
const tagLinks = note.tags
|
|
7554
|
+
.split(",")
|
|
7555
|
+
.filter((tag) => tag !== "")
|
|
7556
|
+
.map((tag) => "<a class='tag' href='/?tag=" + encodeURIComponent(tag) + "'>#" + escape(tag) + "</a>")
|
|
7557
|
+
.join(" ");
|
|
7558
|
+
const controls = archived
|
|
7559
|
+
? (mine ? action("restore", "Put back") : "")
|
|
7560
|
+
: action("pin", note.pinned ? "Unpin" : "Pin") +
|
|
7561
|
+
(mine
|
|
7562
|
+
? "<details><summary>Edit</summary><form class='edit' method='post' action='/notes/" + note.id + "/edit'>" +
|
|
7563
|
+
"<textarea name='body' rows='3' maxlength='" + LONGEST_NOTE + "' required>" + escape(note.body) + "</textarea>" +
|
|
7564
|
+
"<input name='tags' value='" + escape(note.tags.split(",").join(", ")) + "'>" +
|
|
7565
|
+
"<button class='primary'>Save</button></form></details>" +
|
|
7566
|
+
action("remove", "Remove")
|
|
7567
|
+
: "");
|
|
7568
|
+
return (
|
|
7569
|
+
"<li class='note" + (note.pinned && !archived ? " pinned" : "") + "'>" +
|
|
7570
|
+
"<p>" + escape(note.body) + "</p>" +
|
|
7571
|
+
"<div class='meta dim'>" + (note.pinned && !archived ? "<b>Pinned</b> · " : "") +
|
|
7572
|
+
escape(note.author_name || "someone") + " · " + escape(note.created_at.slice(0, 10)) + (tagLinks ? " · " + tagLinks : "") + "</div>" +
|
|
7573
|
+
"<div class='actions'>" + controls + "</div>" +
|
|
7574
|
+
"</li>"
|
|
7575
|
+
);
|
|
7576
|
+
}
|
|
7577
|
+
|
|
7578
|
+
// The app's page on the dashboard, from the address it was opened at:
|
|
7579
|
+
// <org>--<app>.wirl.run, or <org>--<app>--<preview>.wirl.run for a preview.
|
|
7580
|
+
// Under wirl dev the address is 127.0.0.1, which names no app: null.
|
|
7581
|
+
function dashboard(request) {
|
|
7582
|
+
const label = new URL(request.url).hostname.split(".")[0];
|
|
7583
|
+
const parts = label.split("--");
|
|
7584
|
+
if (parts.length < 2) return null;
|
|
7585
|
+
const org = request.headers.get("x-wirl-org") || parts[0];
|
|
7586
|
+
return DASHBOARD + "/apps/" + encodeURIComponent(org) + "/" + encodeURIComponent(parts[1]);
|
|
7587
|
+
}
|
|
7588
|
+
|
|
7589
|
+
function panel(request, me, lastRun) {
|
|
7590
|
+
const base = dashboard(request);
|
|
7591
|
+
const last = lastRun ? "Last run " + lastRun.ran_at.slice(0, 16) + " UTC: " + lastRun.note : "Not run yet.";
|
|
7592
|
+
// One row per feature: what it is, what it does in this app, and its page.
|
|
7593
|
+
const rows = [
|
|
7594
|
+
["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"],
|
|
7595
|
+
["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"],
|
|
7596
|
+
["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"],
|
|
7597
|
+
["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"],
|
|
7598
|
+
["Versions", "Each wirl deploy is a new version, and the earlier ones stay: going back to one takes a click.", "/deployments", "Deployments"],
|
|
7599
|
+
["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"],
|
|
7600
|
+
["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"],
|
|
7601
|
+
["Settings", "Describe the app for your team's list of tools, hand it to someone else, or delete it.", "/settings", "Settings"],
|
|
7602
|
+
];
|
|
7603
|
+
return [
|
|
7604
|
+
"<section><h2>How this works on wirl</h2>",
|
|
7605
|
+
"<table>",
|
|
7606
|
+
...rows.map(([feature, what, path, label]) =>
|
|
7607
|
+
"<tr><td>" + feature + "</td><td>" + escape(what) + "</td><td>" +
|
|
7608
|
+
(base === null ? "<span class='dim'>" + label + "</span>" : "<a href='" + escape(base + path) + "'>" + label + "</a>") +
|
|
7609
|
+
"</td></tr>",
|
|
7610
|
+
),
|
|
7611
|
+
"</table>",
|
|
7612
|
+
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>" : "",
|
|
7613
|
+
"</section>",
|
|
7614
|
+
].join("\n");
|
|
7615
|
+
}
|
|
7616
|
+
|
|
7617
|
+
function suggestions() {
|
|
7618
|
+
return [
|
|
7619
|
+
"<section><h2>Ask your agent for a first change</h2>",
|
|
7620
|
+
"<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>",
|
|
7621
|
+
...SUGGESTIONS.map((each) => "<details><summary>" + escape(each.title) + "</summary><pre>" + escape(each.prompt) + "</pre></details>"),
|
|
7622
|
+
"</section>",
|
|
7623
|
+
].join("\n");
|
|
7624
|
+
}
|
|
7625
|
+
|
|
7626
|
+
`;
|
|
7627
|
+
var SERVER_TAIL = String.raw`
|
|
7628
|
+
|
|
7629
|
+
const STYLE = [
|
|
7630
|
+
":root { color-scheme: light dark; --ink: #1d1d1b; --dim: #66655f; --line: #e2e0d9; --card: #ffffff; --bg: #f7f6f2; --pin: #fff3bf; --accent: #2b6b5a; }",
|
|
7631
|
+
"@media (prefers-color-scheme: dark) { :root { --ink: #ecebe6; --dim: #a6a59f; --line: #36352f; --card: #1f1f1c; --bg: #161614; --pin: #3b3420; --accent: #8fd1bd; } }",
|
|
7632
|
+
"body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, sans-serif; }",
|
|
7633
|
+
"main { max-width: 760px; margin: 0 auto; padding: 24px 16px 48px; }",
|
|
7634
|
+
"h1 { font-size: 24px; margin: 0 0 4px; } h2 { font-size: 17px; margin: 36px 0 8px; }",
|
|
7635
|
+
"a { color: var(--accent); } .dim { color: var(--dim); }",
|
|
7636
|
+
"form.add, form.edit { display: grid; gap: 8px; margin: 16px 0 8px; }",
|
|
7637
|
+
"textarea, input, button { font: inherit; color: inherit; padding: 7px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--card); }",
|
|
7638
|
+
"button { cursor: pointer; } button.primary { justify-self: start; background: var(--accent); border-color: var(--accent); color: var(--bg); }",
|
|
7639
|
+
"ul.notes { list-style: none; padding: 0; display: grid; gap: 10px; }",
|
|
7640
|
+
".note { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }",
|
|
7641
|
+
".note.pinned { background: var(--pin); }",
|
|
7642
|
+
".note p { margin: 0 0 6px; white-space: pre-wrap; overflow-wrap: anywhere; }",
|
|
7643
|
+
".meta, .actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 13px; }",
|
|
7644
|
+
".actions { margin-top: 8px; } .actions form { margin: 0; } .actions details { flex-basis: 100%; order: 3; }",
|
|
7645
|
+
".tag { text-decoration: none; }",
|
|
7646
|
+
"table { width: 100%; border-collapse: collapse; font-size: 14px; }",
|
|
7647
|
+
"td { border-top: 1px solid var(--line); padding: 8px 6px; vertical-align: top; }",
|
|
7648
|
+
"td:first-child { font-weight: 600; }",
|
|
7649
|
+
"pre { white-space: pre-wrap; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 10px; font-size: 13px; }",
|
|
7650
|
+
"details { margin: 6px 0; } summary { cursor: pointer; }",
|
|
7651
|
+
].join("\n");
|
|
7652
|
+
|
|
7653
|
+
const home = (to = "/") => new Response(null, { status: 303, headers: { location: to } });
|
|
7654
|
+
|
|
7655
|
+
const text = (body, status = 200) => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
7656
|
+
|
|
7657
|
+
const html = (markup) => new Response(markup, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
7658
|
+
|
|
7659
|
+
// Escapes text for HTML, in content and in quoted attribute values alike.
|
|
7660
|
+
const escape = (value) =>
|
|
7661
|
+
String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
7662
|
+
|
|
7663
|
+
const decode = (value) => {
|
|
7664
|
+
try {
|
|
7665
|
+
return decodeURIComponent(value);
|
|
7666
|
+
} catch {
|
|
7667
|
+
return value;
|
|
7668
|
+
}
|
|
7669
|
+
};
|
|
7670
|
+
|
|
7671
|
+
// A form field as trimmed text, at most max characters.
|
|
7672
|
+
const clean = (value, max) => (typeof value === "string" ? value.trim().slice(0, max) : "");
|
|
7673
|
+
|
|
7674
|
+
// "Team, Stand-up" becomes "team,stand-up": lowercase words, letters, digits
|
|
7675
|
+
// and hyphens only, so a tag is safe in a link and in the LIKE above.
|
|
7676
|
+
const tags = (value) =>
|
|
7677
|
+
clean(value, 200)
|
|
7678
|
+
.split(",")
|
|
7679
|
+
.map((tag) => tag.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((word) => word !== "").join("-"))
|
|
7680
|
+
.filter((tag, i, all) => tag !== "" && all.indexOf(tag) === i)
|
|
7681
|
+
.slice(0, 8)
|
|
7682
|
+
.join(",");
|
|
7683
|
+
`;
|
|
7684
|
+
var SERVER_MODULE = `${SERVER_HEAD}${SUGGESTIONS_SOURCE}${SERVER_TAIL}`;
|
|
7685
|
+
var STARTER_MIGRATION = [
|
|
7686
|
+
"-- The noticeboard's tables. wirl applies each new file here, in order, before a version goes live.",
|
|
7687
|
+
"-- Make the next change in 0002_<what>.sql; never edit a file that has been deployed.",
|
|
7688
|
+
"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)",
|
|
7689
|
+
"create table runs (id integer primary key, schedule text not null, note text not null, ran_at text not null default (datetime('now')))",
|
|
7690
|
+
"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')",
|
|
7691
|
+
""
|
|
7692
|
+
].join("\n");
|
|
7693
|
+
function starterManifest(app, org) {
|
|
7694
|
+
const manifest = {
|
|
7695
|
+
app,
|
|
7696
|
+
...org ? { org } : {},
|
|
7697
|
+
dir: ".",
|
|
7698
|
+
server: "server.mjs",
|
|
7699
|
+
description: "A team noticeboard: your first app on wirl",
|
|
7700
|
+
visibility: "private",
|
|
7701
|
+
schedules: [{ name: "tidy", cron: "0 2 * * *", timezone: "UTC" }]
|
|
7702
|
+
};
|
|
7703
|
+
return `${JSON.stringify(manifest, null, 2)}
|
|
7704
|
+
`;
|
|
6424
7705
|
}
|
|
7706
|
+
var README = [
|
|
7707
|
+
"# Team noticeboard",
|
|
7708
|
+
"",
|
|
7709
|
+
"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.",
|
|
7710
|
+
"",
|
|
7711
|
+
'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.',
|
|
7712
|
+
"",
|
|
7713
|
+
"## What is where",
|
|
7714
|
+
"",
|
|
7715
|
+
"- `server.mjs`: the whole app, one module with one `fetch`. The page, the forms, who may change a note, and the nightly tidy-up.",
|
|
7716
|
+
"- `migrations/0001_init.sql`: the tables, one statement a line. Put the next change in `migrations/0002_<what>.sql`.",
|
|
7717
|
+
"- `wirl.json`: the app's name, its module, its nightly schedule, and that it is created private.",
|
|
7718
|
+
"- `.claude/skills/wirl/SKILL.md`: how a coding agent works with wirl.",
|
|
7719
|
+
"",
|
|
7720
|
+
"## Run it on this computer",
|
|
7721
|
+
"",
|
|
7722
|
+
` ${WIRL_BIN} dev`,
|
|
7723
|
+
"",
|
|
7724
|
+
"It prints the address to open, and a `curl` line that runs the nightly tidy-up now.",
|
|
7725
|
+
"",
|
|
7726
|
+
"## Changes to ask your agent for",
|
|
7727
|
+
"",
|
|
7728
|
+
"Each is a whole prompt: paste it to your coding agent as it is.",
|
|
7729
|
+
"",
|
|
7730
|
+
...SUGGESTIONS.flatMap(({ title, prompt }) => [`### ${title}`, "", `> ${prompt}`, ""])
|
|
7731
|
+
].join("\n");
|
|
7732
|
+
var STARTER_FILES = {
|
|
7733
|
+
"server.mjs": SERVER_MODULE,
|
|
7734
|
+
"migrations/0001_init.sql": STARTER_MIGRATION,
|
|
7735
|
+
"README.md": README,
|
|
7736
|
+
"wirl.json": starterManifest("starter")
|
|
7737
|
+
};
|
|
6425
7738
|
|
|
6426
7739
|
// packages/cli/src/core.ts
|
|
6427
7740
|
function credentialsPath(env = process.env) {
|
|
@@ -6765,23 +8078,23 @@ var INIT_MIGRATION = [
|
|
|
6765
8078
|
""
|
|
6766
8079
|
].join("\n");
|
|
6767
8080
|
var INIT_MIGRATION_PATH = `${MIGRATIONS_DIR}/${ADOPTED_FILE}`;
|
|
6768
|
-
async function refuseInitMigrations(dir) {
|
|
8081
|
+
async function refuseInitMigrations(dir, flag) {
|
|
6769
8082
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
6770
8083
|
if (entry.name.toLowerCase() !== MIGRATIONS_DIR) continue;
|
|
6771
8084
|
if (entry.isSymbolicLink()) {
|
|
6772
|
-
throw new CliError(`${entry.name}/ is a symbolic link, and wirl writes into no links: make migrations/ a real folder, then run wirl init
|
|
8085
|
+
throw new CliError(`${entry.name}/ is a symbolic link, and wirl writes into no links: make migrations/ a real folder, then run wirl init ${flag} again.`);
|
|
6773
8086
|
}
|
|
6774
8087
|
if (entry.name !== MIGRATIONS_DIR) {
|
|
6775
|
-
throw new CliError(`${entry.name}/ is spelled differently from migrations/: wirl reads migrations/ in lowercase only. Rename it, then run wirl init
|
|
8088
|
+
throw new CliError(`${entry.name}/ is spelled differently from migrations/: wirl reads migrations/ in lowercase only. Rename it, then run wirl init ${flag} again.`);
|
|
6776
8089
|
}
|
|
6777
8090
|
if (!entry.isDirectory()) {
|
|
6778
8091
|
throw new CliError(
|
|
6779
|
-
`migrations is a file, and wirl init
|
|
8092
|
+
`migrations is a file, and wirl init ${flag} writes ${INIT_MIGRATION_PATH} into a folder of that name. Move it aside, then run wirl init ${flag} again.`
|
|
6780
8093
|
);
|
|
6781
8094
|
}
|
|
6782
8095
|
const names = await readdir(join5(dir, entry.name));
|
|
6783
8096
|
if (names.some((name) => name.toLowerCase().endsWith(".sql"))) {
|
|
6784
|
-
throw new CliError(`migrations/ already holds .sql files; wirl init
|
|
8097
|
+
throw new CliError(`migrations/ already holds .sql files; wirl init ${flag} writes ${INIT_MIGRATION_PATH} into a folder with none.`);
|
|
6785
8098
|
}
|
|
6786
8099
|
}
|
|
6787
8100
|
}
|
|
@@ -6793,23 +8106,9 @@ var SKILL_RUN = NPM_PUBLISHED ? `Run the CLI as \`${WIRL_BIN} <command>\`, which
|
|
|
6793
8106
|
` : "";
|
|
6794
8107
|
var SKILL_FALLBACK = hasFallback(SKILL_INSTALL) ? `- ${fallbackWhen(namedControlPlane(SKILL_ORIGIN))} install the CLI from the control plane instead with \`npm install -g ${SKILL_INSTALL.tarball.cli}\`, run plain \`wirl\` where this file says \`${WIRL_BIN}\`, and name \`${SKILL_INSTALL.tarball.mcp}\` in place of \`${SKILL_INSTALL.mcp}\`.
|
|
6795
8108
|
` : "";
|
|
6796
|
-
var SKILL_MARKDOWN =
|
|
6797
|
-
|
|
6798
|
-
|
|
6799
|
-
---
|
|
6800
|
-
|
|
6801
|
-
# wirl
|
|
6802
|
-
|
|
6803
|
-
A new app is visible to everyone in the org when the org came from a
|
|
6804
|
-
company email domain; in any other org, including a personal one or one
|
|
6805
|
-
made with \`wirl orgs create\`, it starts private. Deploys are one
|
|
6806
|
-
command; sharing is by email; a third-party API is declared, never pasted.
|
|
6807
|
-
|
|
6808
|
-
Before you build, fetch https://docs.wirl.dev/llms-full.txt: every page of the docs as one Markdown file, so there is no need to read the site page by page.
|
|
6809
|
-
|
|
6810
|
-
## Commands
|
|
6811
|
-
|
|
6812
|
-
${SKILL_RUN}- Connect first, if the agent supports MCP: \`claude mcp add --transport http wirl https://app.wirl.dev/mcp\` opens the browser once to sign in, and needs no install. No HTTP MCP support? \`claude mcp add wirl -- npx -y ${SKILL_INSTALL.mcp}\` does the same, running locally. Neither available: \`${WIRL_BIN} login\` prints a link and a code; the person approves in the browser. Do this once, when any command says you are not signed in.
|
|
8109
|
+
var SKILL_MARKDOWN = composeSkill({
|
|
8110
|
+
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.`,
|
|
8111
|
+
commands: `${SKILL_RUN}- Connect first, if the agent supports MCP: \`claude mcp add --transport http wirl https://app.wirl.dev/mcp\` needs no install. ${CLAUDE_CODE_SIGN_IN} No HTTP MCP support? \`claude mcp add wirl -- npx -y ${SKILL_INSTALL.mcp}\` runs the same tools locally. ${CLAUDE_CODE_LOCAL_SIGN_IN} Neither available: \`${WIRL_BIN} login\` prints a link and a code; the person approves in the browser. Do this once, when any command says you are not signed in.
|
|
6813
8112
|
- \`${WIRL_BIN} deploy\` \u2014 deploys this directory. Prints the URL and who can open it.
|
|
6814
8113
|
- \`${WIRL_BIN} share someone@company.com\` \u2014 that person can open it. Add \`--role editor\` so they can deploy too.
|
|
6815
8114
|
- \`${WIRL_BIN} dev\` \u2014 runs the app here as it will run deployed, signed in as a stub user.
|
|
@@ -6818,33 +8117,8 @@ ${SKILL_RUN}- Connect first, if the agent supports MCP: \`claude mcp add --trans
|
|
|
6818
8117
|
- \`${WIRL_BIN} migrations\` \u2014 what this app's database has applied, read only.
|
|
6819
8118
|
- \`${WIRL_BIN} migrations adopt\` \u2014 once, for an app whose tables were made in code: writes \`migrations/0001_init.sql\` from the live database's shape.
|
|
6820
8119
|
- \`${WIRL_BIN} --help\` \u2014 everything else: orgs, members, connections, move, transfer, rollback, flows and runs.
|
|
6821
|
-
${SKILL_FALLBACK}
|
|
6822
|
-
|
|
6823
|
-
|
|
6824
|
-
1. **Never ask for or paste an API key.** Declare the connection in \`wirl.json\` and deploy. Needs no key at all (a public API)? Declare it with "kind": "none" and its hosts. If the key is missing, the deploy prints a link to the app's keys page; show the person that link. Locally, keys live in \`.wirl/keys.json\`, which git ignores. To try a vendor call locally without the key, map its connection to a server of your own in \`.wirl/mocks.json\`, like \`{ "anthropic": "http://127.0.0.1:4010" }\`: \`wirl dev\` then sends the calls to that connection's hosts there instead (\`wirl dev --help\` has the rest).
|
|
6825
|
-
2. **Call third-party APIs by their real URL** (\`fetch("https://api.hubspot.com/...")\`). wirl attaches the key on the way out, deployed and under \`wirl dev\` alike.
|
|
6826
|
-
3. If a command names several organisations and asks which, ask the person; pass \`--org\` or put \`"org"\` in \`wirl.json\`.
|
|
6827
|
-
4. To keep a file out of the deploy, list it in a \`.wirlignore\` at the project root (gitignore-style lines; no \`!\` negation).
|
|
6828
|
-
5. **A file that can lose data waits for a person.** A deploy that would apply a destructive file (one wirl.json's \`"destructive"\` list must name) to the live database stops and names the page where a person approves each one. Show the person those pages, and deploy again once they say they approved. If \`${WIRL_BIN} deploy\` asks you to type a file's name, stop it and show the page instead, or deploy with \`--json\`, which never asks. Never type the name yourself or send the confirmation: that is the person's, and no flag skips it.
|
|
6829
|
-
|
|
6830
|
-
## wirl.json
|
|
6831
|
-
|
|
6832
|
-
\`\`\`json
|
|
6833
|
-
{ "app": "crm-sync", "server": "server.mjs", "connections": [{ "slug": "hubspot", "hosts": ["api.hubspot.com"] }] }
|
|
6834
|
-
\`\`\`
|
|
6835
|
-
|
|
6836
|
-
## What runs on wirl
|
|
6837
|
-
|
|
6838
|
-
${BUILD_CONTRACT}
|
|
6839
|
-
|
|
6840
|
-
No blob storage: files go in \`env.DB\`, at most 2,000,000 bytes a value or row (the limit Cloudflare publishes for D1), so store a larger file in chunks, one row each, a few MB to a batch. \`wirl dev\` refuses a single bound value over 2,000,000 bytes, but a row's total, a value grown in SQL, or a batch bigger than one call to D1 carries can still pass locally and fail deployed.
|
|
6841
|
-
|
|
6842
|
-
A static folder maps a path to one file exactly: a path ending in \`/\` serves the \`index.html\` in that folder, \`/about\` never finds \`about.html\`, and a path that names no file goes to the module (with \`"spa": true\`, a page navigation gets \`index.html\` instead; in an app with no module, the top \`index.html\`, or a 404 for a missing file with an extension). Next.js runs only as a static export: \`output: "export"\` and \`trailingSlash: true\` in \`next.config\` make \`next build\` write \`out/\`, which goes under \`"static"\`; \`next start\` cannot run, and API routes move into the module.
|
|
6843
|
-
|
|
6844
|
-
## Flows
|
|
6845
|
-
|
|
6846
|
-
${FLOWS_CONTRACT}
|
|
6847
|
-
`;
|
|
8120
|
+
${SKILL_FALLBACK}`
|
|
8121
|
+
});
|
|
6848
8122
|
async function writeSkill(dir) {
|
|
6849
8123
|
const path = join5(dir, SKILL_PATH);
|
|
6850
8124
|
const existing = await readFile3(path, "utf8").catch(() => null);
|
|
@@ -6865,23 +8139,52 @@ async function pulledDevCommand(dir) {
|
|
|
6865
8139
|
}
|
|
6866
8140
|
return `${needsInstall ? "npm install && " : ""}${WIRL_BIN} dev`;
|
|
6867
8141
|
}
|
|
8142
|
+
function starterFile(path) {
|
|
8143
|
+
const text = STARTER_FILES[path];
|
|
8144
|
+
if (text === void 0) throw new Error(`starter.ts has no ${path}`);
|
|
8145
|
+
return text;
|
|
8146
|
+
}
|
|
8147
|
+
function moduleKit(options) {
|
|
8148
|
+
if (options.starter) {
|
|
8149
|
+
return {
|
|
8150
|
+
flag: "--starter",
|
|
8151
|
+
files: ["server.mjs", INIT_MIGRATION_PATH, "README.md"].map((path) => [path, starterFile(path)]),
|
|
8152
|
+
hint: "Run wirl init --starter in a new folder, or move the existing file."
|
|
8153
|
+
};
|
|
8154
|
+
}
|
|
8155
|
+
if (options.server) {
|
|
8156
|
+
return {
|
|
8157
|
+
flag: "--server",
|
|
8158
|
+
files: [
|
|
8159
|
+
["server.mjs", SERVER_TEMPLATE],
|
|
8160
|
+
[INIT_MIGRATION_PATH, INIT_MIGRATION]
|
|
8161
|
+
],
|
|
8162
|
+
hint: "Remove --server, or move the existing file."
|
|
8163
|
+
};
|
|
8164
|
+
}
|
|
8165
|
+
return null;
|
|
8166
|
+
}
|
|
6868
8167
|
async function scaffold(dir, options) {
|
|
8168
|
+
if (options.server && options.starter) throw new CliError(STARTER_CONFLICT);
|
|
8169
|
+
const kit = moduleKit(options);
|
|
6869
8170
|
const written = [];
|
|
6870
8171
|
await mkdir3(dir, { recursive: true });
|
|
6871
|
-
if (
|
|
6872
|
-
const
|
|
6873
|
-
|
|
6874
|
-
|
|
6875
|
-
|
|
6876
|
-
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
|
|
6881
|
-
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
|
|
8172
|
+
if (kit !== null) {
|
|
8173
|
+
for (const [name] of kit.files) {
|
|
8174
|
+
if (name === INIT_MIGRATION_PATH) continue;
|
|
8175
|
+
try {
|
|
8176
|
+
await readFile3(join5(dir, name));
|
|
8177
|
+
throw new CliError(`${name} already exists in ${dir}.`, kit.hint);
|
|
8178
|
+
} catch (error) {
|
|
8179
|
+
if (error instanceof CliError) throw error;
|
|
8180
|
+
}
|
|
8181
|
+
}
|
|
8182
|
+
await refuseInitMigrations(dir, kit.flag);
|
|
8183
|
+
for (const [name, text] of kit.files) {
|
|
8184
|
+
await mkdir3(dirname3(join5(dir, name)), { recursive: true });
|
|
8185
|
+
await writeFile2(join5(dir, name), text, { encoding: "utf8", flag: "wx" });
|
|
8186
|
+
written.push(name);
|
|
8187
|
+
}
|
|
6885
8188
|
const pkg = join5(dir, "package.json");
|
|
6886
8189
|
if (await readFile3(pkg, "utf8").catch(() => null) === null) {
|
|
6887
8190
|
await writeFile2(pkg, `${JSON.stringify({ name: options.app, private: true, type: "module" }, null, 2)}
|
|
@@ -6895,8 +8198,9 @@ async function scaffold(dir, options) {
|
|
|
6895
8198
|
dir: ".",
|
|
6896
8199
|
...options.server ? { server: "server.mjs" } : {}
|
|
6897
8200
|
};
|
|
6898
|
-
|
|
6899
|
-
|
|
8201
|
+
const manifestText = kit?.flag === "--starter" ? starterManifest(options.app, options.org) : `${JSON.stringify(manifest, null, 2)}
|
|
8202
|
+
`;
|
|
8203
|
+
await writeFile2(join5(dir, MANIFEST_NAME), manifestText, "utf8");
|
|
6900
8204
|
const gitignore = join5(dir, ".gitignore");
|
|
6901
8205
|
const existing = await readFile3(gitignore, "utf8").catch(() => "");
|
|
6902
8206
|
const wanted = [".wirl/", ".env"].filter((line) => !existing.split("\n").includes(line));
|
|
@@ -7165,7 +8469,7 @@ async function callApi(creds, path, options = {}) {
|
|
|
7165
8469
|
const body = parsed ?? {};
|
|
7166
8470
|
const refusal = apiRefusal(parsed, response);
|
|
7167
8471
|
if (response.status === 403 && body.error === "insufficient_scope" && body.message) {
|
|
7168
|
-
throw new CliError(body.message,
|
|
8472
|
+
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);
|
|
7169
8473
|
}
|
|
7170
8474
|
if (response.status === 403 && (body.error === "person_only" || body.error === "bad_confirmation") && body.message) {
|
|
7171
8475
|
throw new CliError(body.message, void 0, refusal);
|
|
@@ -7216,23 +8520,51 @@ async function callApiBytes(creds, path, options = {}) {
|
|
|
7216
8520
|
}
|
|
7217
8521
|
const declared = Number(response.headers.get("content-length") ?? 0);
|
|
7218
8522
|
if (declared > MAX_PULL_BYTES) throw new CliError(`That is ${Math.round(declared / 1048576)} MB, more than wirl will pull.`);
|
|
7219
|
-
|
|
8523
|
+
let bytes;
|
|
8524
|
+
try {
|
|
8525
|
+
bytes = new Uint8Array(await response.arrayBuffer());
|
|
8526
|
+
} catch {
|
|
8527
|
+
throw new CliError(PULL_CUT, void 0, { code: "pull_cut" });
|
|
8528
|
+
}
|
|
7220
8529
|
if (bytes.byteLength > MAX_PULL_BYTES) throw new CliError("The download was larger than wirl will pull.");
|
|
7221
8530
|
return bytes;
|
|
7222
8531
|
}
|
|
8532
|
+
var PULL_CUT = "The pull was cut before it finished, so nothing was written. Run it again.";
|
|
8533
|
+
var pullShort = (dir) => `The pull's archive is cut short or damaged, so nothing was written to ${dir}. Run it again.`;
|
|
7223
8534
|
var MAX_PULL_BYTES = 256 * 1048576;
|
|
7224
8535
|
var pullApp = (creds, org, app, options = {}) => callApiBytes(creds, `/api/v1/apps/${seg(org)}/${seg(app)}/pull`, options.fetchImpl ? { fetchImpl: options.fetchImpl } : {});
|
|
8536
|
+
function isWholeArchive(archive) {
|
|
8537
|
+
return new Promise((resolve7, reject) => {
|
|
8538
|
+
let sound = true;
|
|
8539
|
+
let ended2 = false;
|
|
8540
|
+
const parser = new TarParser({ onReadEntry: (entry) => void entry.resume() });
|
|
8541
|
+
parser.on("warn", (code) => {
|
|
8542
|
+
if (code === "TAR_BAD_ARCHIVE" || code === "TAR_ENTRY_INVALID") sound = false;
|
|
8543
|
+
});
|
|
8544
|
+
parser.on("eof", () => {
|
|
8545
|
+
ended2 = true;
|
|
8546
|
+
});
|
|
8547
|
+
parser.on("error", (error) => typeof error.code === "string" && error.code.startsWith("Z_") ? resolve7(false) : reject(error));
|
|
8548
|
+
parser.on("end", () => resolve7(sound && ended2));
|
|
8549
|
+
parser.end(archive);
|
|
8550
|
+
});
|
|
8551
|
+
}
|
|
7225
8552
|
async function unpackPull(bytes, dir, options = {}) {
|
|
7226
|
-
await
|
|
7227
|
-
|
|
8553
|
+
const existing = await readdir(dir).catch((error) => {
|
|
8554
|
+
if (error.code === "ENOENT") return [];
|
|
8555
|
+
throw error;
|
|
8556
|
+
});
|
|
7228
8557
|
if (existing.length > 0 && !options.force) {
|
|
7229
8558
|
throw new CliError(`${dir} is not empty.`, "Pull into a new directory, or pass --force to overwrite.");
|
|
7230
8559
|
}
|
|
8560
|
+
const archive = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
8561
|
+
if (!await isWholeArchive(archive)) throw new CliError(pullShort(dir), void 0, { code: "pull_cut" });
|
|
8562
|
+
await mkdir3(dir, { recursive: true });
|
|
7231
8563
|
await new Promise((resolve7, reject) => {
|
|
7232
8564
|
const extractor = tarExtract({ cwd: dir });
|
|
7233
8565
|
extractor.on("error", reject);
|
|
7234
8566
|
extractor.on("finish", resolve7);
|
|
7235
|
-
extractor.end(
|
|
8567
|
+
extractor.end(archive);
|
|
7236
8568
|
});
|
|
7237
8569
|
const written = [];
|
|
7238
8570
|
await collect(dir, dir, written);
|
|
@@ -7338,6 +8670,11 @@ function manifestWarningLines(manifest) {
|
|
|
7338
8670
|
const parsed = parseManifest(manifest);
|
|
7339
8671
|
return parsed.ok ? parsed.warnings.map((warning) => `Warning: ${warning}`) : [];
|
|
7340
8672
|
}
|
|
8673
|
+
function manifestVisibility(manifest) {
|
|
8674
|
+
if (manifest === null || manifest === void 0) return void 0;
|
|
8675
|
+
const parsed = parseManifest(manifest);
|
|
8676
|
+
return parsed.ok ? parsed.manifest.visibility : void 0;
|
|
8677
|
+
}
|
|
7341
8678
|
async function prepareDeployBundle(dir, manifest, door, projectDir) {
|
|
7342
8679
|
let server;
|
|
7343
8680
|
let rules = NO_MANIFEST;
|
|
@@ -7544,6 +8881,10 @@ function formatOrgLine(org) {
|
|
|
7544
8881
|
return org.domain ? `${head} ${org.domain}: new apps start visible to everyone in it` : `${head} no company domain: new apps start private`;
|
|
7545
8882
|
}
|
|
7546
8883
|
var whoami = (creds) => callApi(creds, "/api/v1/me");
|
|
8884
|
+
function signedInSentence(me) {
|
|
8885
|
+
const gave = (me.scope ?? "full") === "full" ? "full access" : "build-and-update";
|
|
8886
|
+
return `Signed in (${gave}). Organisations: ${me.orgs.map((o) => o.slug).join(", ") || "none yet"}.`;
|
|
8887
|
+
}
|
|
7547
8888
|
function chooseOrg(orgs, given, hint = 'Pass --org <slug>, or add "org" to wirl.json.') {
|
|
7548
8889
|
const named = (given ?? "").trim();
|
|
7549
8890
|
if (named) return named;
|
|
@@ -7557,13 +8898,25 @@ async function resolveAppsOrg(creds, orgFlag, manifestOrg) {
|
|
|
7557
8898
|
return chooseOrg((await whoami(creds)).orgs, null);
|
|
7558
8899
|
}
|
|
7559
8900
|
var listApps = (creds, org) => callApi(creds, `/api/v1/orgs/${seg(org)}/apps`);
|
|
7560
|
-
var createRemoteApp = (creds, org, slug, name) => callApi(creds, `/api/v1/orgs/${seg(org)}/apps`, {
|
|
8901
|
+
var createRemoteApp = (creds, org, slug, name, visibility) => callApi(creds, `/api/v1/orgs/${seg(org)}/apps`, {
|
|
7561
8902
|
method: "POST",
|
|
7562
|
-
|
|
8903
|
+
// `visibility` rides along only when wirl.json said it: absent, the
|
|
8904
|
+
// org's default holds. It applies to the app this create makes and to
|
|
8905
|
+
// no other: an app that exists answers 409 and is left as it is.
|
|
8906
|
+
body: JSON.stringify({ slug, name: name ?? slug, ...visibility === void 0 ? {} : { visibility } }),
|
|
7563
8907
|
contentType: "application/json"
|
|
7564
8908
|
});
|
|
7565
|
-
async function ensureRemoteApp(creds, org, app) {
|
|
7566
|
-
return createRemoteApp(creds, org, app).then(() =>
|
|
8909
|
+
async function ensureRemoteApp(creds, org, app, visibility) {
|
|
8910
|
+
return createRemoteApp(creds, org, app, void 0, visibility).then((created) => {
|
|
8911
|
+
const made = created.app?.visibility;
|
|
8912
|
+
if (visibility === "private" && typeof made === "string" && made !== "private") {
|
|
8913
|
+
process.stderr.write(
|
|
8914
|
+
`${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}`)}
|
|
8915
|
+
`
|
|
8916
|
+
);
|
|
8917
|
+
}
|
|
8918
|
+
return true;
|
|
8919
|
+
}, (error) => {
|
|
7567
8920
|
if (!(error instanceof CliError)) throw error;
|
|
7568
8921
|
if (error.fields["code"] === "app_deleted") throw deletedNameError(error, app);
|
|
7569
8922
|
if (error.fields["code"] === "app_exists" || error.fields["code"] === "app_moved") throw takenNameError(error, app);
|
|
@@ -7609,7 +8962,7 @@ function deletedAppMessage(org, app, done) {
|
|
|
7609
8962
|
return `Deleted ${org}/${app}. Its address has stopped answering${runs}. Its data is kept until ${done.dataKeptUntil.slice(0, 10)}; until then wirl apps restore ${app} --org ${org} brings it back (one that was public comes back only from the Recently deleted list on the dashboard). Its name stays taken.`;
|
|
7610
8963
|
}
|
|
7611
8964
|
function deleteNeedsFullScopeMessage(apiOrigin, org, app) {
|
|
7612
|
-
return `This sign-in can deploy, but not delete an app: deleting one needs a full-scope sign-in. Sign in with ${WIRL_BIN} login (with WIRL_TOKEN unset), or delete it on its Settings page: ${apiOrigin}/apps/${seg(org)}/${seg(app)}/settings`;
|
|
8965
|
+
return `This sign-in can deploy, but not delete an app: deleting one needs a full-scope sign-in. Sign in with ${WIRL_BIN} login (with WIRL_TOKEN unset) and tick full access on the approval page, or delete it on its Settings page: ${apiOrigin}/apps/${seg(org)}/${seg(app)}/settings`;
|
|
7613
8966
|
}
|
|
7614
8967
|
var VISIBILITY_LEVELS = ["private", "org", "public"];
|
|
7615
8968
|
function describeVisibility(level, org) {
|
|
@@ -7625,10 +8978,13 @@ function describeVisibility(level, org) {
|
|
|
7625
8978
|
}
|
|
7626
8979
|
}
|
|
7627
8980
|
var readVisibility = (creds, org, app) => callApi(creds, `/api/v1/apps/${seg(org)}/${seg(app)}`).then((r) => r.app.visibility ?? "unknown");
|
|
7628
|
-
|
|
7629
|
-
if (!VISIBILITY_LEVELS.includes(
|
|
7630
|
-
throw new CliError(`Visibility is private, org or public, not "${String(
|
|
8981
|
+
function refuseVisibilityLevel(level) {
|
|
8982
|
+
if (!VISIBILITY_LEVELS.includes(level)) {
|
|
8983
|
+
throw new CliError(`Visibility is private, org or public, not "${String(level)}".`);
|
|
7631
8984
|
}
|
|
8985
|
+
}
|
|
8986
|
+
async function changeVisibility(creds, options) {
|
|
8987
|
+
refuseVisibilityLevel(options.level);
|
|
7632
8988
|
if (options.level === "public") {
|
|
7633
8989
|
throw new CliError(
|
|
7634
8990
|
"Public means anyone on the internet with the link can open it, without signing in, so a person decides that with their own click.",
|
|
@@ -7673,11 +9029,59 @@ var transferApp = (creds, org, app, email, options = {}) => callApi(creds, `/api
|
|
|
7673
9029
|
...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
|
|
7674
9030
|
});
|
|
7675
9031
|
var unmailedNote = (result) => result.mailed === false && result.message ? ` ${result.message}` : "";
|
|
9032
|
+
function refuseShareRole(value) {
|
|
9033
|
+
const read = readShareRole(value);
|
|
9034
|
+
if (read.ok) return read.role;
|
|
9035
|
+
const shown2 = typeof value === "string" ? terminalSafe(value.slice(0, 40)) : typeof value;
|
|
9036
|
+
throw new CliError(`Role is viewer or editor, not "${shown2}".`);
|
|
9037
|
+
}
|
|
7676
9038
|
var shareApp = (creds, org, app, email, role) => callApi(creds, `/api/v1/apps/${seg(org)}/${seg(app)}/share`, {
|
|
7677
9039
|
method: "POST",
|
|
7678
|
-
|
|
9040
|
+
// No role is no `role`: the control plane then keeps the one a person has.
|
|
9041
|
+
body: JSON.stringify(role === void 0 ? { email } : { email, role }),
|
|
9042
|
+
contentType: "application/json"
|
|
9043
|
+
});
|
|
9044
|
+
function shareSentence(result, email, asked, voice) {
|
|
9045
|
+
const roleOf = (value) => value === "viewer" || value === "editor" ? value : void 0;
|
|
9046
|
+
const outcome = ["added", "kept", "unchanged", "changed"].includes(String(result.outcome)) ? result.outcome : void 0;
|
|
9047
|
+
const role = roleOf(result.role);
|
|
9048
|
+
const old = outcome === void 0 || role === void 0;
|
|
9049
|
+
const now = role ?? asked ?? "viewer";
|
|
9050
|
+
const was = roleOf(result.previousRole);
|
|
9051
|
+
const article = (name) => name === "editor" ? "an editor" : "a viewer";
|
|
9052
|
+
const mail = unmailedNote(result);
|
|
9053
|
+
const signIn = voice === "cli" ? "the moment they first sign in" : "when they first sign in";
|
|
9054
|
+
if (result.pending) {
|
|
9055
|
+
const wait = `They will have access ${signIn}.`;
|
|
9056
|
+
if (!old && outcome === "kept") return `${email} was already invited as ${now}, and no role was given, so it stays ${now}. ${wait}${mail}`;
|
|
9057
|
+
const lead2 = `Invited ${email} as ${now}. ${wait}`;
|
|
9058
|
+
if (old || outcome === "added") return `${lead2}${mail}`;
|
|
9059
|
+
if (outcome === "unchanged") return `${lead2} The invitation already said that.${mail}`;
|
|
9060
|
+
if (was === void 0) return `${lead2} The invitation's role was changed.${mail}`;
|
|
9061
|
+
return `${lead2} The invitation said ${was} before.${mail}`;
|
|
9062
|
+
}
|
|
9063
|
+
if (!old && outcome === "kept") {
|
|
9064
|
+
const change = voice === "cli" ? "Pass --role viewer or --role editor" : 'Pass role "viewer" or "editor"';
|
|
9065
|
+
return `${email} already had access as ${now}, and no role was given, so it stays ${now}. ${change} to change it.`;
|
|
9066
|
+
}
|
|
9067
|
+
const lead = voice === "cli" ? `Shared with ${email} as ${now}.` : `${email} can now open the app as ${now}.`;
|
|
9068
|
+
if (old || outcome === "added") return lead;
|
|
9069
|
+
if (outcome === "unchanged") return `${lead} They already had that role.`;
|
|
9070
|
+
if (was === void 0) return `${lead} Their role was changed.`;
|
|
9071
|
+
return `${lead} They were ${article(was)} before.`;
|
|
9072
|
+
}
|
|
9073
|
+
var unshareApp = (creds, org, app, email) => callApi(creds, `/api/v1/apps/${seg(org)}/${seg(app)}/share`, {
|
|
9074
|
+
method: "DELETE",
|
|
9075
|
+
body: JSON.stringify({ email }),
|
|
7679
9076
|
contentType: "application/json"
|
|
7680
9077
|
});
|
|
9078
|
+
function unshareSentence(result, email) {
|
|
9079
|
+
const share = result.shareRevoked ?? result.revoked;
|
|
9080
|
+
const invitation = result.invitationWithdrawn ?? false;
|
|
9081
|
+
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.`;
|
|
9082
|
+
const wasDeleted = result.appDeleted === true && (share || invitation) ? " The app was deleted when this was asked, so a restore will not bring it back." : "";
|
|
9083
|
+
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.`;
|
|
9084
|
+
}
|
|
7681
9085
|
var listDeployments = (creds, org, app) => callApi(
|
|
7682
9086
|
creds,
|
|
7683
9087
|
`/api/v1/apps/${seg(org)}/${seg(app)}/deployments`
|
|
@@ -7821,7 +9225,7 @@ var fetchLogs = (creds, org, app, filters) => callApi(
|
|
|
7821
9225
|
);
|
|
7822
9226
|
var LINE_INDENT = " ";
|
|
7823
9227
|
function terminalSafe(text) {
|
|
7824
|
-
return escapeInvisible(text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`));
|
|
9228
|
+
return escapeInvisible(text.replace(/\r\n/g, "\n").replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`));
|
|
7825
9229
|
}
|
|
7826
9230
|
function utcSecond(at2) {
|
|
7827
9231
|
const ms = Date.parse(at2);
|
|
@@ -7857,7 +9261,8 @@ function formatLogPage(page, since) {
|
|
|
7857
9261
|
// queryable. Without this an empty answer right after a request
|
|
7858
9262
|
// reads as "nothing happened".
|
|
7859
9263
|
" A request made in the last minute may not have arrived yet: ask again shortly."
|
|
7860
|
-
)}
|
|
9264
|
+
)}
|
|
9265
|
+
${EDGE_REFUSALS_NOTE}` : page.invocations.map((entry) => formatInvocation(entry)).join("\n\n");
|
|
7861
9266
|
return page.cursor === null ? body : `${body}
|
|
7862
9267
|
|
|
7863
9268
|
More before this: pass cursor=${page.cursor}.`;
|
|
@@ -8403,20 +9808,22 @@ async function previewFetch(creds, org, app, name, path, options) {
|
|
|
8403
9808
|
const headers = callerHeaders(options.headers);
|
|
8404
9809
|
headers.set(VISIT_HEADER, mint.token);
|
|
8405
9810
|
const networkFetch = options.networkFetchImpl ?? fetch;
|
|
8406
|
-
const
|
|
9811
|
+
const clock = options.now ?? (() => performance.now());
|
|
9812
|
+
const sentAt = clock();
|
|
9813
|
+
const elapsed = () => Math.round(clock() - sentAt);
|
|
8407
9814
|
const response = await networkFetch(target.toString(), {
|
|
8408
9815
|
method: options.method ?? (options.body !== void 0 ? "POST" : "GET"),
|
|
8409
9816
|
headers,
|
|
8410
9817
|
redirect: "manual",
|
|
8411
9818
|
...options.body === void 0 ? {} : { body: options.body }
|
|
8412
9819
|
});
|
|
8413
|
-
const headersMs =
|
|
9820
|
+
const headersMs = elapsed();
|
|
8414
9821
|
const contentType = response.headers.get("content-type") ?? "";
|
|
8415
9822
|
const location = response.headers.get("location");
|
|
8416
9823
|
const text = isTextBody(contentType);
|
|
8417
9824
|
const streamWaitMs = Math.min(options.streamWaitMs ?? FETCH_STREAM_WAIT_MS, FETCH_WAIT_MAX_SECONDS * 1e3);
|
|
8418
9825
|
const keep = options.keepBodyBytes === void 0 ? null : Math.min(options.keepBodyBytes, FETCH_FILE_MAX_BYTES);
|
|
8419
|
-
const read = await readBounded(response, streamWaitMs,
|
|
9826
|
+
const read = await readBounded(response, streamWaitMs, elapsed, keep ?? FETCH_READ_CEILING_BYTES);
|
|
8420
9827
|
const bytes = read.bytes;
|
|
8421
9828
|
const truncated = text && (read.cut || bytes.byteLength > FETCH_BODY_CAP_BYTES);
|
|
8422
9829
|
const media = (contentType.split(";")[0] ?? "").trim().toLowerCase();
|
|
@@ -8504,7 +9911,7 @@ function parseWaitFlag(raw) {
|
|
|
8504
9911
|
}
|
|
8505
9912
|
var FETCH_READ_CEILING_BYTES = 8 * FETCH_BODY_CAP_BYTES;
|
|
8506
9913
|
var FETCH_FILE_MAX_BYTES = 1e8;
|
|
8507
|
-
async function readBounded(response, waitMs,
|
|
9914
|
+
async function readBounded(response, waitMs, elapsed, ceilingBytes) {
|
|
8508
9915
|
if (response.body === null) return { bytes: new Uint8Array(0), chunks: [], stillStreaming: false, cut: false };
|
|
8509
9916
|
const reader = response.body.getReader();
|
|
8510
9917
|
const chunks = [];
|
|
@@ -8523,7 +9930,7 @@ async function readBounded(response, waitMs, sentAt, ceilingBytes) {
|
|
|
8523
9930
|
break;
|
|
8524
9931
|
}
|
|
8525
9932
|
if (next.done) break;
|
|
8526
|
-
chunks.push({ atMs:
|
|
9933
|
+
chunks.push({ atMs: elapsed(), bytes: next.value });
|
|
8527
9934
|
total += next.value.byteLength;
|
|
8528
9935
|
if (total > ceilingBytes) {
|
|
8529
9936
|
cut2 = true;
|
|
@@ -8568,9 +9975,9 @@ function formatFetchResult(result, saved) {
|
|
|
8568
9975
|
0,
|
|
8569
9976
|
`answered after ${seconds(result.headersMs)} s; ${said} line${said === 1 ? "" : "s"} in ${pieces} piece${pieces === 1 ? "" : "s"} over ${seconds(last)} s (each line shown at the time it was complete)`
|
|
8570
9977
|
);
|
|
9978
|
+
const unfinished = lastLineUnfinished(result);
|
|
8571
9979
|
timed.forEach((line, i) => {
|
|
8572
|
-
|
|
8573
|
-
lines.push(line.text === "" ? "" : `+${seconds(line.atMs)}s ${line.text}${unfinished ? " (the line had not ended)" : ""}`);
|
|
9980
|
+
lines.push(line.text === "" ? "" : `+${seconds(line.atMs)}s ${line.text}${unfinished && i === timed.length - 1 ? " (the line had not ended)" : ""}`);
|
|
8574
9981
|
});
|
|
8575
9982
|
} else {
|
|
8576
9983
|
lines.push(result.bodyText);
|
|
@@ -8583,6 +9990,10 @@ function formatFetchResult(result, saved) {
|
|
|
8583
9990
|
}
|
|
8584
9991
|
return lines.join("\n");
|
|
8585
9992
|
}
|
|
9993
|
+
function lastLineUnfinished(result) {
|
|
9994
|
+
return result.timed !== null && !result.timed.lastComplete && (!result.bodyComplete || result.truncated);
|
|
9995
|
+
}
|
|
9996
|
+
var STREAMED_DATA_MAX_TEXT_BYTES = 16 * 1024;
|
|
8586
9997
|
async function readDataFile(file, maxBytes = FETCH_FILE_MAX_BYTES) {
|
|
8587
9998
|
if (file.length === 0) throw new CliError("--data @ needs a file name after the @.", void 0, { code: "bad_flag" });
|
|
8588
9999
|
const tooBig = () => new CliError(
|
|
@@ -9049,6 +10460,138 @@ function formatMigrationsListing(listing, app) {
|
|
|
9049
10460
|
return lines;
|
|
9050
10461
|
}
|
|
9051
10462
|
|
|
10463
|
+
// packages/cli/src/args.ts
|
|
10464
|
+
var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
|
|
10465
|
+
"json",
|
|
10466
|
+
"help",
|
|
10467
|
+
"create",
|
|
10468
|
+
"no-create",
|
|
10469
|
+
"no-skill",
|
|
10470
|
+
"print",
|
|
10471
|
+
"server",
|
|
10472
|
+
"starter",
|
|
10473
|
+
"force",
|
|
10474
|
+
"errors",
|
|
10475
|
+
"follow",
|
|
10476
|
+
"fast",
|
|
10477
|
+
"verbose",
|
|
10478
|
+
"yes"
|
|
10479
|
+
]);
|
|
10480
|
+
var EVERYWHERE = {
|
|
10481
|
+
values: ["org", "app"],
|
|
10482
|
+
// `create` was the flag a deploy needed before an app was made by default, and is still read as the boolean it was, to no effect.
|
|
10483
|
+
booleans: ["json", "no-create", "create", "help"]
|
|
10484
|
+
};
|
|
10485
|
+
function commandFlags(listed2, everywhere = true) {
|
|
10486
|
+
return {
|
|
10487
|
+
values: /* @__PURE__ */ new Set([...everywhere ? EVERYWHERE.values : [], ...listed2.values ?? []]),
|
|
10488
|
+
booleans: /* @__PURE__ */ new Set([...everywhere ? EVERYWHERE.booleans : [], ...listed2.booleans ?? []]),
|
|
10489
|
+
own: new Set(listed2.refusedByCommand ?? [])
|
|
10490
|
+
};
|
|
10491
|
+
}
|
|
10492
|
+
var COMMAND_FLAGS = /* @__PURE__ */ new Map([
|
|
10493
|
+
["login", commandFlags({ values: ["token", "api"] })],
|
|
10494
|
+
["init", commandFlags({ booleans: ["server", "starter", "no-skill"] })],
|
|
10495
|
+
["skill", commandFlags({ booleans: ["print"] })],
|
|
10496
|
+
// Only what it lists, as DEV_FLAGS in bin.ts does (OIM-887): `--json` and `--app` were always refused here.
|
|
10497
|
+
["dev", commandFlags({ values: ["user", "name", "role", "org", "port"], booleans: ["verbose"] }, false)],
|
|
10498
|
+
["apps", commandFlags({})],
|
|
10499
|
+
// `--yes` changes nothing on a deploy: no flag answers for the person who approves a destructive file, and the
|
|
10500
|
+
// help says so ("--yes included"). It is read, as the boolean it is, and ignored; an agent that passes it still deploys.
|
|
10501
|
+
["deploy", commandFlags({ booleans: ["yes"] })],
|
|
10502
|
+
["preview", commandFlags({ values: ["name", "delete"], refusedByCommand: ["delete"] })],
|
|
10503
|
+
["previews", commandFlags({})],
|
|
10504
|
+
["fetch", commandFlags({ values: ["preview", "method", "data", "header", "wait", "output"], refusedByCommand: ["preview"] })],
|
|
10505
|
+
["pull", commandFlags({ booleans: ["force"] })],
|
|
10506
|
+
// Only what DELETE_FLAGS in bin.ts lists (OIM-918), and `--preview`, which that file refuses in a sentence about previews.
|
|
10507
|
+
["delete", commandFlags({ values: ["org", "app", "preview"], booleans: ["yes", "json"], refusedByCommand: ["preview"] }, false)],
|
|
10508
|
+
["share", commandFlags({ values: ["role"] })],
|
|
10509
|
+
["unshare", commandFlags({})],
|
|
10510
|
+
["visibility", commandFlags({})],
|
|
10511
|
+
["transfer", commandFlags({})],
|
|
10512
|
+
["move", commandFlags({ values: ["to"] })],
|
|
10513
|
+
["orgs", commandFlags({ values: ["name"] })],
|
|
10514
|
+
["members", commandFlags({ values: ["role"] })],
|
|
10515
|
+
["versions", commandFlags({})],
|
|
10516
|
+
["rollback", commandFlags({})],
|
|
10517
|
+
["migrations", commandFlags({ values: ["preview"] })],
|
|
10518
|
+
["schedules", commandFlags({})],
|
|
10519
|
+
["run", commandFlags({ values: ["preview"] })],
|
|
10520
|
+
["flows", commandFlags({ values: ["preview", "input", "input-file", "key"], booleans: ["fast"] })],
|
|
10521
|
+
["runs", commandFlags({ values: ["flow", "status", "limit", "preview", "data", "from"] })],
|
|
10522
|
+
["logs", commandFlags({ values: ["since", "kind", "limit", "cursor", "run", "ray", "search", "preview"], booleans: ["errors", "follow"] })],
|
|
10523
|
+
["egress", commandFlags({ values: ["since", "run", "limit", "cursor"] })],
|
|
10524
|
+
["connections", commandFlags({ values: ["secret-file", "bearer", "hosts", "kind", "name"] })],
|
|
10525
|
+
["whoami", commandFlags({})]
|
|
10526
|
+
]);
|
|
10527
|
+
function takesValue(command, name) {
|
|
10528
|
+
const table = command === void 0 ? void 0 : COMMAND_FLAGS.get(command);
|
|
10529
|
+
return table === void 0 ? !BOOLEAN_FLAGS.has(name) : table.values.has(name);
|
|
10530
|
+
}
|
|
10531
|
+
function parseArgs(argv) {
|
|
10532
|
+
const positional = [];
|
|
10533
|
+
const flags = /* @__PURE__ */ new Map();
|
|
10534
|
+
const valueless = /* @__PURE__ */ new Set();
|
|
10535
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
10536
|
+
const token = argv[i] ?? "";
|
|
10537
|
+
if (!token.startsWith("--")) {
|
|
10538
|
+
positional.push(token);
|
|
10539
|
+
continue;
|
|
10540
|
+
}
|
|
10541
|
+
const name = token.slice(2);
|
|
10542
|
+
if (!takesValue(positional[0], name)) {
|
|
10543
|
+
flags.set(name, true);
|
|
10544
|
+
continue;
|
|
10545
|
+
}
|
|
10546
|
+
const next = argv[i + 1];
|
|
10547
|
+
if (next !== void 0 && !next.startsWith("--")) {
|
|
10548
|
+
flags.set(name, next);
|
|
10549
|
+
i += 1;
|
|
10550
|
+
} else {
|
|
10551
|
+
flags.set(name, true);
|
|
10552
|
+
valueless.add(name);
|
|
10553
|
+
}
|
|
10554
|
+
}
|
|
10555
|
+
return { command: positional.shift() ?? "help", positional, flags, valueless };
|
|
10556
|
+
}
|
|
10557
|
+
function textFlag(args2, name) {
|
|
10558
|
+
const value = args2.flags.get(name);
|
|
10559
|
+
if (value === true) throw needsValue(args2, name);
|
|
10560
|
+
return value;
|
|
10561
|
+
}
|
|
10562
|
+
function needsValue(args2, name) {
|
|
10563
|
+
return new CliError(`--${name} needs a value.`, `wirl ${args2.command} --help lists what each flag takes.`, { code: "bad_flag" });
|
|
10564
|
+
}
|
|
10565
|
+
var NAMED_FLAGS = 6;
|
|
10566
|
+
function refuseUnknownFlags(args2, allowed) {
|
|
10567
|
+
const unknown = [...args2.flags.keys()].filter((name) => !allowed.has(name));
|
|
10568
|
+
if (unknown.length === 0) return;
|
|
10569
|
+
const shown2 = unknown.map((name) => {
|
|
10570
|
+
const equals = name.indexOf("=");
|
|
10571
|
+
return equals === -1 ? `--${name}` : `--${name.slice(0, equals)}=\u2026`;
|
|
10572
|
+
});
|
|
10573
|
+
const named = unknown.length > NAMED_FLAGS ? `${shown2.slice(0, NAMED_FLAGS).join(", ")} or ${String(unknown.length - NAMED_FLAGS)} more flags` : `${unknown.length === 1 ? shown2[0] : `${shown2.slice(0, -1).join(", ")} or ${shown2.at(-1)}`} flag`;
|
|
10574
|
+
const withValue = unknown.find((name) => name.includes("="));
|
|
10575
|
+
const where = withValue === void 0 ? "" : withValue.slice(0, withValue.indexOf("="));
|
|
10576
|
+
throw new CliError(
|
|
10577
|
+
`wirl ${args2.command} has no ${named}.`,
|
|
10578
|
+
`wirl ${args2.command} --help lists the flags it takes.${withValue === void 0 ? "" : ` A flag's value is the next word, as in --${where} <value>, not --${where}=<value>.`}`,
|
|
10579
|
+
{ code: "bad_flag" }
|
|
10580
|
+
);
|
|
10581
|
+
}
|
|
10582
|
+
var EMPTY_IS_NONE = /* @__PURE__ */ new Set(["token", "api", "preview", "org", "app"]);
|
|
10583
|
+
function refuseBadFlags(args2) {
|
|
10584
|
+
const table = COMMAND_FLAGS.get(args2.command);
|
|
10585
|
+
if (table === void 0) return;
|
|
10586
|
+
refuseUnknownFlags(args2, /* @__PURE__ */ new Set([...table.values, ...table.booleans]));
|
|
10587
|
+
for (const name of args2.valueless) {
|
|
10588
|
+
if (table.values.has(name) && !table.own.has(name)) throw needsValue(args2, name);
|
|
10589
|
+
}
|
|
10590
|
+
for (const [name, value] of args2.flags) {
|
|
10591
|
+
if (value === "" && EMPTY_IS_NONE.has(name) && table.values.has(name) && !table.own.has(name)) throw needsValue(args2, name);
|
|
10592
|
+
}
|
|
10593
|
+
}
|
|
10594
|
+
|
|
9052
10595
|
// packages/cli/src/dev.ts
|
|
9053
10596
|
init_src();
|
|
9054
10597
|
import { readdirSync as readdirSync2, realpathSync, statSync, watch as watchFolder } from "node:fs";
|
|
@@ -9356,7 +10899,12 @@ async function startLocalBroker(options) {
|
|
|
9356
10899
|
const keysPath = join6(options.dir, KEYS_FILE);
|
|
9357
10900
|
const log = options.log ?? (() => {
|
|
9358
10901
|
});
|
|
9359
|
-
const transport = createBrokerTransport({
|
|
10902
|
+
const transport = createBrokerTransport({
|
|
10903
|
+
fetchImpl: options.fetchImpl,
|
|
10904
|
+
pinning: options.pinning,
|
|
10905
|
+
heldBodyLimits: options.heldBodyLimits,
|
|
10906
|
+
heldAnswerLimits: options.heldAnswerLimits
|
|
10907
|
+
});
|
|
9360
10908
|
const mocks = options.mocks ?? /* @__PURE__ */ new Map();
|
|
9361
10909
|
const fetchImpl = mocks.size === 0 ? transport.fetchImpl : routeToMocks(
|
|
9362
10910
|
options.manifest,
|
|
@@ -9374,6 +10922,11 @@ async function startLocalBroker(options) {
|
|
|
9374
10922
|
connectionsFor: async () => localConnections(options.manifest, await readLocalKeys(options.dir), keysPath, mocks),
|
|
9375
10923
|
fetchImpl,
|
|
9376
10924
|
streamSlots: transport.streamSlots,
|
|
10925
|
+
// This response, so that an event stream wirl cut waits for its
|
|
10926
|
+
// closing event to leave before the connection is cut (OIM-837).
|
|
10927
|
+
streamSent: sentOn(outgoing),
|
|
10928
|
+
heldBodies: transport.heldBodies,
|
|
10929
|
+
heldAnswers: transport.heldAnswers,
|
|
9377
10930
|
audit: (entry) => {
|
|
9378
10931
|
log(
|
|
9379
10932
|
`wirl dev \xB7 ${entry.connection || "(no connection)"} ${entry.method} ${entry.host || ""} \u2192 ${entry.outcome}${entry.status ? ` ${entry.status}` : ""}`
|
|
@@ -9925,11 +11478,14 @@ var DEV_RELOAD_NOTE = `The server module, and every file it imports, is rebuilt
|
|
|
9925
11478
|
var DEV_KEYS_NOTE = `Keys come from .wirl/keys.json, in the shape this prints for each connection that has none. To try a connection without its key, map it to a server of your own in .wirl/mocks.json, like { "anthropic": "http://127.0.0.1:4010" }. A call to one of that connection's declared hosts then goes to that URL instead, its path added to the URL's own and its query kept. It carries the key from .wirl/keys.json, or, when that has none, the placeholder "wirl-dev-mock" in the shape of the connection's kind. With no key there, an http_header connection sends no key header, since only .wirl/keys.json names the header, and a none connection never sends one. A host the connection does not declare is refused as before. .wirl/mocks.json is read once, at start, and never uploaded.`;
|
|
9926
11479
|
var DEV_FLAGS_NOTE = "A flag not listed below, other than --help, is refused, not ignored.";
|
|
9927
11480
|
var DEV_TWO_PEOPLE_NOTE = "To try two people on one app, run wirl dev a second time in the same folder with another --user: the two share .wirl/local.sqlite, so each sees what the other writes.";
|
|
9928
|
-
var
|
|
11481
|
+
var PREVIEW_LINK_MINUTES = String(VISIT_TOKEN_TTL_SECONDS / 60);
|
|
11482
|
+
var PREVIEW_PASS_MINUTES = String(GRANT_TTL_DEFAULT_SECONDS / 60);
|
|
11483
|
+
var PREVIEW_LINK_WARNING = `Whoever holds this link is signed in as you when they open it, like a password: it works for ${PREVIEW_LINK_MINUTES} minutes, and the sign-in it gives a browser lasts ${PREVIEW_PASS_MINUTES} minutes. Open it in your browser, and do not share it, save it or put it in a file. To check a page or a stream from a script, use wirl fetch.`;
|
|
9929
11484
|
var USAGE = `wirl \u2014 deploy an app somewhere real, behind a sign-in
|
|
9930
11485
|
|
|
9931
11486
|
wirl login Sign in from this terminal (or --token wirl_...)
|
|
9932
11487
|
wirl init [dir] [--server] Create wirl.json, and a working server module with --server
|
|
11488
|
+
wirl init [dir] --starter Write your first app: a small team noticeboard, ready to deploy
|
|
9933
11489
|
wirl skill [dir] [--print] Write the skill file a coding agent reads (init does this too; --no-skill to skip)
|
|
9934
11490
|
wirl dev [dir] Run the app here, signed in as a stub user
|
|
9935
11491
|
wirl deploy [dir] Build, scan and deploy (creates the app if new)
|
|
@@ -9942,6 +11498,7 @@ var USAGE = `wirl \u2014 deploy an app somewhere real, behind a sign-in
|
|
|
9942
11498
|
wirl apps restore <name> Bring a deleted app back, inside its ${GRACE_DAYS} days (its owner while a member, or an organisation owner or admin)
|
|
9943
11499
|
wirl delete [app] [--yes] Delete an app: restorable for ${GRACE_DAYS} days, and its name stays taken
|
|
9944
11500
|
wirl share <email> [--role viewer|editor]
|
|
11501
|
+
wirl unshare <email> Take a person's share back (an invitation waiting for them too)
|
|
9945
11502
|
wirl visibility [private|org] See or change who can open the app (public is a person's click on its Access page)
|
|
9946
11503
|
wirl transfer <email> Hand the app to a new owner (you keep editor access)
|
|
9947
11504
|
wirl move --to <org> Move the app to another organisation you belong to
|
|
@@ -9992,14 +11549,18 @@ var HELP = {
|
|
|
9992
11549
|
example: "wirl login"
|
|
9993
11550
|
},
|
|
9994
11551
|
init: {
|
|
9995
|
-
usage: ["wirl init [dir] [--server] [--app <name>] [--org <slug>] [--no-skill]"],
|
|
11552
|
+
usage: ["wirl init [dir] [--server|--starter] [--app <name>] [--org <slug>] [--no-skill]"],
|
|
9996
11553
|
flags: [
|
|
9997
11554
|
{ flag: "--app <name>", desc: "App slug. Defaults to the directory name." },
|
|
9998
11555
|
{ flag: "--org <slug>", desc: "Organisation to put in wirl.json." },
|
|
9999
11556
|
{ flag: "--server", desc: "Also write server.mjs, a working backend to build on." },
|
|
11557
|
+
{
|
|
11558
|
+
flag: "--starter",
|
|
11559
|
+
desc: "Write your first app instead: a team noticeboard that shows what wirl does, private to you when deployed. Without [dir], the folder is <your-name>-starter."
|
|
11560
|
+
},
|
|
10000
11561
|
{ flag: "--no-skill", desc: "Skip writing the skill file a coding agent reads." }
|
|
10001
11562
|
],
|
|
10002
|
-
example: "wirl init my-app --server"
|
|
11563
|
+
example: "wirl init --starter\n wirl init my-app --server"
|
|
10003
11564
|
},
|
|
10004
11565
|
skill: {
|
|
10005
11566
|
usage: ["wirl skill [dir] [--print]"],
|
|
@@ -10027,7 +11588,7 @@ ${DEV_FLAGS_NOTE}`,
|
|
|
10027
11588
|
},
|
|
10028
11589
|
deploy: {
|
|
10029
11590
|
usage: ["wirl deploy [dir] [--org <slug>] [--app <slug>] [--no-create] [--json]"],
|
|
10030
|
-
note: `[dir] defaults to wirl.json's "dir", else the current folder. A [dir] with a wirl.json of its own is a project: that wirl.json is the one used (app, org, server, static) and its "dir" is relative to it. A [dir] without one is build output: the wirl.json here is used and that folder goes up. A .wirlignore at its root keeps extra files out of the upload. New files in migrations/ are applied to the app's database before the version goes live; one that fails fails the deploy, and the version already live, if any, keeps serving. A destructive file (one wirl.json's "destructive" list must name) waits for a person before it applies to the live database: the deploy is refused with the address of a page where a person approves it. Run at a terminal, signed in as yourself (not with a deploy token) and without --json, wirl deploy shows the file's destructive lines and asks the person to type its name instead. No flag skips the person, --yes included. A preview never asks
|
|
11591
|
+
note: `[dir] defaults to wirl.json's "dir", else the current folder. A [dir] with a wirl.json of its own is a project: that wirl.json is the one used (app, org, server, static) and its "dir" is relative to it. A [dir] without one is build output: the wirl.json here is used and that folder goes up. A .wirlignore at its root keeps extra files out of the upload. New files in migrations/ are applied to the app's database before the version goes live; one that fails fails the deploy, and the version already live, if any, keeps serving. A destructive file (one wirl.json's "destructive" list must name) waits for a person before it applies to the live database: the deploy is refused with the address of a page where a person approves it. Run at a terminal, signed in as yourself (not with a deploy token) and without --json, wirl deploy shows the file's destructive lines and asks the person to type its name instead. No flag skips the person, --yes included. A preview never asks. ` + versionNumbersSentence("wirl versions"),
|
|
10031
11592
|
flags: [
|
|
10032
11593
|
ORG_FLAG,
|
|
10033
11594
|
APP_FLAG,
|
|
@@ -10106,13 +11667,19 @@ ${DEV_FLAGS_NOTE}`,
|
|
|
10106
11667
|
share: {
|
|
10107
11668
|
usage: ["wirl share <email> [--role viewer|editor] [--org <slug>] [--app <slug>] [--json]"],
|
|
10108
11669
|
flags: [
|
|
10109
|
-
{ flag: "--role viewer|editor", desc: "Access level.
|
|
11670
|
+
{ flag: "--role viewer|editor", desc: "Access level. A new person is a viewer; someone who already has access keeps their role unless you name one." },
|
|
10110
11671
|
ORG_FLAG,
|
|
10111
11672
|
APP_FLAG,
|
|
10112
11673
|
JSON_FLAG
|
|
10113
11674
|
],
|
|
10114
11675
|
example: "wirl share sam@acme.test --role editor"
|
|
10115
11676
|
},
|
|
11677
|
+
unshare: {
|
|
11678
|
+
usage: ["wirl unshare <email> [--org <slug>] [--app <slug>] [--json]"],
|
|
11679
|
+
note: "With --json, shareRevoked and invitationWithdrawn say what was taken and appDeleted whether the app was deleted; revoked is always true (the request was carried out).",
|
|
11680
|
+
flags: [ORG_FLAG, APP_FLAG, JSON_FLAG],
|
|
11681
|
+
example: "wirl unshare sam@acme.test"
|
|
11682
|
+
},
|
|
10116
11683
|
transfer: {
|
|
10117
11684
|
usage: ["wirl transfer <email> [--org <slug>] [--app <slug>] [--json]"],
|
|
10118
11685
|
flags: [ORG_FLAG, APP_FLAG, JSON_FLAG],
|
|
@@ -10251,6 +11818,7 @@ wirl migrations adopt runs once, for an app whose own code made its tables: the
|
|
|
10251
11818
|
"wirl logs [--since 1h] [--errors] [--follow] [--limit n] [--cursor n]",
|
|
10252
11819
|
" [--kind <kind>] [--run <id>] [--ray <id>] [--search <text>] [--preview <name>] [--org <slug>] [--app <slug>] [--json]"
|
|
10253
11820
|
],
|
|
11821
|
+
note: EDGE_REFUSALS_NOTE,
|
|
10254
11822
|
flags: [
|
|
10255
11823
|
{ flag: "--since <window>", desc: "How far back to look, e.g. 1h, 30m. Defaults to 1h." },
|
|
10256
11824
|
{ flag: "--errors", desc: "Only failed requests." },
|
|
@@ -10375,11 +11943,10 @@ function personAt(input, output) {
|
|
|
10375
11943
|
}
|
|
10376
11944
|
|
|
10377
11945
|
// packages/cli/src/bin.ts
|
|
10378
|
-
var out = (line = "") => process.stdout.write(`${
|
|
11946
|
+
var out = (line = "") => process.stdout.write(`${terminalSafe(line)}
|
|
10379
11947
|
`);
|
|
10380
|
-
var err = (line = "") => process.stderr.write(`${
|
|
11948
|
+
var err = (line = "") => process.stderr.write(`${terminalSafe(line)}
|
|
10381
11949
|
`);
|
|
10382
|
-
var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["json", "help", "create", "no-create", "no-skill", "print", "server", "force", "errors", "follow", "fast", "verbose", "yes"]);
|
|
10383
11950
|
function readTypedLine(question) {
|
|
10384
11951
|
return new Promise((done) => {
|
|
10385
11952
|
const typed = createInterface2({ input: process.stdin, output: process.stderr, terminal: false });
|
|
@@ -10387,7 +11954,7 @@ function readTypedLine(question) {
|
|
|
10387
11954
|
typed.on("close", () => {
|
|
10388
11955
|
if (!answered) done(null);
|
|
10389
11956
|
});
|
|
10390
|
-
process.stderr.write(
|
|
11957
|
+
process.stderr.write(terminalSafe(question));
|
|
10391
11958
|
typed.once("line", (line) => {
|
|
10392
11959
|
answered = true;
|
|
10393
11960
|
typed.close();
|
|
@@ -10395,39 +11962,6 @@ function readTypedLine(question) {
|
|
|
10395
11962
|
});
|
|
10396
11963
|
});
|
|
10397
11964
|
}
|
|
10398
|
-
function parseArgs(argv) {
|
|
10399
|
-
const positional = [];
|
|
10400
|
-
const flags = /* @__PURE__ */ new Map();
|
|
10401
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
10402
|
-
const token = argv[i] ?? "";
|
|
10403
|
-
if (token.startsWith("--")) {
|
|
10404
|
-
const name = token.slice(2);
|
|
10405
|
-
const next = argv[i + 1];
|
|
10406
|
-
if (!BOOLEAN_FLAGS.has(name) && next !== void 0 && !next.startsWith("--")) {
|
|
10407
|
-
flags.set(name, next);
|
|
10408
|
-
i += 1;
|
|
10409
|
-
} else {
|
|
10410
|
-
flags.set(name, true);
|
|
10411
|
-
}
|
|
10412
|
-
} else {
|
|
10413
|
-
positional.push(token);
|
|
10414
|
-
}
|
|
10415
|
-
}
|
|
10416
|
-
return { command: positional.shift() ?? "help", positional, flags };
|
|
10417
|
-
}
|
|
10418
|
-
function textFlag(args2, name) {
|
|
10419
|
-
const value = args2.flags.get(name);
|
|
10420
|
-
if (value === true) {
|
|
10421
|
-
throw new CliError(`--${name} needs a value.`, `wirl ${args2.command} --help lists what each flag takes.`, { code: "bad_flag" });
|
|
10422
|
-
}
|
|
10423
|
-
return value;
|
|
10424
|
-
}
|
|
10425
|
-
function refuseUnknownFlags(args2, allowed) {
|
|
10426
|
-
const unknown = [...args2.flags.keys()].filter((name) => !allowed.has(name)).map((name) => `--${name}`);
|
|
10427
|
-
if (unknown.length === 0) return;
|
|
10428
|
-
const named = unknown.length === 1 ? unknown[0] : `${unknown.slice(0, -1).join(", ")} or ${unknown.at(-1)}`;
|
|
10429
|
-
throw new CliError(`wirl ${args2.command} has no ${named} flag.`, `wirl ${args2.command} --help lists the flags it takes.`, { code: "bad_flag" });
|
|
10430
|
-
}
|
|
10431
11965
|
var NAMED_WORDS = 6;
|
|
10432
11966
|
function nameWords(words) {
|
|
10433
11967
|
const shown2 = words.map((word) => word === "" ? '""' : word);
|
|
@@ -10454,6 +11988,8 @@ function parsePortFlag(raw) {
|
|
|
10454
11988
|
}
|
|
10455
11989
|
return port;
|
|
10456
11990
|
}
|
|
11991
|
+
var unknownMembersCommand = (sub) => new CliError(`Unknown members command: ${sub}`, "wirl members list | add <email> --role | role <email> --role | remove <email>");
|
|
11992
|
+
var unknownConnectionsCommand = (sub) => new CliError(`Unknown: wirl connections ${sub}`, "list, create, rotate, delete, show, grants, pending, approve, revoke, request");
|
|
10457
11993
|
var RUNS_SUBCOMMANDS = /* @__PURE__ */ new Set(["show", "send", "cancel", "retry"]);
|
|
10458
11994
|
var REFUSES_AS_JSON = /* @__PURE__ */ new Set(["flows", "runs"]);
|
|
10459
11995
|
var refusesAsJson = (args2) => args2.flags.has("json") && REFUSES_AS_JSON.has(args2.command);
|
|
@@ -10468,6 +12004,13 @@ async function resolveTarget(creds, args2, dir) {
|
|
|
10468
12004
|
return { org: String(org), app };
|
|
10469
12005
|
}
|
|
10470
12006
|
var args = parseArgs(process.argv.slice(2).map((token) => token === "-h" ? "--help" : token));
|
|
12007
|
+
async function starterFolder() {
|
|
12008
|
+
const creds = await loadCredentials().catch(() => null);
|
|
12009
|
+
if (creds === null) return starterName(null);
|
|
12010
|
+
const me = await whoami(creds).catch(() => null);
|
|
12011
|
+
if (me === null) err("Could not read your account, so the starter is named starter.");
|
|
12012
|
+
return starterName(me?.email ?? null);
|
|
12013
|
+
}
|
|
10471
12014
|
async function main() {
|
|
10472
12015
|
const json2 = args.flags.has("json");
|
|
10473
12016
|
if (process.argv[2] === "--version" || process.argv[2] === "-v") {
|
|
@@ -10487,9 +12030,12 @@ async function main() {
|
|
|
10487
12030
|
return 0;
|
|
10488
12031
|
}
|
|
10489
12032
|
if (args.command === "login") {
|
|
12033
|
+
refuseBadFlags(args);
|
|
10490
12034
|
refuseExtraPositionals("login", args.positional, 0, "no argument", { secret: true, hint: "A token goes after --token: wirl login --token <token>." });
|
|
10491
|
-
const
|
|
10492
|
-
const
|
|
12035
|
+
const given = textFlag(args, "token");
|
|
12036
|
+
const token = (given ?? "").trim();
|
|
12037
|
+
if (given !== void 0 && token === "") throw needsValue(args, "token");
|
|
12038
|
+
const apiOrigin = String(textFlag(args, "api") ?? apiOriginFromEnv()).replace(/\/+$/, "");
|
|
10493
12039
|
if (token) {
|
|
10494
12040
|
out(`Saved to ${await saveCredentials({ apiOrigin, token })}`);
|
|
10495
12041
|
return 0;
|
|
@@ -10505,25 +12051,32 @@ async function main() {
|
|
|
10505
12051
|
const creds2 = await pollDeviceLogin(apiOrigin, login);
|
|
10506
12052
|
const path = await saveCredentials(creds2);
|
|
10507
12053
|
const me = await whoami(creds2);
|
|
10508
|
-
out(
|
|
12054
|
+
out(signedInSentence(me));
|
|
10509
12055
|
out(`Saved to ${path}`);
|
|
10510
12056
|
return 0;
|
|
10511
12057
|
}
|
|
10512
12058
|
if (args.command === "init") {
|
|
12059
|
+
refuseBadFlags(args);
|
|
10513
12060
|
refuseExtraPositionals("init", args.positional, 1, "one folder", { hint: folderHint("The app's name goes after --app <name>.") });
|
|
10514
|
-
const
|
|
12061
|
+
const starter = args.flags.has("starter");
|
|
12062
|
+
if (starter && args.flags.has("server")) throw new CliError(STARTER_CONFLICT);
|
|
12063
|
+
const dir = resolve6(args.positional[0] ?? (starter ? await starterFolder() : "."));
|
|
10515
12064
|
const app = String(args.flags.get("app") ?? basename4(dir));
|
|
10516
12065
|
const written = await scaffold(dir, {
|
|
10517
12066
|
app,
|
|
10518
12067
|
...args.flags.has("org") ? { org: String(args.flags.get("org")) } : {},
|
|
10519
12068
|
server: args.flags.has("server"),
|
|
12069
|
+
starter,
|
|
10520
12070
|
skill: !args.flags.has("no-skill")
|
|
10521
12071
|
});
|
|
10522
12072
|
for (const file of written) out(`Wrote ${file}`);
|
|
10523
12073
|
if (args.flags.has("server")) out("Deploy it with: wirl deploy");
|
|
12074
|
+
const folder = relative3(process.cwd(), dir);
|
|
12075
|
+
if (starter) out(`Deploy it with: ${folder === "" ? "" : `cd ${shellPath(folder)} && `}wirl deploy`);
|
|
10524
12076
|
return 0;
|
|
10525
12077
|
}
|
|
10526
12078
|
if (args.command === "skill") {
|
|
12079
|
+
refuseBadFlags(args);
|
|
10527
12080
|
refuseExtraPositionals("skill", args.positional, 1, "one folder", { hint: folderHint() });
|
|
10528
12081
|
if (args.flags.has("print")) {
|
|
10529
12082
|
out(SKILL_MARKDOWN);
|
|
@@ -10535,6 +12088,7 @@ async function main() {
|
|
|
10535
12088
|
return 0;
|
|
10536
12089
|
}
|
|
10537
12090
|
if (args.command === "dev") {
|
|
12091
|
+
refuseBadFlags(args);
|
|
10538
12092
|
refuseUnknownFlags(args, DEV_FLAGS);
|
|
10539
12093
|
refuseExtraPositionals("dev", args.positional, 1, "one folder", { hint: folderHint() });
|
|
10540
12094
|
const portFlag = textFlag(args, "port");
|
|
@@ -10572,6 +12126,7 @@ async function main() {
|
|
|
10572
12126
|
return 0;
|
|
10573
12127
|
}
|
|
10574
12128
|
const creds = await loadCredentials();
|
|
12129
|
+
refuseBadFlags(args);
|
|
10575
12130
|
switch (args.command) {
|
|
10576
12131
|
case "whoami": {
|
|
10577
12132
|
refuseExtraPositionals("whoami", args.positional, 0, "no argument");
|
|
@@ -10623,7 +12178,7 @@ async function main() {
|
|
|
10623
12178
|
const upload = { dir, projectDir: project.manifestDir, org: target.org, app: target.app, ...manifest === null ? {} : { manifest } };
|
|
10624
12179
|
const prepared = await prepareUpload(dir, upload.manifest, "wirl deploy", upload.projectDir);
|
|
10625
12180
|
for (const line of prepared.notices) err(line);
|
|
10626
|
-
const made = !args.flags.has("no-create") && await ensureRemoteApp(creds, target.org, target.app);
|
|
12181
|
+
const made = !args.flags.has("no-create") && await ensureRemoteApp(creds, target.org, target.app, manifestVisibility(manifest));
|
|
10627
12182
|
const terminal = terminalMayConfirm({ stdinIsTTY: process.stdin.isTTY, stdoutIsTTY: process.stdout.isTTY, stderrIsTTY: process.stderr.isTTY, json: json2 }) ? personAt(process.stdin, process.stderr) : void 0;
|
|
10628
12183
|
const result = await deploy(creds, { ...upload, prepared, ...terminal === void 0 ? {} : { terminal } }).catch((error) => {
|
|
10629
12184
|
throw withMadeApp(error, made, "wirl deploy", target.app);
|
|
@@ -10684,7 +12239,7 @@ async function main() {
|
|
|
10684
12239
|
const upload = { dir, projectDir: project.manifestDir, org: target.org, app: target.app, name: resolved, ...manifest === null ? {} : { manifest } };
|
|
10685
12240
|
const prepared = await prepareUpload(dir, upload.manifest, "wirl preview", upload.projectDir);
|
|
10686
12241
|
for (const line of prepared.notices) err(line);
|
|
10687
|
-
const made = !args.flags.has("no-create") && await ensureRemoteApp(creds, target.org, target.app);
|
|
12242
|
+
const made = !args.flags.has("no-create") && await ensureRemoteApp(creds, target.org, target.app, manifestVisibility(manifest));
|
|
10688
12243
|
const result = await deployPreview(creds, { ...upload, prepared }).catch((error) => {
|
|
10689
12244
|
throw withMadeApp(error, made, "wirl preview", target.app);
|
|
10690
12245
|
});
|
|
@@ -10790,6 +12345,8 @@ async function main() {
|
|
|
10790
12345
|
});
|
|
10791
12346
|
} else if (sub === "remove") {
|
|
10792
12347
|
refuseExtraPositionals("members remove", args.positional.slice(1), 1, "one email", { hint: "Run it once for each person." });
|
|
12348
|
+
} else {
|
|
12349
|
+
throw unknownMembersCommand(sub);
|
|
10793
12350
|
}
|
|
10794
12351
|
const org = String(args.flags.get("org") ?? (await loadManifest("."))?.org ?? "") || (await resolveTarget(creds, args, ".")).org;
|
|
10795
12352
|
if (sub === "list") {
|
|
@@ -10835,7 +12392,7 @@ async function main() {
|
|
|
10835
12392
|
}
|
|
10836
12393
|
return 0;
|
|
10837
12394
|
}
|
|
10838
|
-
throw
|
|
12395
|
+
throw unknownMembersCommand(sub);
|
|
10839
12396
|
}
|
|
10840
12397
|
case "connections": {
|
|
10841
12398
|
const sub = args.positional[0] ?? "list";
|
|
@@ -10858,6 +12415,8 @@ async function main() {
|
|
|
10858
12415
|
refuseExtraPositionals("connections show", words, 1, "one app", { hint: "wirl connections grants lists every app and what it uses (admins)." });
|
|
10859
12416
|
} else if (sub === "approve" || sub === "revoke" || sub === "request") {
|
|
10860
12417
|
refuseExtraPositionals(`connections ${sub}`, words, 2, "an app and a connection", { hint: "Run it once for each connection." });
|
|
12418
|
+
} else {
|
|
12419
|
+
throw unknownConnectionsCommand(sub);
|
|
10861
12420
|
}
|
|
10862
12421
|
const orgSlug = String(args.flags.get("org") ?? (await loadManifest("."))?.org ?? "");
|
|
10863
12422
|
const org = orgSlug || (await resolveTarget(creds, args, ".")).org;
|
|
@@ -10975,7 +12534,7 @@ async function main() {
|
|
|
10975
12534
|
out(`${sub === "approve" ? "Approved" : "Revoked"} ${slug} for ${app}.`);
|
|
10976
12535
|
return 0;
|
|
10977
12536
|
}
|
|
10978
|
-
throw
|
|
12537
|
+
throw unknownConnectionsCommand(sub);
|
|
10979
12538
|
}
|
|
10980
12539
|
case "delete": {
|
|
10981
12540
|
const preview = args.flags.get("preview");
|
|
@@ -11057,6 +12616,7 @@ async function main() {
|
|
|
11057
12616
|
case "visibility": {
|
|
11058
12617
|
refuseExtraPositionals("visibility", args.positional, 1, "one level");
|
|
11059
12618
|
const level = args.positional[0];
|
|
12619
|
+
if (level) refuseVisibilityLevel(level);
|
|
11060
12620
|
const target = await resolveTarget(creds, args, ".");
|
|
11061
12621
|
const now = level ? await changeVisibility(creds, { org: target.org, app: target.app, level }) : await readVisibility(creds, target.org, target.app);
|
|
11062
12622
|
out(json2 ? JSON.stringify({ visibility: now }) : describeVisibility(now, target.org));
|
|
@@ -11068,12 +12628,23 @@ async function main() {
|
|
|
11068
12628
|
});
|
|
11069
12629
|
const email = args.positional[0];
|
|
11070
12630
|
if (!email) throw new CliError("Who should it be shared with?", "wirl share someone@company.com");
|
|
11071
|
-
const role = args.flags.get("role")
|
|
12631
|
+
const role = refuseShareRole(args.flags.get("role"));
|
|
11072
12632
|
const target = await resolveTarget(creds, args, ".");
|
|
11073
12633
|
const result = await shareApp(creds, target.org, target.app, email, role);
|
|
11074
|
-
out(
|
|
11075
|
-
|
|
11076
|
-
|
|
12634
|
+
out(json2 ? JSON.stringify(result) : shareSentence(result, email, role, "cli"));
|
|
12635
|
+
return 0;
|
|
12636
|
+
}
|
|
12637
|
+
case "unshare": {
|
|
12638
|
+
refuseExtraPositionals("unshare", args.positional, 1, "one email", {
|
|
12639
|
+
hint: "Take back one person's whole share at a time. To change a role instead, use wirl share --role."
|
|
12640
|
+
});
|
|
12641
|
+
const email = args.positional[0];
|
|
12642
|
+
if (!email) throw new CliError("Whose access?", "wirl unshare someone@company.com");
|
|
12643
|
+
const target = await resolveTarget(creds, args, ".");
|
|
12644
|
+
if (!isWellFormedSlug(target.app)) throw new CliError(`"${target.app}" is not an app's name, so nothing was changed.`, void 0, { code: "bad_name" });
|
|
12645
|
+
if (!isWellFormedSlug(target.org)) throw new CliError(`"${target.org}" is not an organisation's name, so nothing was changed.`, void 0, { code: "bad_name" });
|
|
12646
|
+
const result = await unshareApp(creds, target.org, target.app, email);
|
|
12647
|
+
out(json2 ? JSON.stringify(result) : unshareSentence(result, email));
|
|
11077
12648
|
return 0;
|
|
11078
12649
|
}
|
|
11079
12650
|
case "versions": {
|