wirl 0.2.2 → 0.2.4
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/bin.js +932 -97
- package/package.json +1 -1
package/bin.js
CHANGED
|
@@ -486,6 +486,27 @@ var init_broker_token = __esm({
|
|
|
486
486
|
}
|
|
487
487
|
});
|
|
488
488
|
|
|
489
|
+
// packages/core/src/broker-status.ts
|
|
490
|
+
function restoreStatus(response) {
|
|
491
|
+
const carried = response.headers.get(CARRIED_STATUS_HEADER);
|
|
492
|
+
if (carried === null) return response;
|
|
493
|
+
const headers = new Headers(response.headers);
|
|
494
|
+
headers.delete(CARRIED_STATUS_HEADER);
|
|
495
|
+
const named = Number(carried);
|
|
496
|
+
const status = response.status === CARRIER && CARRIED.has(named) ? named : response.status;
|
|
497
|
+
return new Response(response.body, { status, statusText: status === response.status ? response.statusText : STATUS_TEXT[status] ?? "", headers });
|
|
498
|
+
}
|
|
499
|
+
var CARRIED_STATUS_HEADER, CARRIED, CARRIER, STATUS_TEXT;
|
|
500
|
+
var init_broker_status = __esm({
|
|
501
|
+
"packages/core/src/broker-status.ts"() {
|
|
502
|
+
"use strict";
|
|
503
|
+
CARRIED_STATUS_HEADER = "x-wirl-status";
|
|
504
|
+
CARRIED = /* @__PURE__ */ new Set([502, 504]);
|
|
505
|
+
CARRIER = 500;
|
|
506
|
+
STATUS_TEXT = { 502: "Bad Gateway", 504: "Gateway Timeout" };
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
|
|
489
510
|
// packages/core/src/egress.ts
|
|
490
511
|
function hostMatches(host, pattern) {
|
|
491
512
|
const target = host.trim().toLowerCase();
|
|
@@ -2977,6 +2998,9 @@ function parseManifest(input) {
|
|
|
2977
2998
|
}
|
|
2978
2999
|
accent = rawAccent;
|
|
2979
3000
|
}
|
|
3001
|
+
const rawVisibility = raw["visibility"];
|
|
3002
|
+
if (rawVisibility !== void 0 && rawVisibility !== "private") return refuse(VISIBILITY_REFUSED);
|
|
3003
|
+
const visibility = rawVisibility === "private" ? { visibility: "private" } : {};
|
|
2980
3004
|
const flowResult = parseFlows(raw);
|
|
2981
3005
|
if (!flowResult.ok) return refuse(flowResult.message);
|
|
2982
3006
|
const flows = flowResult.flows;
|
|
@@ -2995,7 +3019,7 @@ function parseManifest(input) {
|
|
|
2995
3019
|
if (!publicPathsResult2.ok) return refuse(publicPathsResult2.message);
|
|
2996
3020
|
return {
|
|
2997
3021
|
ok: true,
|
|
2998
|
-
manifest: { runtime, connections: [], description, accent, schedules, flows, publicPaths: publicPathsResult2.publicPaths, static: staticFiles, migrations },
|
|
3022
|
+
manifest: { runtime, connections: [], description, accent, schedules, flows, publicPaths: publicPathsResult2.publicPaths, static: staticFiles, migrations, ...visibility },
|
|
2999
3023
|
warnings
|
|
3000
3024
|
};
|
|
3001
3025
|
}
|
|
@@ -3054,11 +3078,11 @@ function parseManifest(input) {
|
|
|
3054
3078
|
if (!publicPathsResult.ok) return refuse(publicPathsResult.message);
|
|
3055
3079
|
return {
|
|
3056
3080
|
ok: true,
|
|
3057
|
-
manifest: { runtime, connections, description, accent, schedules, flows, publicPaths: publicPathsResult.publicPaths, static: staticFiles, migrations },
|
|
3081
|
+
manifest: { runtime, connections, description, accent, schedules, flows, publicPaths: publicPathsResult.publicPaths, static: staticFiles, migrations, ...visibility },
|
|
3058
3082
|
warnings
|
|
3059
3083
|
};
|
|
3060
3084
|
}
|
|
3061
|
-
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;
|
|
3085
|
+
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;
|
|
3062
3086
|
var init_manifest = __esm({
|
|
3063
3087
|
"packages/core/src/manifest.ts"() {
|
|
3064
3088
|
"use strict";
|
|
@@ -3074,7 +3098,7 @@ var init_manifest = __esm({
|
|
|
3074
3098
|
};
|
|
3075
3099
|
CREATABLE_CONNECTION_KINDS = CONNECTION_KINDS.filter((kind) => !(kind in UNDELIVERED_KINDS));
|
|
3076
3100
|
needsNoKey = (kind) => kind === "none";
|
|
3077
|
-
ACCENTS = ["blue", "green", "sun", "purple", "red", "sky", "orange", "lime"];
|
|
3101
|
+
ACCENTS = ["blue", "green", "sun", "purple", "red", "sky", "orange", "lime", "mint", "butter", "blush", "lilac"];
|
|
3078
3102
|
SCHEDULE_LIMITS = {
|
|
3079
3103
|
perApp: 10,
|
|
3080
3104
|
minTimeoutMs: 3e4,
|
|
@@ -3098,7 +3122,8 @@ var init_manifest = __esm({
|
|
|
3098
3122
|
"server",
|
|
3099
3123
|
"spa",
|
|
3100
3124
|
"start",
|
|
3101
|
-
"static"
|
|
3125
|
+
"static",
|
|
3126
|
+
"visibility"
|
|
3102
3127
|
]);
|
|
3103
3128
|
QUIET_KEYS = Object.freeze(["$schema"]);
|
|
3104
3129
|
UNKNOWN_KEYS_NAMED = 10;
|
|
@@ -3120,6 +3145,7 @@ var init_manifest = __esm({
|
|
|
3120
3145
|
TIMEOUT_MESSAGE = (name) => `timeout for ${name} must be between 30s and 15m; a run longer than that should be split into resumable chunks.`;
|
|
3121
3146
|
FLOW_KEYS = ["name", "step_timeout", "retries"];
|
|
3122
3147
|
MIGRATIONS_KEYS = ["destructive"];
|
|
3148
|
+
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.`;
|
|
3123
3149
|
}
|
|
3124
3150
|
});
|
|
3125
3151
|
|
|
@@ -3339,6 +3365,29 @@ var init_runtime = __esm({
|
|
|
3339
3365
|
}
|
|
3340
3366
|
});
|
|
3341
3367
|
|
|
3368
|
+
// packages/core/src/ai-vendors.ts
|
|
3369
|
+
function aiRefusal(status, code, message, extra = {}) {
|
|
3370
|
+
const error = { type: "wirl", code, message };
|
|
3371
|
+
if (extra.scope !== void 0) error.scope = extra.scope;
|
|
3372
|
+
if (extra.resets !== void 0) error.resets = extra.resets;
|
|
3373
|
+
const headers = new Headers({ "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
3374
|
+
if (extra.retryAfterSeconds !== void 0) headers.set("retry-after", String(extra.retryAfterSeconds));
|
|
3375
|
+
return new Response(JSON.stringify({ error }, null, 2), { status, headers });
|
|
3376
|
+
}
|
|
3377
|
+
var AI_HOST, AI_VENDOR_SPECS;
|
|
3378
|
+
var init_ai_vendors = __esm({
|
|
3379
|
+
"packages/core/src/ai-vendors.ts"() {
|
|
3380
|
+
"use strict";
|
|
3381
|
+
AI_HOST = "ai.wirl";
|
|
3382
|
+
AI_VENDOR_SPECS = {
|
|
3383
|
+
openrouter: { host: "openrouter.ai", prefix: "/api", keyHeader: "authorization", label: "OpenRouter" },
|
|
3384
|
+
openai: { host: "api.openai.com", prefix: "", keyHeader: "authorization", label: "OpenAI" },
|
|
3385
|
+
anthropic: { host: "api.anthropic.com", prefix: "", keyHeader: "x-api-key", label: "Anthropic" },
|
|
3386
|
+
google: { host: "generativelanguage.googleapis.com", prefix: "", keyHeader: "x-goog-api-key", label: "Google" }
|
|
3387
|
+
};
|
|
3388
|
+
}
|
|
3389
|
+
});
|
|
3390
|
+
|
|
3342
3391
|
// packages/core/src/broker-stream.ts
|
|
3343
3392
|
function mediaTypeOf(headers) {
|
|
3344
3393
|
const declared = headers.get("content-type");
|
|
@@ -3779,9 +3828,14 @@ var init_broker_bodies = __esm({
|
|
|
3779
3828
|
|
|
3780
3829
|
// packages/core/src/broker.ts
|
|
3781
3830
|
async function handleBrokerRequest(request, deps) {
|
|
3782
|
-
const holding = { body: null, answer: null };
|
|
3831
|
+
const holding = { body: null, answer: null, tap: null, vendorStatus: null };
|
|
3783
3832
|
try {
|
|
3784
|
-
|
|
3833
|
+
const response = await brokerCall(request, deps, holding);
|
|
3834
|
+
holding.tap?.end("error", holding.vendorStatus ?? response.status);
|
|
3835
|
+
return response;
|
|
3836
|
+
} catch (error) {
|
|
3837
|
+
holding.tap?.end("error", holding.vendorStatus ?? 500);
|
|
3838
|
+
throw error;
|
|
3785
3839
|
} finally {
|
|
3786
3840
|
holding.body?.release();
|
|
3787
3841
|
holding.answer?.release();
|
|
@@ -3881,34 +3935,46 @@ async function brokerCall(request, deps, holding) {
|
|
|
3881
3935
|
}
|
|
3882
3936
|
requestBody = bodyRead.bytes;
|
|
3883
3937
|
}
|
|
3884
|
-
const connections = await deps.connectionsFor(appId, deploymentId);
|
|
3885
3938
|
let connection;
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3939
|
+
let aiUpstream = null;
|
|
3940
|
+
const aiTarget = aiTargetOf(request);
|
|
3941
|
+
if (aiTarget !== null) {
|
|
3942
|
+
if (!deps.ai) return noAiAnswer();
|
|
3943
|
+
const got = await deps.ai(request, { appId, deploymentId, origin }, aiTarget, requestBody);
|
|
3944
|
+
if (got instanceof Response) return got;
|
|
3945
|
+
holding.tap = onceTap(got.tap);
|
|
3946
|
+
connection = got.connection;
|
|
3947
|
+
aiUpstream = got.upstream;
|
|
3948
|
+
if (got.body !== null) requestBody = got.body.buffer instanceof ArrayBuffer ? got.body : new Uint8Array(got.body);
|
|
3949
|
+
} else {
|
|
3950
|
+
const connections = await deps.connectionsFor(appId, deploymentId);
|
|
3951
|
+
if (route.slug === RESOLVE_BY_HOST) {
|
|
3952
|
+
const target2 = request.headers.get("x-wirl-target");
|
|
3953
|
+
if (!target2) {
|
|
3954
|
+
await record("no_target", 400, "");
|
|
3955
|
+
return refuse2(400, "no_target", "Resolving a connection by host needs X-Wirl-Target.");
|
|
3956
|
+
}
|
|
3957
|
+
path = egressAuditPath(target2);
|
|
3958
|
+
const host2 = safeHost(target2);
|
|
3959
|
+
const matches = connections.filter(
|
|
3960
|
+
(candidate) => isHttpKind(candidate.kind) && host2.length > 0 && isHostAllowed(host2, candidate.allowedHosts)
|
|
3907
3961
|
);
|
|
3962
|
+
if (matches.length === 0) {
|
|
3963
|
+
await record("no_grant", 403, host2);
|
|
3964
|
+
return refuse2(403, "no_grant", `No granted connection covers ${host2 || target2}.`);
|
|
3965
|
+
}
|
|
3966
|
+
if (matches.length > 1) {
|
|
3967
|
+
await record("ambiguous", 403, host2);
|
|
3968
|
+
return refuse2(
|
|
3969
|
+
403,
|
|
3970
|
+
"ambiguous",
|
|
3971
|
+
`More than one granted connection covers ${host2}: ${matches.map((m) => m.slug).join(", ")}. Call /e/<connection>/ to say which.`
|
|
3972
|
+
);
|
|
3973
|
+
}
|
|
3974
|
+
connection = matches[0];
|
|
3975
|
+
} else {
|
|
3976
|
+
connection = connections.find((candidate) => candidate.slug === route.slug);
|
|
3908
3977
|
}
|
|
3909
|
-
connection = matches[0];
|
|
3910
|
-
} else {
|
|
3911
|
-
connection = connections.find((candidate) => candidate.slug === route.slug);
|
|
3912
3978
|
}
|
|
3913
3979
|
if (!connection) {
|
|
3914
3980
|
await record("no_grant", 403, "");
|
|
@@ -3944,7 +4010,7 @@ async function brokerCall(request, deps, holding) {
|
|
|
3944
4010
|
`The "${connection.slug}" connection is a ${connection.kind} credential and is not reachable over http.`
|
|
3945
4011
|
);
|
|
3946
4012
|
}
|
|
3947
|
-
const target = buildTarget(connection.baseUrl, route.rest, url.search, request);
|
|
4013
|
+
const target = aiUpstream !== null ? aiUpstream.href : buildTarget(connection.baseUrl, route.rest, url.search, request);
|
|
3948
4014
|
path = egressAuditPath(target);
|
|
3949
4015
|
const host = safeHost(target);
|
|
3950
4016
|
if (host.length === 0 || !isHostAllowed(host, connection.allowedHosts)) {
|
|
@@ -3966,6 +4032,7 @@ async function brokerCall(request, deps, holding) {
|
|
|
3966
4032
|
if (STRIPPED.has(lower)) return;
|
|
3967
4033
|
if (STRIPPED_PREFIXES.some((prefix) => lower.startsWith(prefix))) return;
|
|
3968
4034
|
if (isHopHeader(lower)) return;
|
|
4035
|
+
if (aiUpstream !== null && AI_KEY_HEADERS.has(lower)) return;
|
|
3969
4036
|
headers.set(name, value);
|
|
3970
4037
|
});
|
|
3971
4038
|
attachCredential(headers, connection);
|
|
@@ -4000,10 +4067,21 @@ async function brokerCall(request, deps, holding) {
|
|
|
4000
4067
|
await record("private_address", 403, safeHost(target));
|
|
4001
4068
|
return refuse2(403, "private_address", error.message);
|
|
4002
4069
|
}
|
|
4003
|
-
if (request.signal.aborted)
|
|
4070
|
+
if (request.signal.aborted) {
|
|
4071
|
+
holding.tap?.end("cut", 499);
|
|
4072
|
+
return callerLeft(record, safeHost(target));
|
|
4073
|
+
}
|
|
4004
4074
|
await record("upstream_error", 502, safeHost(target));
|
|
4075
|
+
if (holding.tap !== null) {
|
|
4076
|
+
holding.tap.end("error", 502);
|
|
4077
|
+
return aiRefusal(502, "ai_vendor_unreachable", `${aiVendorLabel(slug)} could not be reached.`);
|
|
4078
|
+
}
|
|
4005
4079
|
return refuse2(502, "upstream_error", error instanceof Error ? error.message : String(error));
|
|
4006
4080
|
}
|
|
4081
|
+
if (holding.tap !== null) {
|
|
4082
|
+
holding.vendorStatus = upstream.status;
|
|
4083
|
+
holding.tap.answered?.(upstream.status);
|
|
4084
|
+
}
|
|
4007
4085
|
let upstreamBody;
|
|
4008
4086
|
try {
|
|
4009
4087
|
upstreamBody = upstream.body;
|
|
@@ -4022,6 +4100,8 @@ async function brokerCall(request, deps, holding) {
|
|
|
4022
4100
|
if (upstreamBody !== null && isStreamedAnswer(upstream.status, upstream.headers)) {
|
|
4023
4101
|
const body2 = holding.body;
|
|
4024
4102
|
holding.body = null;
|
|
4103
|
+
const tap = holding.tap;
|
|
4104
|
+
holding.tap = null;
|
|
4025
4105
|
return streamAnswer(upstream, upstreamBody, {
|
|
4026
4106
|
appId,
|
|
4027
4107
|
limit,
|
|
@@ -4032,7 +4112,8 @@ async function brokerCall(request, deps, holding) {
|
|
|
4032
4112
|
sentMs: deps.streamSentMs ?? BROKER_LIMITS.cutEventSentMs,
|
|
4033
4113
|
signal: request.signal,
|
|
4034
4114
|
record: (outcome, status) => record(outcome, status, safeHost(target)),
|
|
4035
|
-
held: body2
|
|
4115
|
+
held: body2,
|
|
4116
|
+
tap
|
|
4036
4117
|
});
|
|
4037
4118
|
}
|
|
4038
4119
|
const heldAnswer = answers?.open(appId, ownerId) ?? null;
|
|
@@ -4045,7 +4126,10 @@ async function brokerCall(request, deps, holding) {
|
|
|
4045
4126
|
return refuse2(502, refusal.error, refusal.message);
|
|
4046
4127
|
}
|
|
4047
4128
|
if (read.outcome === "error") {
|
|
4048
|
-
if (request.signal.aborted)
|
|
4129
|
+
if (request.signal.aborted) {
|
|
4130
|
+
holding.tap?.end("cut", upstream.status);
|
|
4131
|
+
return callerLeft(record, safeHost(target));
|
|
4132
|
+
}
|
|
4049
4133
|
await record("upstream_error", 502, safeHost(target));
|
|
4050
4134
|
return refuse2(502, "upstream_error", read.error instanceof Error ? read.error.message : String(read.error));
|
|
4051
4135
|
}
|
|
@@ -4054,6 +4138,11 @@ async function brokerCall(request, deps, holding) {
|
|
|
4054
4138
|
return refuse2(502, "too_large", `The upstream returned more than ${limit} bytes.`);
|
|
4055
4139
|
}
|
|
4056
4140
|
const body = read.bytes;
|
|
4141
|
+
if (holding.tap !== null) {
|
|
4142
|
+
if (body.byteLength > 0) holding.tap.feed(body);
|
|
4143
|
+
holding.tap.end("complete", upstream.status);
|
|
4144
|
+
holding.tap = null;
|
|
4145
|
+
}
|
|
4057
4146
|
await record("allowed", upstream.status, safeHost(target));
|
|
4058
4147
|
const out2 = answerHeaders(upstream.headers);
|
|
4059
4148
|
const empty = upstream.status === 204 || upstream.status === 304;
|
|
@@ -4160,6 +4249,53 @@ function flowsTargetOf(request) {
|
|
|
4160
4249
|
function noFlowsAnswer() {
|
|
4161
4250
|
return refuse2(404, "no_flows", "Flows run in a preview: wirl preview, then wirl flows start <flow> --preview <name> --fast.");
|
|
4162
4251
|
}
|
|
4252
|
+
function aiTargetOf(request) {
|
|
4253
|
+
const target = request.headers.get("x-wirl-target");
|
|
4254
|
+
if (target === null) return null;
|
|
4255
|
+
let url;
|
|
4256
|
+
try {
|
|
4257
|
+
url = new URL(target);
|
|
4258
|
+
} catch {
|
|
4259
|
+
return null;
|
|
4260
|
+
}
|
|
4261
|
+
return url.hostname.toLowerCase().replace(/\.$/, "") === AI_HOST ? url : null;
|
|
4262
|
+
}
|
|
4263
|
+
function noAiAnswer() {
|
|
4264
|
+
return aiRefusal(503, "ai_unavailable", "Models are not available here.");
|
|
4265
|
+
}
|
|
4266
|
+
function aiVendorLabel(slug) {
|
|
4267
|
+
const vendor = slug.startsWith("ai:") ? slug.slice(3) : slug;
|
|
4268
|
+
return Object.hasOwn(AI_VENDOR_SPECS, vendor) ? AI_VENDOR_SPECS[vendor].label : slug;
|
|
4269
|
+
}
|
|
4270
|
+
function onceTap(tap) {
|
|
4271
|
+
let ended2 = false;
|
|
4272
|
+
let answered = false;
|
|
4273
|
+
return {
|
|
4274
|
+
answered(status) {
|
|
4275
|
+
if (ended2 || answered) return;
|
|
4276
|
+
answered = true;
|
|
4277
|
+
try {
|
|
4278
|
+
tap.answered?.(status);
|
|
4279
|
+
} catch {
|
|
4280
|
+
}
|
|
4281
|
+
},
|
|
4282
|
+
feed(chunk) {
|
|
4283
|
+
if (ended2) return;
|
|
4284
|
+
try {
|
|
4285
|
+
tap.feed(chunk);
|
|
4286
|
+
} catch {
|
|
4287
|
+
}
|
|
4288
|
+
},
|
|
4289
|
+
end(how, status) {
|
|
4290
|
+
if (ended2) return;
|
|
4291
|
+
ended2 = true;
|
|
4292
|
+
try {
|
|
4293
|
+
tap.end(how, status);
|
|
4294
|
+
} catch {
|
|
4295
|
+
}
|
|
4296
|
+
}
|
|
4297
|
+
};
|
|
4298
|
+
}
|
|
4163
4299
|
function answerHeaders(upstream) {
|
|
4164
4300
|
const out2 = new Headers();
|
|
4165
4301
|
upstream.forEach((value, name) => {
|
|
@@ -4182,6 +4318,7 @@ function declaredLength(headers) {
|
|
|
4182
4318
|
}
|
|
4183
4319
|
async function streamAnswer(upstream, body, context) {
|
|
4184
4320
|
const giveBack = () => context.held?.release();
|
|
4321
|
+
const tap = context.tap ?? null;
|
|
4185
4322
|
let release = giveBack;
|
|
4186
4323
|
if (context.slots !== null) {
|
|
4187
4324
|
const slot = context.slots.acquire(context.appId);
|
|
@@ -4189,6 +4326,7 @@ async function streamAnswer(upstream, body, context) {
|
|
|
4189
4326
|
await body.cancel().catch(() => {
|
|
4190
4327
|
});
|
|
4191
4328
|
giveBack();
|
|
4329
|
+
tap?.end("error", upstream.status);
|
|
4192
4330
|
await context.record("busy", 429);
|
|
4193
4331
|
return refuse2(
|
|
4194
4332
|
429,
|
|
@@ -4207,17 +4345,19 @@ async function streamAnswer(upstream, body, context) {
|
|
|
4207
4345
|
await body.cancel().catch(() => {
|
|
4208
4346
|
});
|
|
4209
4347
|
release();
|
|
4348
|
+
tap?.end("error", upstream.status);
|
|
4210
4349
|
throw error;
|
|
4211
4350
|
}
|
|
4212
4351
|
try {
|
|
4213
|
-
return new Response(
|
|
4214
|
-
|
|
4215
|
-
headers: answerHeaders(upstream.headers)
|
|
4216
|
-
|
|
4352
|
+
return new Response(
|
|
4353
|
+
passOn(body, context.limit, release, context.safetyMs, context.signal, context.closing, context.sent, context.sentMs, tap, upstream.status),
|
|
4354
|
+
{ status: upstream.status, headers: answerHeaders(upstream.headers) }
|
|
4355
|
+
);
|
|
4217
4356
|
} catch (error) {
|
|
4218
4357
|
await body.cancel().catch(() => {
|
|
4219
4358
|
});
|
|
4220
4359
|
release();
|
|
4360
|
+
tap?.end("error", upstream.status);
|
|
4221
4361
|
throw error;
|
|
4222
4362
|
}
|
|
4223
4363
|
}
|
|
@@ -4242,7 +4382,7 @@ async function eventSent(sent, ms) {
|
|
|
4242
4382
|
stop.abort();
|
|
4243
4383
|
}
|
|
4244
4384
|
}
|
|
4245
|
-
function passOn(body, limit, release, safetyMs, signal, closing, sent, sentMs) {
|
|
4385
|
+
function passOn(body, limit, release, safetyMs, signal, closing, sent, sentMs, tap = null, status = 200) {
|
|
4246
4386
|
const reader = body.getReader();
|
|
4247
4387
|
const says = closing && limit > CUT_EVENT_RESERVE_BYTES;
|
|
4248
4388
|
const room = says ? limit - CUT_EVENT_RESERVE_BYTES : limit;
|
|
@@ -4253,16 +4393,17 @@ function passOn(body, limit, release, safetyMs, signal, closing, sent, sentMs) {
|
|
|
4253
4393
|
let safety;
|
|
4254
4394
|
let appLeft = () => {
|
|
4255
4395
|
};
|
|
4256
|
-
const finish = () => {
|
|
4396
|
+
const finish = (how) => {
|
|
4257
4397
|
if (over) return false;
|
|
4258
4398
|
over = true;
|
|
4259
4399
|
clearTimeout(safety);
|
|
4260
4400
|
signal.removeEventListener("abort", appLeft);
|
|
4261
4401
|
release();
|
|
4402
|
+
tap?.end(how, status);
|
|
4262
4403
|
return true;
|
|
4263
4404
|
};
|
|
4264
4405
|
const cut2 = (controller, error, why) => {
|
|
4265
|
-
if (!finish()) return;
|
|
4406
|
+
if (!finish("cut")) return;
|
|
4266
4407
|
reader.cancel(error).catch(() => {
|
|
4267
4408
|
});
|
|
4268
4409
|
if (!says) {
|
|
@@ -4299,7 +4440,7 @@ function passOn(body, limit, release, safetyMs, signal, closing, sent, sentMs) {
|
|
|
4299
4440
|
timer.unref?.();
|
|
4300
4441
|
safety = timer;
|
|
4301
4442
|
appLeft = () => {
|
|
4302
|
-
if (!finish()) return;
|
|
4443
|
+
if (!finish("cut")) return;
|
|
4303
4444
|
reader.cancel(signal.reason).catch(() => {
|
|
4304
4445
|
});
|
|
4305
4446
|
controller.close();
|
|
@@ -4322,12 +4463,12 @@ function passOn(body, limit, release, safetyMs, signal, closing, sent, sentMs) {
|
|
|
4322
4463
|
} catch (error) {
|
|
4323
4464
|
const bound = error instanceof PinnedFetchError ? error.streamBound : void 0;
|
|
4324
4465
|
if (bound !== void 0) cut2(controller, error, { cause: bound.kind, ms: bound.ms });
|
|
4325
|
-
else if (finish()) controller.error(error);
|
|
4466
|
+
else if (finish("error")) controller.error(error);
|
|
4326
4467
|
return;
|
|
4327
4468
|
}
|
|
4328
4469
|
if (over) return;
|
|
4329
4470
|
if (next.done) {
|
|
4330
|
-
finish();
|
|
4471
|
+
finish("complete");
|
|
4331
4472
|
controller.close();
|
|
4332
4473
|
return;
|
|
4333
4474
|
}
|
|
@@ -4342,10 +4483,11 @@ function passOn(body, limit, release, safetyMs, signal, closing, sent, sentMs) {
|
|
|
4342
4483
|
);
|
|
4343
4484
|
return;
|
|
4344
4485
|
}
|
|
4486
|
+
tap?.feed(next.value);
|
|
4345
4487
|
controller.enqueue(next.value);
|
|
4346
4488
|
},
|
|
4347
4489
|
cancel(reason) {
|
|
4348
|
-
finish();
|
|
4490
|
+
finish("cut");
|
|
4349
4491
|
clearTimeout(unread);
|
|
4350
4492
|
return reader.cancel(reason).catch(() => {
|
|
4351
4493
|
});
|
|
@@ -4470,10 +4612,11 @@ function refuse2(status, reason, message, extra = {}) {
|
|
|
4470
4612
|
})
|
|
4471
4613
|
});
|
|
4472
4614
|
}
|
|
4473
|
-
var RESOLVE_BY_HOST, STRIPPED_PREFIXES, STRIPPED, HOP_PREFIXES, HOP_HEADERS, isHopHeader, HAND_OUT_BYTES, AUDIT_PATH_CHARS, STALLED, SLOW, FIRST_HOLD_BYTES, CREDENTIAL_SHAPES, isHttpKind, safeHost;
|
|
4615
|
+
var RESOLVE_BY_HOST, STRIPPED_PREFIXES, AI_KEY_HEADERS, STRIPPED, HOP_PREFIXES, HOP_HEADERS, isHopHeader, HAND_OUT_BYTES, AUDIT_PATH_CHARS, STALLED, SLOW, FIRST_HOLD_BYTES, CREDENTIAL_SHAPES, isHttpKind, safeHost;
|
|
4474
4616
|
var init_broker = __esm({
|
|
4475
4617
|
"packages/core/src/broker.ts"() {
|
|
4476
4618
|
"use strict";
|
|
4619
|
+
init_ai_vendors();
|
|
4477
4620
|
init_broker_bodies();
|
|
4478
4621
|
init_broker_stream();
|
|
4479
4622
|
init_broker_token();
|
|
@@ -4483,6 +4626,7 @@ var init_broker = __esm({
|
|
|
4483
4626
|
init_token();
|
|
4484
4627
|
RESOLVE_BY_HOST = "_";
|
|
4485
4628
|
STRIPPED_PREFIXES = ["x-wirl-"];
|
|
4629
|
+
AI_KEY_HEADERS = /* @__PURE__ */ new Set([...Object.values(AI_VENDOR_SPECS).map((spec) => spec.keyHeader), "api-key"]);
|
|
4486
4630
|
STRIPPED = /* @__PURE__ */ new Set([
|
|
4487
4631
|
"authorization",
|
|
4488
4632
|
"proxy-authorization",
|
|
@@ -4603,7 +4747,11 @@ var init_retention = __esm({
|
|
|
4603
4747
|
* The counts settle a "why was my app paused" question and the claims
|
|
4604
4748
|
* ration the mail; neither is worth anything two months on.
|
|
4605
4749
|
*/
|
|
4606
|
-
flowDayRowsDays: 60
|
|
4750
|
+
flowDayRowsDays: 60,
|
|
4751
|
+
/** A row of the AI call ledger is kept this long, then removed. */
|
|
4752
|
+
aiCallDays: 90,
|
|
4753
|
+
/** An AI monthly total (the rows the caps read) is kept this long, 24 months. */
|
|
4754
|
+
aiMonthDays: 730
|
|
4607
4755
|
};
|
|
4608
4756
|
DELETED_SHARE_MARK = "deleted; a restore brings this share back; its data is kept until";
|
|
4609
4757
|
}
|
|
@@ -4661,6 +4809,7 @@ own live deploy and a person's yes. Declare a host under \`connections\`, then \
|
|
|
4661
4809
|
wirl attaches the key. An undeclared host is refused; a public API needing no key uses \`"kind": "none"\`.
|
|
4662
4810
|
Declaring one deploys before anyone approves it; the deploy says per connection whether it is usable now or
|
|
4663
4811
|
waiting on an admin. A vendor has 60 seconds to answer, whole, unless it streams: ask a model for a stream.
|
|
4812
|
+
Models: \`https://ai.wirl/<vendor>/\u2026\` (openrouter, openai, anthropic, google) with the vendor's own API and no key; OpenRouter works out of the box on wirl's allowance, an org's own key lifts it. See Models.
|
|
4664
4813
|
|
|
4665
4814
|
Six headers say who is asking: \`x-wirl-user-id\`, \`x-wirl-user-email\`, \`x-wirl-user-name\`,
|
|
4666
4815
|
\`x-wirl-role\`, \`x-wirl-app-id\`, \`x-wirl-org\`. Read the name with
|
|
@@ -4713,7 +4862,7 @@ var init_version = __esm({
|
|
|
4713
4862
|
"packages/core/src/version.ts"() {
|
|
4714
4863
|
"use strict";
|
|
4715
4864
|
WORKSPACE_VERSION = "0.0.0";
|
|
4716
|
-
BUILT_VERSION = true ? "0.2.
|
|
4865
|
+
BUILT_VERSION = true ? "0.2.4" : null;
|
|
4717
4866
|
}
|
|
4718
4867
|
});
|
|
4719
4868
|
|
|
@@ -4767,6 +4916,22 @@ var init_connect = __esm({
|
|
|
4767
4916
|
}
|
|
4768
4917
|
});
|
|
4769
4918
|
|
|
4919
|
+
// packages/core/src/setup-prompt.ts
|
|
4920
|
+
function starterName(email) {
|
|
4921
|
+
const handle = (email ?? "").split("@")[0] ?? "";
|
|
4922
|
+
const slug = handle.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
4923
|
+
const cut2 = slug.slice(0, SLUG_MAX - STARTER_SUFFIX.length).replace(/-+$/, "");
|
|
4924
|
+
return cut2 === "" ? "starter" : `${cut2}${STARTER_SUFFIX}`;
|
|
4925
|
+
}
|
|
4926
|
+
var STARTER_SUFFIX;
|
|
4927
|
+
var init_setup_prompt = __esm({
|
|
4928
|
+
"packages/core/src/setup-prompt.ts"() {
|
|
4929
|
+
"use strict";
|
|
4930
|
+
init_slug();
|
|
4931
|
+
STARTER_SUFFIX = "-starter";
|
|
4932
|
+
}
|
|
4933
|
+
});
|
|
4934
|
+
|
|
4770
4935
|
// packages/core/src/porting.ts
|
|
4771
4936
|
function dependencyNames(packageJson) {
|
|
4772
4937
|
if (packageJson === void 0) return [];
|
|
@@ -5099,6 +5264,7 @@ var init_src = __esm({
|
|
|
5099
5264
|
init_slug();
|
|
5100
5265
|
init_token();
|
|
5101
5266
|
init_broker_token();
|
|
5267
|
+
init_broker_status();
|
|
5102
5268
|
init_egress();
|
|
5103
5269
|
init_manifest();
|
|
5104
5270
|
init_sql_lex();
|
|
@@ -5119,12 +5285,14 @@ var init_src = __esm({
|
|
|
5119
5285
|
init_install();
|
|
5120
5286
|
init_share_role();
|
|
5121
5287
|
init_connect();
|
|
5288
|
+
init_setup_prompt();
|
|
5122
5289
|
init_porting();
|
|
5123
5290
|
init_throttle_report();
|
|
5124
5291
|
init_supplied_bundle();
|
|
5125
5292
|
init_manifest_transport();
|
|
5126
5293
|
init_preview();
|
|
5127
5294
|
init_visit_token();
|
|
5295
|
+
init_ai_vendors();
|
|
5128
5296
|
}
|
|
5129
5297
|
});
|
|
5130
5298
|
|
|
@@ -6384,7 +6552,7 @@ function searchGroup(text) {
|
|
|
6384
6552
|
]
|
|
6385
6553
|
}
|
|
6386
6554
|
];
|
|
6387
|
-
if (
|
|
6555
|
+
if (STATUS_TEXT2.test(text)) filters.push({ key: "$workers.event.response.status", type: "number", operation: "eq", value: Number(text) });
|
|
6388
6556
|
return { kind: "group", filterCombination: "or", filters };
|
|
6389
6557
|
}
|
|
6390
6558
|
function kindFilters(kind) {
|
|
@@ -6405,7 +6573,7 @@ function kindFilters(kind) {
|
|
|
6405
6573
|
return [];
|
|
6406
6574
|
}
|
|
6407
6575
|
}
|
|
6408
|
-
var LogSourceError, DEFAULTS3, DATASET, MAX_EVENTS, RUN_HEADER, PUBLIC_HEADER, FLOW_HEADER, NOT_A_FLOW_STEP, serviceFilter, failedGroup,
|
|
6576
|
+
var LogSourceError, DEFAULTS3, DATASET, MAX_EVENTS, RUN_HEADER, PUBLIC_HEADER, FLOW_HEADER, NOT_A_FLOW_STEP, serviceFilter, failedGroup, STATUS_TEXT2, WorkersLogSource;
|
|
6409
6577
|
var init_workers_logs = __esm({
|
|
6410
6578
|
"packages/runtime/src/workers-logs.ts"() {
|
|
6411
6579
|
"use strict";
|
|
@@ -6434,7 +6602,7 @@ var init_workers_logs = __esm({
|
|
|
6434
6602
|
{ key: "$workers.outcome", type: "string", operation: "neq", value: "ok" }
|
|
6435
6603
|
]
|
|
6436
6604
|
});
|
|
6437
|
-
|
|
6605
|
+
STATUS_TEXT2 = /^\d{3}$/;
|
|
6438
6606
|
WorkersLogSource = class {
|
|
6439
6607
|
#opts;
|
|
6440
6608
|
constructor(options) {
|
|
@@ -7240,11 +7408,12 @@ company email domain; in any other org, including a personal one or one
|
|
|
7240
7408
|
made with \`wirl orgs create\`, it starts private. Deploys are one
|
|
7241
7409
|
command; sharing is by email; a third-party API is declared, never pasted.`;
|
|
7242
7410
|
var SKILL_DOCS_LINE = "Before you build, fetch https://docs.wirl.dev/llms-full.txt: every page of the docs as one Markdown file, so there is no need to read the site page by page.";
|
|
7243
|
-
var SKILL_RULES = `1. **Never ask for or paste an API key.** Declare the connection in \`wirl.json\` and deploy. Needs no key at all (a public API)? Declare it with "kind": "none" and its hosts. If the key is missing, the deploy prints a link to the app's keys page; show the person that link. Locally, keys live in \`.wirl/keys.json\`, which git ignores. To try a vendor call locally without the key, map its connection to a server of your own in \`.wirl/mocks.json\`, like \`{ "anthropic": "http://127.0.0.1:4010" }\`: \`wirl dev\` then sends the calls to that connection's hosts there instead (\`wirl dev --help\` has the rest).
|
|
7411
|
+
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). Models need no key either: call https://ai.wirl/<vendor>/\u2026 (see Models).
|
|
7244
7412
|
2. **Call third-party APIs by their real URL** (\`fetch("https://api.hubspot.com/...")\`). wirl attaches the key on the way out, deployed and under \`wirl dev\` alike.
|
|
7245
|
-
3.
|
|
7246
|
-
4.
|
|
7247
|
-
5.
|
|
7413
|
+
3. **Asked to connect an app to Slack, or to receive events from any outside service:** follow the pattern on https://docs.wirl.dev/models (public path with "verify": "slack", a connection of kind http_bearer for the bot token and one of kind webhook_secret for the signing secret, the Slack app manifest you give the person to create the app with). Never ask for the token or secret in chat: the person pastes them on the app's Connections page, and you say which two values.
|
|
7414
|
+
4. If a command names several organisations and asks which, ask the person; pass \`--org\` or put \`"org"\` in \`wirl.json\`.
|
|
7415
|
+
5. To keep a file out of the deploy, list it in a \`.wirlignore\` at the project root (gitignore-style lines; no \`!\` negation).
|
|
7416
|
+
6. **A file that can lose data waits for a person.** A deploy that would apply a destructive file (one wirl.json's \`"destructive"\` list must name) to the live database stops and names the page where a person approves each one. Show the person those pages, and deploy again once they say they approved. If \`${WIRL_BIN} deploy\` asks you to type a file's name, stop it and show the page instead, or deploy with \`--json\`, which never asks. Never type the name yourself or send the confirmation: that is the person's, and no flag skips it.`;
|
|
7248
7417
|
var SKILL_MANIFEST_EXAMPLE = `\`\`\`json
|
|
7249
7418
|
{ "app": "crm-sync", "server": "server.mjs", "connections": [{ "slug": "hubspot", "hosts": ["api.hubspot.com"] }] }
|
|
7250
7419
|
\`\`\``;
|
|
@@ -7291,6 +7460,465 @@ ${FLOWS_CONTRACT}
|
|
|
7291
7460
|
`;
|
|
7292
7461
|
}
|
|
7293
7462
|
|
|
7463
|
+
// packages/cli/src/starter.ts
|
|
7464
|
+
init_src();
|
|
7465
|
+
var STARTER_CONFLICT = "Choose one: --server or --starter.";
|
|
7466
|
+
var SUGGESTIONS = [
|
|
7467
|
+
{
|
|
7468
|
+
number: 1,
|
|
7469
|
+
title: "Summarise the week",
|
|
7470
|
+
capability: "A model comes with every app. Your agent just uses it; nothing to sign up for or paste.",
|
|
7471
|
+
prompt: `Add a "Summarise this week" button above the notes. When pressed, send this week's notes to a language model and show a three-line summary at the top of the page. Try it with wirl dev, then deploy it with wirl deploy. Keep the latest summary so everyone who opens the page sees it.`,
|
|
7472
|
+
youGet: "a summary card above the notes."
|
|
7473
|
+
},
|
|
7474
|
+
{
|
|
7475
|
+
number: 2,
|
|
7476
|
+
title: "Give notes a due date",
|
|
7477
|
+
capability: "The database is yours. Changing its shape is one migration file and one deploy; wirl applies it before the new version goes live, and the Data page on the dashboard shows the result.",
|
|
7478
|
+
prompt: "Add an optional due date to notes. Add the column in a new migration file with the next number, and never edit a migration that has already been applied. 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.",
|
|
7479
|
+
youGet: "a date on each note, the soonest first, and a new column on the Data page."
|
|
7480
|
+
},
|
|
7481
|
+
{
|
|
7482
|
+
number: 3,
|
|
7483
|
+
title: "Pull in something from outside",
|
|
7484
|
+
capability: "Every call out goes through wirl. The agent declares the host as a connection; a public API needs no key, and one that does gets its key on the dashboard, never in the code.",
|
|
7485
|
+
prompt: `Show today's weather under the notes for our city (ask me which city if you don't know), from a public weather API that needs no key. Declare the API's host as a connection in wirl.json with "kind": "none". Try it with wirl dev, then deploy it with wirl deploy. If the deploy says the connection is waiting on an admin, show me the link it prints.`,
|
|
7486
|
+
youGet: "a weather line under the notes, and the host listed on the app's Connections page."
|
|
7487
|
+
},
|
|
7488
|
+
{
|
|
7489
|
+
number: 4,
|
|
7490
|
+
title: "Try a new look before anyone sees it",
|
|
7491
|
+
// The mock says "Previews are free."; the starter carries no price words (its test forbids "free").
|
|
7492
|
+
capability: "Previews are a safe place to try. A preview gets its own address and its own database, so you can look at a change with a colleague before it goes live.",
|
|
7493
|
+
prompt: "Give the noticeboard a calmer look: more space between notes, a softer background, the newest note set apart at the top. Put it up as a preview called new-look with wirl preview and give me the link. Don't deploy it yet. Keep the change on a branch and don't run wirl deploy until I say deploy it.",
|
|
7494
|
+
youGet: 'a second address to open next to this one. Say "deploy it" when you like it.'
|
|
7495
|
+
},
|
|
7496
|
+
{
|
|
7497
|
+
number: 5,
|
|
7498
|
+
title: "Share it with a colleague",
|
|
7499
|
+
capability: "Company sign-in is already on. Anyone you name opens the link with their work account; the Access page shows who can, and you can take it back any time.",
|
|
7500
|
+
prompt: "Help me share this app with a colleague: tell me how to give them access and the link they open.",
|
|
7501
|
+
youGet: "their name on the Access page, and a link they open with their work account."
|
|
7502
|
+
},
|
|
7503
|
+
{
|
|
7504
|
+
number: 6,
|
|
7505
|
+
title: "Connect it to Slack",
|
|
7506
|
+
capability: "Apps can reach the tools your company already uses. Your agent knows wirl's Slack pattern: it gives you a Slack app to create in one click, you paste two values on the app's Connections page, and the noticeboard starts a DM with you. Ask it about the notes, or tell it to add one, from Slack.",
|
|
7507
|
+
prompt: "Connect this noticeboard to Slack so it DMs me this week's summary and I can chat with it there: ask it about the notes, or tell it to add or change one. Give me the Slack app manifest to create the app with, and tell me which two values to paste on the app's Connections page.",
|
|
7508
|
+
youGet: "a DM from the noticeboard in Slack. Everything above works without it.",
|
|
7509
|
+
optional: true
|
|
7510
|
+
}
|
|
7511
|
+
];
|
|
7512
|
+
var splitCapability = (capability) => {
|
|
7513
|
+
const stop = capability.indexOf(". ");
|
|
7514
|
+
return [capability.slice(0, stop + 1), capability.slice(stop + 2)];
|
|
7515
|
+
};
|
|
7516
|
+
var SUGGESTIONS_SOURCE = [
|
|
7517
|
+
"const SUGGESTIONS = [",
|
|
7518
|
+
...SUGGESTIONS.map(
|
|
7519
|
+
({ number, title, capability, prompt, youGet, optional }) => ` { number: ${String(number)}, title: ${JSON.stringify(title)}, capability: ${JSON.stringify(capability)}, prompt: ${JSON.stringify(prompt)}, youGet: ${JSON.stringify(youGet)}${optional ? ", optional: true" : ""} },`
|
|
7520
|
+
),
|
|
7521
|
+
"];"
|
|
7522
|
+
].join("\n");
|
|
7523
|
+
var SERVER_HEAD = String.raw`/**
|
|
7524
|
+
* Team noticeboard: your first app on wirl.
|
|
7525
|
+
*
|
|
7526
|
+
* Your team posts short notes here, tags them, pins the ones everyone should
|
|
7527
|
+
* see, and edits or removes their own. Every night a schedule archives old
|
|
7528
|
+
* notes nobody pinned. Below the notes, "Make it yours": six prompts to
|
|
7529
|
+
* paste to your coding agent, in order, each saying what wirl gives the app
|
|
7530
|
+
* and what you get. Under them, the app's pages on app.wirl.dev.
|
|
7531
|
+
*
|
|
7532
|
+
* The whole app is this one file:
|
|
7533
|
+
* - The page is HTML this module returns: plain forms, no script.
|
|
7534
|
+
* - Notes live in env.DB, a SQLite database that belongs to this app alone.
|
|
7535
|
+
* Its tables are in migrations/0001_init.sql, and wirl applies each new
|
|
7536
|
+
* file in migrations/ before a version goes live. Put the next change in
|
|
7537
|
+
* migrations/0002_<what>.sql rather than editing that one.
|
|
7538
|
+
* - Who is asking arrives in headers wirl sets after signing them in, which
|
|
7539
|
+
* the browser cannot forge. Who may change a note is a line of code here:
|
|
7540
|
+
* a check in the page is only a suggestion to whoever holds the browser.
|
|
7541
|
+
* - The nightly schedule is declared in wirl.json. wirl sends it here as a
|
|
7542
|
+
* POST to /__wirl/schedule/tidy, and what it answers becomes the run's note.
|
|
7543
|
+
* - console.log lines appear on the app's Logs page, and in wirl logs.
|
|
7544
|
+
*/
|
|
7545
|
+
|
|
7546
|
+
const TITLE = "Team noticeboard";
|
|
7547
|
+
const DASHBOARD = "https://app.wirl.dev";
|
|
7548
|
+
const ARCHIVE_AFTER_DAYS = 30;
|
|
7549
|
+
const LONGEST_NOTE = 2000;
|
|
7550
|
+
|
|
7551
|
+
export default {
|
|
7552
|
+
async fetch(request, env) {
|
|
7553
|
+
const url = new URL(request.url);
|
|
7554
|
+
// One line per request, on the Logs page.
|
|
7555
|
+
console.log(request.method, url.pathname);
|
|
7556
|
+
|
|
7557
|
+
// The nightly tidy-up, sent by wirl at the time wirl.json gives. wirl
|
|
7558
|
+
// names the schedule in x-wirl-schedule as well as in the path, and a
|
|
7559
|
+
// request without that header is not one wirl sent.
|
|
7560
|
+
if (url.pathname.startsWith("/__wirl/schedule/")) {
|
|
7561
|
+
const name = url.pathname.slice("/__wirl/schedule/".length);
|
|
7562
|
+
const fromWirl = request.headers.get("x-wirl-schedule") === name;
|
|
7563
|
+
if (name === "tidy" && request.method === "POST" && fromWirl) return tidy(env);
|
|
7564
|
+
return text("No such schedule", 404);
|
|
7565
|
+
}
|
|
7566
|
+
|
|
7567
|
+
const me = visitor(request);
|
|
7568
|
+
|
|
7569
|
+
if (request.method === "POST") {
|
|
7570
|
+
// A form posted from another site is never one of yours.
|
|
7571
|
+
if (request.headers.get("sec-fetch-site") === "cross-site") return text("Refused: this form came from another site.", 403);
|
|
7572
|
+
if (me.id === "") return text("Sign in to change the board.", 403);
|
|
7573
|
+
let form;
|
|
7574
|
+
try {
|
|
7575
|
+
form = await request.formData();
|
|
7576
|
+
} catch {
|
|
7577
|
+
return text("Send this as a form.", 400);
|
|
7578
|
+
}
|
|
7579
|
+
return change(url.pathname, form, me, env);
|
|
7580
|
+
}
|
|
7581
|
+
|
|
7582
|
+
if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) {
|
|
7583
|
+
return html(await page(request, url, me, env));
|
|
7584
|
+
}
|
|
7585
|
+
return text("Not found", 404);
|
|
7586
|
+
},
|
|
7587
|
+
};
|
|
7588
|
+
|
|
7589
|
+
// Set by wirl after it signed this person in. Never take identity from a form
|
|
7590
|
+
// or the query string: those say whatever the browser wants them to say. A
|
|
7591
|
+
// header carries only Latin-1 and a name can be anything, so wirl
|
|
7592
|
+
// percent-encodes the name; it is decoded here.
|
|
7593
|
+
function visitor(request) {
|
|
7594
|
+
const header = (name) => decode(request.headers.get(name) || "");
|
|
7595
|
+
return {
|
|
7596
|
+
id: header("x-wirl-user-id"),
|
|
7597
|
+
name: header("x-wirl-user-name") || header("x-wirl-user-email") || "someone",
|
|
7598
|
+
role: header("x-wirl-role"),
|
|
7599
|
+
};
|
|
7600
|
+
}
|
|
7601
|
+
|
|
7602
|
+
// POST /notes, and POST /notes/<id>/<edit|pin|remove|restore>.
|
|
7603
|
+
async function change(path, form, me, env) {
|
|
7604
|
+
const [, first, id, action] = path.split("/");
|
|
7605
|
+
if (first !== "notes") return text("Not found", 404);
|
|
7606
|
+
|
|
7607
|
+
if (id === undefined) {
|
|
7608
|
+
const body = clean(form.get("body"), LONGEST_NOTE);
|
|
7609
|
+
if (body === "") return text("A note needs some words.", 400);
|
|
7610
|
+
const { meta } = await env.DB.prepare("insert into notes (body, tags, author_id, author_name) values (?, ?, ?, ?)")
|
|
7611
|
+
.bind(body, tags(form.get("tags")), me.id, me.name)
|
|
7612
|
+
.run();
|
|
7613
|
+
console.log("note", meta.last_row_id, "posted by", me.id);
|
|
7614
|
+
return home();
|
|
7615
|
+
}
|
|
7616
|
+
|
|
7617
|
+
const number = Number.parseInt(id, 10);
|
|
7618
|
+
if (String(number) !== id) return text("Not found", 404);
|
|
7619
|
+
const note = await env.DB.prepare("select id, author_id from notes where id = ?").bind(number).first();
|
|
7620
|
+
if (!note) return text("No such note", 404);
|
|
7621
|
+
|
|
7622
|
+
// The rule that matters, in code: authors change their own notes, and the
|
|
7623
|
+
// app's owner can change any. Anyone on the board can pin.
|
|
7624
|
+
const mine = note.author_id === me.id || me.role === "owner";
|
|
7625
|
+
|
|
7626
|
+
if (action === "pin") {
|
|
7627
|
+
await env.DB.prepare("update notes set pinned = 1 - pinned where id = ?").bind(number).run();
|
|
7628
|
+
console.log("note", number, "pin changed by", me.id);
|
|
7629
|
+
return home();
|
|
7630
|
+
}
|
|
7631
|
+
if (!mine) return text("Only the person who posted this note, or the app's owner, can change it.", 403);
|
|
7632
|
+
|
|
7633
|
+
if (action === "edit") {
|
|
7634
|
+
const body = clean(form.get("body"), LONGEST_NOTE);
|
|
7635
|
+
if (body === "") return text("A note needs some words.", 400);
|
|
7636
|
+
await env.DB.prepare("update notes set body = ?, tags = ? where id = ?").bind(body, tags(form.get("tags")), number).run();
|
|
7637
|
+
console.log("note", number, "edited by", me.id);
|
|
7638
|
+
return home();
|
|
7639
|
+
}
|
|
7640
|
+
if (action === "remove") {
|
|
7641
|
+
await env.DB.prepare("delete from notes where id = ?").bind(number).run();
|
|
7642
|
+
console.log("note", number, "removed by", me.id);
|
|
7643
|
+
return home();
|
|
7644
|
+
}
|
|
7645
|
+
if (action === "restore") {
|
|
7646
|
+
// Back on the board as if posted today, so tonight's tidy-up leaves it be.
|
|
7647
|
+
await env.DB.prepare("update notes set archived_at = null, created_at = datetime('now') where id = ?").bind(number).run();
|
|
7648
|
+
console.log("note", number, "put back by", me.id);
|
|
7649
|
+
return home("/?view=archived");
|
|
7650
|
+
}
|
|
7651
|
+
return text("Not found", 404);
|
|
7652
|
+
}
|
|
7653
|
+
|
|
7654
|
+
// The schedule: archive notes older than ARCHIVE_AFTER_DAYS that nobody
|
|
7655
|
+
// pinned, and keep a line saying so, which the page shows as the last run.
|
|
7656
|
+
async function tidy(env) {
|
|
7657
|
+
const { meta } = await env.DB.prepare(
|
|
7658
|
+
"update notes set archived_at = datetime('now') where archived_at is null and pinned = 0 and created_at < datetime('now', ?)",
|
|
7659
|
+
)
|
|
7660
|
+
.bind("-" + ARCHIVE_AFTER_DAYS + " days")
|
|
7661
|
+
.run();
|
|
7662
|
+
const note = "Archived " + meta.changes + (meta.changes === 1 ? " note" : " notes") + " older than " + ARCHIVE_AFTER_DAYS + " days.";
|
|
7663
|
+
await env.DB.prepare("insert into runs (schedule, note) values (?, ?)").bind("tidy", note).run();
|
|
7664
|
+
console.log("tidy:", note);
|
|
7665
|
+
return text(note);
|
|
7666
|
+
}
|
|
7667
|
+
|
|
7668
|
+
async function page(request, url, me, env) {
|
|
7669
|
+
const tag = tags(url.searchParams.get("tag")).split(",")[0];
|
|
7670
|
+
const archived = url.searchParams.get("view") === "archived";
|
|
7671
|
+
let sql = "select id, body, tags, pinned, author_id, author_name, created_at from notes where archived_at is " + (archived ? "not null" : "null");
|
|
7672
|
+
const binds = [];
|
|
7673
|
+
if (tag !== "") {
|
|
7674
|
+
sql += " and (',' || tags || ',') like ?";
|
|
7675
|
+
binds.push("%," + tag + ",%");
|
|
7676
|
+
}
|
|
7677
|
+
sql += " order by pinned desc, id desc limit 200";
|
|
7678
|
+
const notes = (await env.DB.prepare(sql).bind(...binds).all()).results;
|
|
7679
|
+
const archivedCount = await env.DB.prepare("select count(*) as n from notes where archived_at is not null").first("n");
|
|
7680
|
+
const lastRun = await env.DB.prepare("select note, ran_at from runs where schedule = 'tidy' order by id desc limit 1").first();
|
|
7681
|
+
|
|
7682
|
+
const shown = archived
|
|
7683
|
+
? "<p class='dim'>Notes the nightly tidy-up moved off the board. <a href='/'>Back to the board</a></p>"
|
|
7684
|
+
: form() +
|
|
7685
|
+
"<p class='dim filters'>" +
|
|
7686
|
+
(tag === "" ? "All notes" : "Tagged <b>" + escape(tag) + "</b> · <a href='/'>show all</a>") +
|
|
7687
|
+
" · <a href='/?view=archived'>Archived (" + archivedCount + ")</a></p>";
|
|
7688
|
+
|
|
7689
|
+
return [
|
|
7690
|
+
"<!doctype html>",
|
|
7691
|
+
"<html lang='en'>",
|
|
7692
|
+
"<meta charset='utf-8'>",
|
|
7693
|
+
"<meta name='viewport' content='width=device-width, initial-scale=1'>",
|
|
7694
|
+
"<title>" + TITLE + "</title>",
|
|
7695
|
+
"<style>" + STYLE + "</style>",
|
|
7696
|
+
"<main>",
|
|
7697
|
+
"<header><h1>" + TITLE + "</h1>",
|
|
7698
|
+
"<p class='dim'>Your first app on wirl. Signed in as <b>" + escape(me.name) + "</b>" + (me.role ? " (" + escape(me.role) + ")" : "") + ".</p></header>",
|
|
7699
|
+
shown,
|
|
7700
|
+
"<ul class='notes'>" + (notes.length === 0 ? "<li class='dim'>Nothing here yet.</li>" : notes.map((note) => card(note, me, archived)).join("")) + "</ul>",
|
|
7701
|
+
"<p class='dim'>Nightly tidy-up, which archives notes older than " + ARCHIVE_AFTER_DAYS + " days that nobody pinned: " +
|
|
7702
|
+
(lastRun ? "last run " + escape(lastRun.ran_at.slice(0, 16)) + " UTC: " + escape(lastRun.note) : "Not run yet.") + "</p>",
|
|
7703
|
+
prompts(request),
|
|
7704
|
+
"</main>",
|
|
7705
|
+
"</html>",
|
|
7706
|
+
].join("\n");
|
|
7707
|
+
}
|
|
7708
|
+
|
|
7709
|
+
function form() {
|
|
7710
|
+
return [
|
|
7711
|
+
"<form class='add' method='post' action='/notes'>",
|
|
7712
|
+
"<textarea name='body' rows='3' maxlength='" + LONGEST_NOTE + "' required placeholder='Post a note for the team'></textarea>",
|
|
7713
|
+
"<input name='tags' placeholder='Tags, separated by commas'>",
|
|
7714
|
+
"<button class='primary'>Post</button>",
|
|
7715
|
+
"</form>",
|
|
7716
|
+
].join("");
|
|
7717
|
+
}
|
|
7718
|
+
|
|
7719
|
+
function card(note, me, archived) {
|
|
7720
|
+
const mine = note.author_id === me.id || me.role === "owner";
|
|
7721
|
+
const action = (what, label) =>
|
|
7722
|
+
"<form method='post' action='/notes/" + note.id + "/" + what + "'><button>" + label + "</button></form>";
|
|
7723
|
+
const tagLinks = note.tags
|
|
7724
|
+
.split(",")
|
|
7725
|
+
.filter((tag) => tag !== "")
|
|
7726
|
+
.map((tag) => "<a class='tag' href='/?tag=" + encodeURIComponent(tag) + "'>#" + escape(tag) + "</a>")
|
|
7727
|
+
.join(" ");
|
|
7728
|
+
const controls = archived
|
|
7729
|
+
? (mine ? action("restore", "Put back") : "")
|
|
7730
|
+
: action("pin", note.pinned ? "Unpin" : "Pin") +
|
|
7731
|
+
(mine
|
|
7732
|
+
? "<details><summary>Edit</summary><form class='edit' method='post' action='/notes/" + note.id + "/edit'>" +
|
|
7733
|
+
"<textarea name='body' rows='3' maxlength='" + LONGEST_NOTE + "' required>" + escape(note.body) + "</textarea>" +
|
|
7734
|
+
"<input name='tags' value='" + escape(note.tags.split(",").join(", ")) + "'>" +
|
|
7735
|
+
"<button class='primary'>Save</button></form></details>" +
|
|
7736
|
+
action("remove", "Remove")
|
|
7737
|
+
: "");
|
|
7738
|
+
return (
|
|
7739
|
+
"<li class='note" + (note.pinned && !archived ? " pinned" : "") + "'>" +
|
|
7740
|
+
"<p>" + escape(note.body) + "</p>" +
|
|
7741
|
+
"<div class='meta dim'>" + (note.pinned && !archived ? "<b>Pinned</b> · " : "") +
|
|
7742
|
+
escape(note.author_name || "someone") + " · " + escape(note.created_at.slice(0, 10)) + (tagLinks ? " · " + tagLinks : "") + "</div>" +
|
|
7743
|
+
"<div class='actions'>" + controls + "</div>" +
|
|
7744
|
+
"</li>"
|
|
7745
|
+
);
|
|
7746
|
+
}
|
|
7747
|
+
|
|
7748
|
+
// The app's pages on the dashboard, from the address it was opened at:
|
|
7749
|
+
// <org>--<app>.wirl.run, or <org>--<app>--<preview>.wirl.run for a preview.
|
|
7750
|
+
// Under wirl dev the address is 127.0.0.1, which names no app: null.
|
|
7751
|
+
function dashboard(request) {
|
|
7752
|
+
const label = new URL(request.url).hostname.split(".")[0];
|
|
7753
|
+
const parts = label.split("--");
|
|
7754
|
+
if (parts.length < 2) return null;
|
|
7755
|
+
const org = encodeURIComponent(request.headers.get("x-wirl-org") || parts[0]);
|
|
7756
|
+
return { app: DASHBOARD + "/apps/" + org + "/" + encodeURIComponent(parts[1]), org: DASHBOARD + "/orgs/" + org };
|
|
7757
|
+
}
|
|
7758
|
+
|
|
7759
|
+
// The onboarding path: each prompt whole, visible and ready to select and
|
|
7760
|
+
// copy, with what wirl gives the app and what you get. Then what the app
|
|
7761
|
+
// already uses, and its pages on the dashboard.
|
|
7762
|
+
function prompts(request) {
|
|
7763
|
+
const where = dashboard(request);
|
|
7764
|
+
const pages = [
|
|
7765
|
+
["Overview", "app", ""],
|
|
7766
|
+
["Data", "app", "/data"],
|
|
7767
|
+
["Access", "app", "/access"],
|
|
7768
|
+
["Logs", "app", "/logs"],
|
|
7769
|
+
["Deployments", "app", "/deployments"],
|
|
7770
|
+
["Models", "org", "/models"],
|
|
7771
|
+
["Connections", "app", "/connections"],
|
|
7772
|
+
["Settings", "app", "/settings"],
|
|
7773
|
+
];
|
|
7774
|
+
const link = ([label, of, path]) =>
|
|
7775
|
+
where === null ? "<span class='dim'>" + label + "</span>" : "<a href='" + escape(where[of] + path) + "'>" + label + "</a>";
|
|
7776
|
+
const step = (each) => {
|
|
7777
|
+
const stop = each.capability.indexOf(". ");
|
|
7778
|
+
return (
|
|
7779
|
+
"<li class='step" + (each.optional ? " optional" : "") + "'>" +
|
|
7780
|
+
"<h3><span class='number'>" + each.number + "</span> " + escape(each.title) +
|
|
7781
|
+
(each.optional ? " <span class='badge'>Optional. The big one.</span>" : "") + "</h3>" +
|
|
7782
|
+
"<p class='dim'><b>" + escape(each.capability.slice(0, stop + 1)) + "</b> " + escape(each.capability.slice(stop + 2)) + "</p>" +
|
|
7783
|
+
"<pre>" + escape(each.prompt) + "</pre>" +
|
|
7784
|
+
"<p class='dim'>You get: " + escape(each.youGet) + "</p>" +
|
|
7785
|
+
"</li>"
|
|
7786
|
+
);
|
|
7787
|
+
};
|
|
7788
|
+
return [
|
|
7789
|
+
"<section id='prompts'><h2>Make it yours</h2>",
|
|
7790
|
+
"<p>This app is live, at a link your team can open. Everything below is a whole prompt. Copy one, paste it to the coding agent that made this app, and the feature appears here on the next deploy. Go in order or skip around; the last one is the big one.</p>",
|
|
7791
|
+
"<ol class='steps'>",
|
|
7792
|
+
...SUGGESTIONS.map(step),
|
|
7793
|
+
"</ol>",
|
|
7794
|
+
"<p class='dim uses'>This app already uses a database, a nightly schedule, company sign-in and logs. Each has a page on the dashboard: " + pages.map(link).join(" · ") + "</p>",
|
|
7795
|
+
where === 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>" : "",
|
|
7796
|
+
"</section>",
|
|
7797
|
+
].join("\n");
|
|
7798
|
+
}
|
|
7799
|
+
|
|
7800
|
+
`;
|
|
7801
|
+
var SERVER_TAIL = String.raw`
|
|
7802
|
+
|
|
7803
|
+
const STYLE = [
|
|
7804
|
+
":root { color-scheme: light dark; --ink: #1d1d1b; --dim: #66655f; --line: #e2e0d9; --card: #ffffff; --bg: #f7f6f2; --pin: #fff3bf; --accent: #2b6b5a; }",
|
|
7805
|
+
"@media (prefers-color-scheme: dark) { :root { --ink: #ecebe6; --dim: #a6a59f; --line: #36352f; --card: #1f1f1c; --bg: #161614; --pin: #3b3420; --accent: #8fd1bd; } }",
|
|
7806
|
+
"body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, sans-serif; }",
|
|
7807
|
+
"main { max-width: 760px; margin: 0 auto; padding: 24px 16px 48px; }",
|
|
7808
|
+
"h1 { font-size: 24px; margin: 0 0 4px; } h2 { font-size: 17px; margin: 36px 0 8px; }",
|
|
7809
|
+
"a { color: var(--accent); } .dim { color: var(--dim); }",
|
|
7810
|
+
"form.add, form.edit { display: grid; gap: 8px; margin: 16px 0 8px; }",
|
|
7811
|
+
"textarea, input, button { font: inherit; color: inherit; padding: 7px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--card); }",
|
|
7812
|
+
"button { cursor: pointer; } button.primary { justify-self: start; background: var(--accent); border-color: var(--accent); color: var(--bg); }",
|
|
7813
|
+
"ul.notes { list-style: none; padding: 0; display: grid; gap: 10px; }",
|
|
7814
|
+
".note { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }",
|
|
7815
|
+
".note.pinned { background: var(--pin); }",
|
|
7816
|
+
".note p { margin: 0 0 6px; white-space: pre-wrap; overflow-wrap: anywhere; }",
|
|
7817
|
+
".meta, .actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 13px; }",
|
|
7818
|
+
".actions { margin-top: 8px; } .actions form { margin: 0; } .actions details { flex-basis: 100%; order: 3; }",
|
|
7819
|
+
".tag { text-decoration: none; }",
|
|
7820
|
+
"ol.steps { list-style: none; padding: 0; display: grid; gap: 12px; }",
|
|
7821
|
+
".step { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }",
|
|
7822
|
+
".step.optional { border: 2px solid var(--accent); }",
|
|
7823
|
+
".step h3 { font-size: 16px; margin: 0 0 6px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }",
|
|
7824
|
+
".step p { margin: 6px 0; } .step b { color: var(--accent); }",
|
|
7825
|
+
".number { font-size: 12px; background: var(--ink); color: var(--bg); padding: 2px 7px; border-radius: 5px; }",
|
|
7826
|
+
".optional .number { background: var(--accent); }",
|
|
7827
|
+
".badge { font-size: 12px; font-weight: 600; color: var(--dim); border: 1px solid var(--line); padding: 1px 8px; border-radius: 999px; }",
|
|
7828
|
+
".uses { border-top: 1px solid var(--line); padding-top: 12px; font-size: 14px; }",
|
|
7829
|
+
"pre { white-space: pre-wrap; user-select: all; background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 10px; font-size: 13px; }",
|
|
7830
|
+
"details { margin: 6px 0; } summary { cursor: pointer; }",
|
|
7831
|
+
].join("\n");
|
|
7832
|
+
|
|
7833
|
+
const home = (to = "/") => new Response(null, { status: 303, headers: { location: to } });
|
|
7834
|
+
|
|
7835
|
+
const text = (body, status = 200) => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
7836
|
+
|
|
7837
|
+
const html = (markup) => new Response(markup, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
7838
|
+
|
|
7839
|
+
// Escapes text for HTML, in content and in quoted attribute values alike.
|
|
7840
|
+
const escape = (value) =>
|
|
7841
|
+
String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
7842
|
+
|
|
7843
|
+
const decode = (value) => {
|
|
7844
|
+
try {
|
|
7845
|
+
return decodeURIComponent(value);
|
|
7846
|
+
} catch {
|
|
7847
|
+
return value;
|
|
7848
|
+
}
|
|
7849
|
+
};
|
|
7850
|
+
|
|
7851
|
+
// A form field as trimmed text, at most max characters.
|
|
7852
|
+
const clean = (value, max) => (typeof value === "string" ? value.trim().slice(0, max) : "");
|
|
7853
|
+
|
|
7854
|
+
// "Team, Stand-up" becomes "team,stand-up": lowercase words, letters, digits
|
|
7855
|
+
// and hyphens only, so a tag is safe in a link and in the LIKE above.
|
|
7856
|
+
const tags = (value) =>
|
|
7857
|
+
clean(value, 200)
|
|
7858
|
+
.split(",")
|
|
7859
|
+
.map((tag) => tag.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((word) => word !== "").join("-"))
|
|
7860
|
+
.filter((tag, i, all) => tag !== "" && all.indexOf(tag) === i)
|
|
7861
|
+
.slice(0, 8)
|
|
7862
|
+
.join(",");
|
|
7863
|
+
`;
|
|
7864
|
+
var SERVER_MODULE = `${SERVER_HEAD}${SUGGESTIONS_SOURCE}${SERVER_TAIL}`;
|
|
7865
|
+
var STARTER_MIGRATION = [
|
|
7866
|
+
"-- The noticeboard's tables. wirl applies each new file here, in order, before a version goes live.",
|
|
7867
|
+
"-- Make the next change in 0002_<what>.sql; never edit a file that has been deployed.",
|
|
7868
|
+
"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)",
|
|
7869
|
+
"create table runs (id integer primary key, schedule text not null, note text not null, ran_at text not null default (datetime('now')))",
|
|
7870
|
+
"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')",
|
|
7871
|
+
""
|
|
7872
|
+
].join("\n");
|
|
7873
|
+
function starterManifest(app, org) {
|
|
7874
|
+
const manifest = {
|
|
7875
|
+
app,
|
|
7876
|
+
...org ? { org } : {},
|
|
7877
|
+
dir: ".",
|
|
7878
|
+
server: "server.mjs",
|
|
7879
|
+
description: "A team noticeboard: your first app on wirl",
|
|
7880
|
+
visibility: "private",
|
|
7881
|
+
schedules: [{ name: "tidy", cron: "0 2 * * *", timezone: "UTC" }]
|
|
7882
|
+
};
|
|
7883
|
+
return `${JSON.stringify(manifest, null, 2)}
|
|
7884
|
+
`;
|
|
7885
|
+
}
|
|
7886
|
+
var README = [
|
|
7887
|
+
"# Team noticeboard",
|
|
7888
|
+
"",
|
|
7889
|
+
"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.",
|
|
7890
|
+
"",
|
|
7891
|
+
"Open it at the address `wirl deploy` prints. It starts private to you. Its page is the way in: six prompts to give your coding agent, each saying what wirl gives the app and what you get, and under them the app's pages on app.wirl.dev.",
|
|
7892
|
+
"",
|
|
7893
|
+
"## What is where",
|
|
7894
|
+
"",
|
|
7895
|
+
"- `server.mjs`: the whole app, one module with one `fetch`. The page, the forms, who may change a note, and the nightly tidy-up.",
|
|
7896
|
+
"- `migrations/0001_init.sql`: the tables, one statement a line. Put the next change in `migrations/0002_<what>.sql`.",
|
|
7897
|
+
"- `wirl.json`: the app's name, its module, its nightly schedule, and that it is created private.",
|
|
7898
|
+
"- `.claude/skills/wirl/SKILL.md`: how a coding agent works with wirl.",
|
|
7899
|
+
"",
|
|
7900
|
+
"## Run it on this computer",
|
|
7901
|
+
"",
|
|
7902
|
+
` ${WIRL_BIN} dev`,
|
|
7903
|
+
"",
|
|
7904
|
+
"It prints the address to open, and a `curl` line that runs the nightly tidy-up now.",
|
|
7905
|
+
"",
|
|
7906
|
+
"## Make it yours",
|
|
7907
|
+
"",
|
|
7908
|
+
"The same six prompts as the page. Each is a whole prompt: paste it to your coding agent as it is. Go in order or skip around; the last one is the big one.",
|
|
7909
|
+
"",
|
|
7910
|
+
...SUGGESTIONS.flatMap(({ number, title, capability, prompt, youGet, optional }) => {
|
|
7911
|
+
const [bold, rest] = splitCapability(capability);
|
|
7912
|
+
return [`### ${String(number)}. ${title}`, "", ...optional ? ["Optional. The big one.", ""] : [], `**${bold}** ${rest}`, "", `> ${prompt}`, "", `You get: ${youGet}`, ""];
|
|
7913
|
+
})
|
|
7914
|
+
].join("\n");
|
|
7915
|
+
var STARTER_FILES = {
|
|
7916
|
+
"server.mjs": SERVER_MODULE,
|
|
7917
|
+
"migrations/0001_init.sql": STARTER_MIGRATION,
|
|
7918
|
+
"README.md": README,
|
|
7919
|
+
"wirl.json": starterManifest("starter")
|
|
7920
|
+
};
|
|
7921
|
+
|
|
7294
7922
|
// packages/cli/src/core.ts
|
|
7295
7923
|
function credentialsPath(env = process.env) {
|
|
7296
7924
|
return env["WIRL_CREDENTIALS_PATH"] || join5(homedir(), ".config", "wirl", "credentials.json");
|
|
@@ -7353,6 +7981,14 @@ async function loadCredentials(env = process.env) {
|
|
|
7353
7981
|
}
|
|
7354
7982
|
throw new CliError("Not signed in.", `Run \`${WIRL_BIN} login\` first, or set WIRL_TOKEN.`, { code: "not_signed_in" });
|
|
7355
7983
|
}
|
|
7984
|
+
async function loadCredentialsIfAny(env = process.env) {
|
|
7985
|
+
try {
|
|
7986
|
+
return await loadCredentials(env);
|
|
7987
|
+
} catch (error) {
|
|
7988
|
+
if (error instanceof CliError && error.code === "not_signed_in") return null;
|
|
7989
|
+
throw error;
|
|
7990
|
+
}
|
|
7991
|
+
}
|
|
7356
7992
|
async function saveCredentials(creds, env = process.env) {
|
|
7357
7993
|
const path = credentialsPath(env);
|
|
7358
7994
|
await mkdir3(dirname3(path), { recursive: true });
|
|
@@ -7633,23 +8269,23 @@ var INIT_MIGRATION = [
|
|
|
7633
8269
|
""
|
|
7634
8270
|
].join("\n");
|
|
7635
8271
|
var INIT_MIGRATION_PATH = `${MIGRATIONS_DIR}/${ADOPTED_FILE}`;
|
|
7636
|
-
async function refuseInitMigrations(dir) {
|
|
8272
|
+
async function refuseInitMigrations(dir, flag) {
|
|
7637
8273
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
7638
8274
|
if (entry.name.toLowerCase() !== MIGRATIONS_DIR) continue;
|
|
7639
8275
|
if (entry.isSymbolicLink()) {
|
|
7640
|
-
throw new CliError(`${entry.name}/ is a symbolic link, and wirl writes into no links: make migrations/ a real folder, then run wirl init
|
|
8276
|
+
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.`);
|
|
7641
8277
|
}
|
|
7642
8278
|
if (entry.name !== MIGRATIONS_DIR) {
|
|
7643
|
-
throw new CliError(`${entry.name}/ is spelled differently from migrations/: wirl reads migrations/ in lowercase only. Rename it, then run wirl init
|
|
8279
|
+
throw new CliError(`${entry.name}/ is spelled differently from migrations/: wirl reads migrations/ in lowercase only. Rename it, then run wirl init ${flag} again.`);
|
|
7644
8280
|
}
|
|
7645
8281
|
if (!entry.isDirectory()) {
|
|
7646
8282
|
throw new CliError(
|
|
7647
|
-
`migrations is a file, and wirl init
|
|
8283
|
+
`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.`
|
|
7648
8284
|
);
|
|
7649
8285
|
}
|
|
7650
8286
|
const names = await readdir(join5(dir, entry.name));
|
|
7651
8287
|
if (names.some((name) => name.toLowerCase().endsWith(".sql"))) {
|
|
7652
|
-
throw new CliError(`migrations/ already holds .sql files; wirl init
|
|
8288
|
+
throw new CliError(`migrations/ already holds .sql files; wirl init ${flag} writes ${INIT_MIGRATION_PATH} into a folder with none.`);
|
|
7653
8289
|
}
|
|
7654
8290
|
}
|
|
7655
8291
|
}
|
|
@@ -7694,23 +8330,52 @@ async function pulledDevCommand(dir) {
|
|
|
7694
8330
|
}
|
|
7695
8331
|
return `${needsInstall ? "npm install && " : ""}${WIRL_BIN} dev`;
|
|
7696
8332
|
}
|
|
8333
|
+
function starterFile(path) {
|
|
8334
|
+
const text = STARTER_FILES[path];
|
|
8335
|
+
if (text === void 0) throw new Error(`starter.ts has no ${path}`);
|
|
8336
|
+
return text;
|
|
8337
|
+
}
|
|
8338
|
+
function moduleKit(options) {
|
|
8339
|
+
if (options.starter) {
|
|
8340
|
+
return {
|
|
8341
|
+
flag: "--starter",
|
|
8342
|
+
files: ["server.mjs", INIT_MIGRATION_PATH, "README.md"].map((path) => [path, starterFile(path)]),
|
|
8343
|
+
hint: "Run wirl init --starter in a new folder, or move the existing file."
|
|
8344
|
+
};
|
|
8345
|
+
}
|
|
8346
|
+
if (options.server) {
|
|
8347
|
+
return {
|
|
8348
|
+
flag: "--server",
|
|
8349
|
+
files: [
|
|
8350
|
+
["server.mjs", SERVER_TEMPLATE],
|
|
8351
|
+
[INIT_MIGRATION_PATH, INIT_MIGRATION]
|
|
8352
|
+
],
|
|
8353
|
+
hint: "Remove --server, or move the existing file."
|
|
8354
|
+
};
|
|
8355
|
+
}
|
|
8356
|
+
return null;
|
|
8357
|
+
}
|
|
7697
8358
|
async function scaffold(dir, options) {
|
|
8359
|
+
if (options.server && options.starter) throw new CliError(STARTER_CONFLICT);
|
|
8360
|
+
const kit = moduleKit(options);
|
|
7698
8361
|
const written = [];
|
|
7699
8362
|
await mkdir3(dir, { recursive: true });
|
|
7700
|
-
if (
|
|
7701
|
-
const
|
|
7702
|
-
|
|
7703
|
-
|
|
7704
|
-
|
|
7705
|
-
|
|
7706
|
-
|
|
7707
|
-
|
|
7708
|
-
|
|
7709
|
-
|
|
7710
|
-
|
|
7711
|
-
|
|
7712
|
-
|
|
7713
|
-
|
|
8363
|
+
if (kit !== null) {
|
|
8364
|
+
for (const [name] of kit.files) {
|
|
8365
|
+
if (name === INIT_MIGRATION_PATH) continue;
|
|
8366
|
+
try {
|
|
8367
|
+
await readFile3(join5(dir, name));
|
|
8368
|
+
throw new CliError(`${name} already exists in ${dir}.`, kit.hint);
|
|
8369
|
+
} catch (error) {
|
|
8370
|
+
if (error instanceof CliError) throw error;
|
|
8371
|
+
}
|
|
8372
|
+
}
|
|
8373
|
+
await refuseInitMigrations(dir, kit.flag);
|
|
8374
|
+
for (const [name, text] of kit.files) {
|
|
8375
|
+
await mkdir3(dirname3(join5(dir, name)), { recursive: true });
|
|
8376
|
+
await writeFile2(join5(dir, name), text, { encoding: "utf8", flag: "wx" });
|
|
8377
|
+
written.push(name);
|
|
8378
|
+
}
|
|
7714
8379
|
const pkg = join5(dir, "package.json");
|
|
7715
8380
|
if (await readFile3(pkg, "utf8").catch(() => null) === null) {
|
|
7716
8381
|
await writeFile2(pkg, `${JSON.stringify({ name: options.app, private: true, type: "module" }, null, 2)}
|
|
@@ -7724,8 +8389,9 @@ async function scaffold(dir, options) {
|
|
|
7724
8389
|
dir: ".",
|
|
7725
8390
|
...options.server ? { server: "server.mjs" } : {}
|
|
7726
8391
|
};
|
|
7727
|
-
|
|
7728
|
-
|
|
8392
|
+
const manifestText = kit?.flag === "--starter" ? starterManifest(options.app, options.org) : `${JSON.stringify(manifest, null, 2)}
|
|
8393
|
+
`;
|
|
8394
|
+
await writeFile2(join5(dir, MANIFEST_NAME), manifestText, "utf8");
|
|
7729
8395
|
const gitignore = join5(dir, ".gitignore");
|
|
7730
8396
|
const existing = await readFile3(gitignore, "utf8").catch(() => "");
|
|
7731
8397
|
const wanted = [".wirl/", ".env"].filter((line) => !existing.split("\n").includes(line));
|
|
@@ -7994,7 +8660,7 @@ async function callApi(creds, path, options = {}) {
|
|
|
7994
8660
|
const body = parsed ?? {};
|
|
7995
8661
|
const refusal = apiRefusal(parsed, response);
|
|
7996
8662
|
if (response.status === 403 && body.error === "insufficient_scope" && body.message) {
|
|
7997
|
-
throw new CliError(body.message,
|
|
8663
|
+
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);
|
|
7998
8664
|
}
|
|
7999
8665
|
if (response.status === 403 && (body.error === "person_only" || body.error === "bad_confirmation") && body.message) {
|
|
8000
8666
|
throw new CliError(body.message, void 0, refusal);
|
|
@@ -8038,7 +8704,7 @@ async function callApiBytes(creds, path, options = {}) {
|
|
|
8038
8704
|
body = {};
|
|
8039
8705
|
}
|
|
8040
8706
|
if (response.status === 401 || response.status === 403) {
|
|
8041
|
-
throw new CliError("Not signed in, or this token cannot do that.", `Try \`${WIRL_BIN} login
|
|
8707
|
+
throw new CliError("Not signed in, or this token cannot do that.", `Try \`${WIRL_BIN} login\`.`, { code: "unauthenticated" });
|
|
8042
8708
|
}
|
|
8043
8709
|
if (response.status === 404) throw new CliError("Not found, or you do not have access to it.");
|
|
8044
8710
|
throw new CliError(body.message ?? body.error ?? `Request failed (${response.status}).`);
|
|
@@ -8195,6 +8861,11 @@ function manifestWarningLines(manifest) {
|
|
|
8195
8861
|
const parsed = parseManifest(manifest);
|
|
8196
8862
|
return parsed.ok ? parsed.warnings.map((warning) => `Warning: ${warning}`) : [];
|
|
8197
8863
|
}
|
|
8864
|
+
function manifestVisibility(manifest) {
|
|
8865
|
+
if (manifest === null || manifest === void 0) return void 0;
|
|
8866
|
+
const parsed = parseManifest(manifest);
|
|
8867
|
+
return parsed.ok ? parsed.manifest.visibility : void 0;
|
|
8868
|
+
}
|
|
8198
8869
|
async function prepareDeployBundle(dir, manifest, door, projectDir) {
|
|
8199
8870
|
let server;
|
|
8200
8871
|
let rules = NO_MANIFEST;
|
|
@@ -8401,6 +9072,10 @@ function formatOrgLine(org) {
|
|
|
8401
9072
|
return org.domain ? `${head} ${org.domain}: new apps start visible to everyone in it` : `${head} no company domain: new apps start private`;
|
|
8402
9073
|
}
|
|
8403
9074
|
var whoami = (creds) => callApi(creds, "/api/v1/me");
|
|
9075
|
+
function signedInSentence(me) {
|
|
9076
|
+
const gave = (me.scope ?? "full") === "full" ? "full access" : "build-and-update";
|
|
9077
|
+
return `Signed in (${gave}). Organisations: ${me.orgs.map((o) => o.slug).join(", ") || "none yet"}.`;
|
|
9078
|
+
}
|
|
8404
9079
|
function chooseOrg(orgs, given, hint = 'Pass --org <slug>, or add "org" to wirl.json.') {
|
|
8405
9080
|
const named = (given ?? "").trim();
|
|
8406
9081
|
if (named) return named;
|
|
@@ -8414,13 +9089,25 @@ async function resolveAppsOrg(creds, orgFlag, manifestOrg) {
|
|
|
8414
9089
|
return chooseOrg((await whoami(creds)).orgs, null);
|
|
8415
9090
|
}
|
|
8416
9091
|
var listApps = (creds, org) => callApi(creds, `/api/v1/orgs/${seg(org)}/apps`);
|
|
8417
|
-
var createRemoteApp = (creds, org, slug, name) => callApi(creds, `/api/v1/orgs/${seg(org)}/apps`, {
|
|
9092
|
+
var createRemoteApp = (creds, org, slug, name, visibility) => callApi(creds, `/api/v1/orgs/${seg(org)}/apps`, {
|
|
8418
9093
|
method: "POST",
|
|
8419
|
-
|
|
9094
|
+
// `visibility` rides along only when wirl.json said it: absent, the
|
|
9095
|
+
// org's default holds. It applies to the app this create makes and to
|
|
9096
|
+
// no other: an app that exists answers 409 and is left as it is.
|
|
9097
|
+
body: JSON.stringify({ slug, name: name ?? slug, ...visibility === void 0 ? {} : { visibility } }),
|
|
8420
9098
|
contentType: "application/json"
|
|
8421
9099
|
});
|
|
8422
|
-
async function ensureRemoteApp(creds, org, app) {
|
|
8423
|
-
return createRemoteApp(creds, org, app).then(() =>
|
|
9100
|
+
async function ensureRemoteApp(creds, org, app, visibility) {
|
|
9101
|
+
return createRemoteApp(creds, org, app, void 0, visibility).then((created) => {
|
|
9102
|
+
const made = created.app?.visibility;
|
|
9103
|
+
if (visibility === "private" && typeof made === "string" && made !== "private") {
|
|
9104
|
+
process.stderr.write(
|
|
9105
|
+
`${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}`)}
|
|
9106
|
+
`
|
|
9107
|
+
);
|
|
9108
|
+
}
|
|
9109
|
+
return true;
|
|
9110
|
+
}, (error) => {
|
|
8424
9111
|
if (!(error instanceof CliError)) throw error;
|
|
8425
9112
|
if (error.fields["code"] === "app_deleted") throw deletedNameError(error, app);
|
|
8426
9113
|
if (error.fields["code"] === "app_exists" || error.fields["code"] === "app_moved") throw takenNameError(error, app);
|
|
@@ -8466,7 +9153,7 @@ function deletedAppMessage(org, app, done) {
|
|
|
8466
9153
|
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.`;
|
|
8467
9154
|
}
|
|
8468
9155
|
function deleteNeedsFullScopeMessage(apiOrigin, org, app) {
|
|
8469
|
-
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`;
|
|
9156
|
+
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`;
|
|
8470
9157
|
}
|
|
8471
9158
|
var VISIBILITY_LEVELS = ["private", "org", "public"];
|
|
8472
9159
|
function describeVisibility(level, org) {
|
|
@@ -9973,6 +10660,7 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
|
|
|
9973
10660
|
"no-skill",
|
|
9974
10661
|
"print",
|
|
9975
10662
|
"server",
|
|
10663
|
+
"starter",
|
|
9976
10664
|
"force",
|
|
9977
10665
|
"errors",
|
|
9978
10666
|
"follow",
|
|
@@ -9994,7 +10682,7 @@ function commandFlags(listed2, everywhere = true) {
|
|
|
9994
10682
|
}
|
|
9995
10683
|
var COMMAND_FLAGS = /* @__PURE__ */ new Map([
|
|
9996
10684
|
["login", commandFlags({ values: ["token", "api"] })],
|
|
9997
|
-
["init", commandFlags({ booleans: ["server", "no-skill"] })],
|
|
10685
|
+
["init", commandFlags({ booleans: ["server", "starter", "no-skill"] })],
|
|
9998
10686
|
["skill", commandFlags({ booleans: ["print"] })],
|
|
9999
10687
|
// Only what it lists, as DEV_FLAGS in bin.ts does (OIM-887): `--json` and `--app` were always refused here.
|
|
10000
10688
|
["dev", commandFlags({ values: ["user", "name", "role", "org", "port"], booleans: ["verbose"] }, false)],
|
|
@@ -10196,6 +10884,8 @@ function orMock(declared) {
|
|
|
10196
10884
|
var MOCKS_FILE = join6(".wirl", "mocks.json");
|
|
10197
10885
|
var MOCKS_FILE_MAX_BYTES = 65536;
|
|
10198
10886
|
var MOCK_PLACEHOLDER = "wirl-dev-mock";
|
|
10887
|
+
var MODELS_MOCK = "ai";
|
|
10888
|
+
var isModelsMock = (mock) => mock.hosts.length === 1 && mock.hosts[0] === AI_HOST;
|
|
10199
10889
|
var MOCKS_EXAMPLE = 'It should look like: { "anthropic": "http://127.0.0.1:4010" }';
|
|
10200
10890
|
async function readBounded2(path, limit) {
|
|
10201
10891
|
let handle;
|
|
@@ -10248,7 +10938,8 @@ async function readLocalMocks(dir, manifest) {
|
|
|
10248
10938
|
for (const [slug, value] of Object.entries(parsed)) {
|
|
10249
10939
|
const name = JSON.stringify(slug);
|
|
10250
10940
|
const declared = manifest.connections.find((each) => each.slug === slug);
|
|
10251
|
-
|
|
10941
|
+
const forModels = declared === void 0 && slug === MODELS_MOCK;
|
|
10942
|
+
if (declared === void 0 && !forModels) {
|
|
10252
10943
|
throw new CliError(`${path} maps ${name}, and wirl.json declares no connection called that.`, "Each name in it is a connection wirl.json declares.");
|
|
10253
10944
|
}
|
|
10254
10945
|
if (typeof value !== "string") {
|
|
@@ -10269,6 +10960,10 @@ async function readLocalMocks(dir, manifest) {
|
|
|
10269
10960
|
if (base.search !== "" || base.hash !== "") {
|
|
10270
10961
|
throw new CliError(`${path}: the base URL for ${name} has a query or a fragment; a call's own path and query are added to it.`);
|
|
10271
10962
|
}
|
|
10963
|
+
if (declared === void 0) {
|
|
10964
|
+
mocks.set(slug, { slug, hosts: [AI_HOST], base });
|
|
10965
|
+
continue;
|
|
10966
|
+
}
|
|
10272
10967
|
const hosts = declared.hosts ?? [];
|
|
10273
10968
|
if (hosts.length === 0) {
|
|
10274
10969
|
throw new CliError(`${path} maps ${name}, which declares no hosts in wirl.json, so no call could go to its mock.`);
|
|
@@ -10339,6 +11034,111 @@ function routeToMocks(manifest, mocks, real, toMock) {
|
|
|
10339
11034
|
return toMock(mockTarget(mock.base, url), init);
|
|
10340
11035
|
};
|
|
10341
11036
|
}
|
|
11037
|
+
function forwardedHeaders(incoming) {
|
|
11038
|
+
const out2 = new Headers();
|
|
11039
|
+
incoming.forEach((value, name) => {
|
|
11040
|
+
const lower = name.toLowerCase();
|
|
11041
|
+
if (NOT_FORWARDED.has(lower) || lower.startsWith("x-wirl-")) return;
|
|
11042
|
+
out2.set(name, value);
|
|
11043
|
+
});
|
|
11044
|
+
return out2;
|
|
11045
|
+
}
|
|
11046
|
+
var NOT_FORWARDED = /* @__PURE__ */ new Set([
|
|
11047
|
+
"authorization",
|
|
11048
|
+
"proxy-authorization",
|
|
11049
|
+
"cookie",
|
|
11050
|
+
"host",
|
|
11051
|
+
"connection",
|
|
11052
|
+
"keep-alive",
|
|
11053
|
+
"transfer-encoding",
|
|
11054
|
+
"upgrade",
|
|
11055
|
+
"te",
|
|
11056
|
+
"trailer",
|
|
11057
|
+
"content-length",
|
|
11058
|
+
"accept-encoding"
|
|
11059
|
+
]);
|
|
11060
|
+
function passedOn(response) {
|
|
11061
|
+
const headers = new Headers();
|
|
11062
|
+
response.headers.forEach((value, name) => {
|
|
11063
|
+
const lower = name.toLowerCase();
|
|
11064
|
+
if (lower === "content-encoding" || lower === "content-length" || lower.startsWith("x-wirl-")) return;
|
|
11065
|
+
headers.append(name, value);
|
|
11066
|
+
});
|
|
11067
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
11068
|
+
}
|
|
11069
|
+
function reasonOf3(error) {
|
|
11070
|
+
const cause = error instanceof Error ? error.cause : void 0;
|
|
11071
|
+
if (typeof cause?.code === "string") return cause.code;
|
|
11072
|
+
return error instanceof Error ? error.message : String(error);
|
|
11073
|
+
}
|
|
11074
|
+
function localModels(ai, mock, log) {
|
|
11075
|
+
if (ai === void 0 && mock === null) return void 0;
|
|
11076
|
+
const credentials = ai?.credentials ?? null;
|
|
11077
|
+
let org = null;
|
|
11078
|
+
const orgOnce = () => {
|
|
11079
|
+
org ??= (ai?.org ?? (async () => null))().catch((error) => {
|
|
11080
|
+
org = null;
|
|
11081
|
+
throw error;
|
|
11082
|
+
});
|
|
11083
|
+
return org;
|
|
11084
|
+
};
|
|
11085
|
+
return async (request, _caller, target, body) => {
|
|
11086
|
+
const said = `wirl dev \xB7 ai.wirl ${request.method} ${target.pathname}`;
|
|
11087
|
+
const headers = forwardedHeaders(request.headers);
|
|
11088
|
+
const sent = body === null || request.method === "GET" || request.method === "HEAD" ? null : body;
|
|
11089
|
+
if (mock !== null) {
|
|
11090
|
+
let response2;
|
|
11091
|
+
try {
|
|
11092
|
+
response2 = await fetch(mockTarget(mock, target), { method: request.method, headers, body: sent, redirect: "manual", signal: request.signal });
|
|
11093
|
+
} catch (error) {
|
|
11094
|
+
if (request.signal.aborted) throw error;
|
|
11095
|
+
log(`${said} \u2192 mock unreachable (${reasonOf3(error)})`);
|
|
11096
|
+
return aiRefusal(502, "ai_vendor_unreachable", "The ai mock could not be reached.");
|
|
11097
|
+
}
|
|
11098
|
+
log(`${said} \u2192 mock ${response2.status}`);
|
|
11099
|
+
return passedOn(response2);
|
|
11100
|
+
}
|
|
11101
|
+
if (credentials === null) {
|
|
11102
|
+
log(`${said} \u2192 ai_no_key 402 (not signed in)`);
|
|
11103
|
+
return aiRefusal(402, "ai_no_key", "Run `wirl login` to use models locally.");
|
|
11104
|
+
}
|
|
11105
|
+
let slug;
|
|
11106
|
+
try {
|
|
11107
|
+
slug = await orgOnce();
|
|
11108
|
+
} catch (error) {
|
|
11109
|
+
if (error instanceof CliError && (error.code === "unauthenticated" || error.code === "not_signed_in")) {
|
|
11110
|
+
log(`${said} \u2192 ai_no_key 402 (login not accepted)`);
|
|
11111
|
+
return aiRefusal(402, "ai_no_key", "Run `wirl login` to use models locally.");
|
|
11112
|
+
}
|
|
11113
|
+
log(`${said} \u2192 ai_vendor_unreachable 502 (whoami failed)`);
|
|
11114
|
+
return aiRefusal(502, "ai_vendor_unreachable", "app.wirl.dev could not be reached to find your organisation.");
|
|
11115
|
+
}
|
|
11116
|
+
if (slug === null) {
|
|
11117
|
+
log(`${said} \u2192 ai_no_key 402 (no org in wirl.json)`);
|
|
11118
|
+
return aiRefusal(402, "ai_no_key", 'Add "org" to wirl.json to say which organisation local model calls count against.');
|
|
11119
|
+
}
|
|
11120
|
+
headers.set("authorization", `Bearer ${credentials.token}`);
|
|
11121
|
+
headers.set("x-wirl-org", slug);
|
|
11122
|
+
headers.set("x-wirl-client", "cli");
|
|
11123
|
+
let response;
|
|
11124
|
+
try {
|
|
11125
|
+
response = await (credentials.fetchImpl ?? fetch)(`${credentials.apiOrigin}/ai/_${target.pathname}${target.search}`, {
|
|
11126
|
+
method: request.method,
|
|
11127
|
+
headers,
|
|
11128
|
+
body: sent,
|
|
11129
|
+
redirect: "manual",
|
|
11130
|
+
signal: request.signal
|
|
11131
|
+
});
|
|
11132
|
+
} catch (error) {
|
|
11133
|
+
if (request.signal.aborted) throw error;
|
|
11134
|
+
log(`${said} \u2192 ai_vendor_unreachable 502 (${reasonOf3(error)})`);
|
|
11135
|
+
return aiRefusal(502, "ai_vendor_unreachable", "app.wirl.dev could not be reached for this model call.");
|
|
11136
|
+
}
|
|
11137
|
+
const answer = restoreStatus(response);
|
|
11138
|
+
log(`${said} \u2192 ${answer.status}`);
|
|
11139
|
+
return passedOn(answer);
|
|
11140
|
+
};
|
|
11141
|
+
}
|
|
10342
11142
|
async function toRequest(incoming, outgoing, origin) {
|
|
10343
11143
|
const left = new AbortController();
|
|
10344
11144
|
outgoing.once("close", () => {
|
|
@@ -10408,7 +11208,10 @@ async function startLocalBroker(options) {
|
|
|
10408
11208
|
heldBodyLimits: options.heldBodyLimits,
|
|
10409
11209
|
heldAnswerLimits: options.heldAnswerLimits
|
|
10410
11210
|
});
|
|
10411
|
-
const
|
|
11211
|
+
const allMocks = options.mocks ?? /* @__PURE__ */ new Map();
|
|
11212
|
+
const modelsMock = [...allMocks.values()].find(isModelsMock);
|
|
11213
|
+
const mocks = new Map([...allMocks].filter(([, mock]) => !isModelsMock(mock)));
|
|
11214
|
+
const models = localModels(options.ai, options.ai?.mock ?? modelsMock?.base ?? null, log);
|
|
10412
11215
|
const fetchImpl = mocks.size === 0 ? transport.fetchImpl : routeToMocks(
|
|
10413
11216
|
options.manifest,
|
|
10414
11217
|
mocks,
|
|
@@ -10441,7 +11244,8 @@ async function startLocalBroker(options) {
|
|
|
10441
11244
|
flows: async (request, _caller, target) => {
|
|
10442
11245
|
log(`wirl dev \xB7 flows.wirl ${request.method} ${target.pathname} \u2192 no_flows 404`);
|
|
10443
11246
|
return noFlowsAnswer();
|
|
10444
|
-
}
|
|
11247
|
+
},
|
|
11248
|
+
...models === void 0 ? {} : { ai: models }
|
|
10445
11249
|
});
|
|
10446
11250
|
await send2(response, outgoing);
|
|
10447
11251
|
} catch (error) {
|
|
@@ -10528,7 +11332,21 @@ async function dev(dir, options = {}) {
|
|
|
10528
11332
|
}
|
|
10529
11333
|
const keys = await readLocalKeys(dir);
|
|
10530
11334
|
const mocks = await readLocalMocks(dir, parsed.manifest);
|
|
10531
|
-
const
|
|
11335
|
+
const credentials = await loadCredentialsIfAny();
|
|
11336
|
+
const manifestOrg = (await loadManifest(dir))?.org ?? null;
|
|
11337
|
+
const org = async () => {
|
|
11338
|
+
if (manifestOrg !== null) return manifestOrg;
|
|
11339
|
+
if (credentials === null) return null;
|
|
11340
|
+
try {
|
|
11341
|
+
return chooseOrg((await whoami(credentials)).orgs, null);
|
|
11342
|
+
} catch (error) {
|
|
11343
|
+
if (error instanceof CliError && (error.code === "no_org" || error.code === "missing_argument")) return null;
|
|
11344
|
+
throw error;
|
|
11345
|
+
}
|
|
11346
|
+
};
|
|
11347
|
+
const broker = await startLocalBroker({ manifest: parsed.manifest, dir, log, mocks, ai: { credentials, org } });
|
|
11348
|
+
const modelsMock = parsed.manifest.connections.some((each) => each.slug === MODELS_MOCK) ? void 0 : mocks.get(MODELS_MOCK);
|
|
11349
|
+
if (modelsMock !== void 0) log(`mock ${MODELS_MOCK}: calls to ${modelsMock.hosts.join(", ")} go to ${modelsMock.base.href}, with no token`);
|
|
10532
11350
|
for (const declared of parsed.manifest.connections) {
|
|
10533
11351
|
log(keyLine(declared, keys));
|
|
10534
11352
|
const misshapen = keyShapeWarning(declared, keys);
|
|
@@ -10978,7 +11796,7 @@ var Restarts = class {
|
|
|
10978
11796
|
init_src();
|
|
10979
11797
|
var GRACE_DAYS = String(RETENTION.deletedAppGraceDays);
|
|
10980
11798
|
var DEV_RELOAD_NOTE = `The server module, and every file it imports, is rebuilt and restarted at the same address when one of them changes, with a line on stderr each time; the app's own write to a file does not count. While a change does not build, it tries again every second, and whenever you save a file in the project, until it builds, so a new file or a package installed counts. wirl.json, .wirl/mocks.json and migrations/ are read once, at start, when migrations/ is applied to .wirl/local.sqlite: stop this and run it again after changing any of them. A change to migrations/ is said once on stderr and restarts nothing. Files in a "static" folder are read fresh on every request.`;
|
|
10981
|
-
var DEV_KEYS_NOTE = `Keys come from .wirl/keys.json, in the shape this prints for each connection that has none. To try a connection without its key, map it to a server of your own in .wirl/mocks.json, like { "anthropic": "http://127.0.0.1:4010" }. A call to one of that connection's declared hosts then goes to that URL instead, its path added to the URL's own and its query kept. It carries the key from .wirl/keys.json, or, when that has none, the placeholder "wirl-dev-mock" in the shape of the connection's kind. With no key there, an http_header connection sends no key header, since only .wirl/keys.json names the header, and a none connection never sends one. A host the connection does not declare is refused as before. .wirl/mocks.json is read once, at start, and never uploaded.`;
|
|
11799
|
+
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. Model calls to https://ai.wirl go to your account's allowance on app.wirl.dev while you are signed in (wirl login); they count against the org in wirl.json.`;
|
|
10982
11800
|
var DEV_FLAGS_NOTE = "A flag not listed below, other than --help, is refused, not ignored.";
|
|
10983
11801
|
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.";
|
|
10984
11802
|
var PREVIEW_LINK_MINUTES = String(VISIT_TOKEN_TTL_SECONDS / 60);
|
|
@@ -10988,6 +11806,7 @@ var USAGE = `wirl \u2014 deploy an app somewhere real, behind a sign-in
|
|
|
10988
11806
|
|
|
10989
11807
|
wirl login Sign in from this terminal (or --token wirl_...)
|
|
10990
11808
|
wirl init [dir] [--server] Create wirl.json, and a working server module with --server
|
|
11809
|
+
wirl init [dir] --starter Write your first app: a small team noticeboard, ready to deploy
|
|
10991
11810
|
wirl skill [dir] [--print] Write the skill file a coding agent reads (init does this too; --no-skill to skip)
|
|
10992
11811
|
wirl dev [dir] Run the app here, signed in as a stub user
|
|
10993
11812
|
wirl deploy [dir] Build, scan and deploy (creates the app if new)
|
|
@@ -11051,14 +11870,18 @@ var HELP = {
|
|
|
11051
11870
|
example: "wirl login"
|
|
11052
11871
|
},
|
|
11053
11872
|
init: {
|
|
11054
|
-
usage: ["wirl init [dir] [--server] [--app <name>] [--org <slug>] [--no-skill]"],
|
|
11873
|
+
usage: ["wirl init [dir] [--server|--starter] [--app <name>] [--org <slug>] [--no-skill]"],
|
|
11055
11874
|
flags: [
|
|
11056
11875
|
{ flag: "--app <name>", desc: "App slug. Defaults to the directory name." },
|
|
11057
11876
|
{ flag: "--org <slug>", desc: "Organisation to put in wirl.json." },
|
|
11058
11877
|
{ flag: "--server", desc: "Also write server.mjs, a working backend to build on." },
|
|
11878
|
+
{
|
|
11879
|
+
flag: "--starter",
|
|
11880
|
+
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."
|
|
11881
|
+
},
|
|
11059
11882
|
{ flag: "--no-skill", desc: "Skip writing the skill file a coding agent reads." }
|
|
11060
11883
|
],
|
|
11061
|
-
example: "wirl init my-app --server"
|
|
11884
|
+
example: "wirl init --starter\n wirl init my-app --server"
|
|
11062
11885
|
},
|
|
11063
11886
|
skill: {
|
|
11064
11887
|
usage: ["wirl skill [dir] [--print]"],
|
|
@@ -11502,6 +12325,13 @@ async function resolveTarget(creds, args2, dir) {
|
|
|
11502
12325
|
return { org: String(org), app };
|
|
11503
12326
|
}
|
|
11504
12327
|
var args = parseArgs(process.argv.slice(2).map((token) => token === "-h" ? "--help" : token));
|
|
12328
|
+
async function starterFolder() {
|
|
12329
|
+
const creds = await loadCredentials().catch(() => null);
|
|
12330
|
+
if (creds === null) return starterName(null);
|
|
12331
|
+
const me = await whoami(creds).catch(() => null);
|
|
12332
|
+
if (me === null) err("Could not read your account, so the starter is named starter.");
|
|
12333
|
+
return starterName(me?.email ?? null);
|
|
12334
|
+
}
|
|
11505
12335
|
async function main() {
|
|
11506
12336
|
const json2 = args.flags.has("json");
|
|
11507
12337
|
if (process.argv[2] === "--version" || process.argv[2] === "-v") {
|
|
@@ -11542,23 +12372,28 @@ async function main() {
|
|
|
11542
12372
|
const creds2 = await pollDeviceLogin(apiOrigin, login);
|
|
11543
12373
|
const path = await saveCredentials(creds2);
|
|
11544
12374
|
const me = await whoami(creds2);
|
|
11545
|
-
out(
|
|
12375
|
+
out(signedInSentence(me));
|
|
11546
12376
|
out(`Saved to ${path}`);
|
|
11547
12377
|
return 0;
|
|
11548
12378
|
}
|
|
11549
12379
|
if (args.command === "init") {
|
|
11550
12380
|
refuseBadFlags(args);
|
|
11551
12381
|
refuseExtraPositionals("init", args.positional, 1, "one folder", { hint: folderHint("The app's name goes after --app <name>.") });
|
|
11552
|
-
const
|
|
12382
|
+
const starter = args.flags.has("starter");
|
|
12383
|
+
if (starter && args.flags.has("server")) throw new CliError(STARTER_CONFLICT);
|
|
12384
|
+
const dir = resolve6(args.positional[0] ?? (starter ? await starterFolder() : "."));
|
|
11553
12385
|
const app = String(args.flags.get("app") ?? basename4(dir));
|
|
11554
12386
|
const written = await scaffold(dir, {
|
|
11555
12387
|
app,
|
|
11556
12388
|
...args.flags.has("org") ? { org: String(args.flags.get("org")) } : {},
|
|
11557
12389
|
server: args.flags.has("server"),
|
|
12390
|
+
starter,
|
|
11558
12391
|
skill: !args.flags.has("no-skill")
|
|
11559
12392
|
});
|
|
11560
12393
|
for (const file of written) out(`Wrote ${file}`);
|
|
11561
12394
|
if (args.flags.has("server")) out("Deploy it with: wirl deploy");
|
|
12395
|
+
const folder = relative3(process.cwd(), dir);
|
|
12396
|
+
if (starter) out(`Deploy it with: ${folder === "" ? "" : `cd ${shellPath(folder)} && `}wirl deploy`);
|
|
11562
12397
|
return 0;
|
|
11563
12398
|
}
|
|
11564
12399
|
if (args.command === "skill") {
|
|
@@ -11664,7 +12499,7 @@ async function main() {
|
|
|
11664
12499
|
const upload = { dir, projectDir: project.manifestDir, org: target.org, app: target.app, ...manifest === null ? {} : { manifest } };
|
|
11665
12500
|
const prepared = await prepareUpload(dir, upload.manifest, "wirl deploy", upload.projectDir);
|
|
11666
12501
|
for (const line of prepared.notices) err(line);
|
|
11667
|
-
const made = !args.flags.has("no-create") && await ensureRemoteApp(creds, target.org, target.app);
|
|
12502
|
+
const made = !args.flags.has("no-create") && await ensureRemoteApp(creds, target.org, target.app, manifestVisibility(manifest));
|
|
11668
12503
|
const terminal = terminalMayConfirm({ stdinIsTTY: process.stdin.isTTY, stdoutIsTTY: process.stdout.isTTY, stderrIsTTY: process.stderr.isTTY, json: json2 }) ? personAt(process.stdin, process.stderr) : void 0;
|
|
11669
12504
|
const result = await deploy(creds, { ...upload, prepared, ...terminal === void 0 ? {} : { terminal } }).catch((error) => {
|
|
11670
12505
|
throw withMadeApp(error, made, "wirl deploy", target.app);
|
|
@@ -11725,7 +12560,7 @@ async function main() {
|
|
|
11725
12560
|
const upload = { dir, projectDir: project.manifestDir, org: target.org, app: target.app, name: resolved, ...manifest === null ? {} : { manifest } };
|
|
11726
12561
|
const prepared = await prepareUpload(dir, upload.manifest, "wirl preview", upload.projectDir);
|
|
11727
12562
|
for (const line of prepared.notices) err(line);
|
|
11728
|
-
const made = !args.flags.has("no-create") && await ensureRemoteApp(creds, target.org, target.app);
|
|
12563
|
+
const made = !args.flags.has("no-create") && await ensureRemoteApp(creds, target.org, target.app, manifestVisibility(manifest));
|
|
11729
12564
|
const result = await deployPreview(creds, { ...upload, prepared }).catch((error) => {
|
|
11730
12565
|
throw withMadeApp(error, made, "wirl preview", target.app);
|
|
11731
12566
|
});
|