wirl 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin.js +498 -128
  2. 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();
@@ -3077,7 +3098,7 @@ var init_manifest = __esm({
3077
3098
  };
3078
3099
  CREATABLE_CONNECTION_KINDS = CONNECTION_KINDS.filter((kind) => !(kind in UNDELIVERED_KINDS));
3079
3100
  needsNoKey = (kind) => kind === "none";
3080
- ACCENTS = ["blue", "green", "sun", "purple", "red", "sky", "orange", "lime"];
3101
+ ACCENTS = ["blue", "green", "sun", "purple", "red", "sky", "orange", "lime", "mint", "butter", "blush", "lilac"];
3081
3102
  SCHEDULE_LIMITS = {
3082
3103
  perApp: 10,
3083
3104
  minTimeoutMs: 3e4,
@@ -3344,6 +3365,29 @@ var init_runtime = __esm({
3344
3365
  }
3345
3366
  });
3346
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
+
3347
3391
  // packages/core/src/broker-stream.ts
3348
3392
  function mediaTypeOf(headers) {
3349
3393
  const declared = headers.get("content-type");
@@ -3784,9 +3828,14 @@ var init_broker_bodies = __esm({
3784
3828
 
3785
3829
  // packages/core/src/broker.ts
3786
3830
  async function handleBrokerRequest(request, deps) {
3787
- const holding = { body: null, answer: null };
3831
+ const holding = { body: null, answer: null, tap: null, vendorStatus: null };
3788
3832
  try {
3789
- return await brokerCall(request, deps, holding);
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;
3790
3839
  } finally {
3791
3840
  holding.body?.release();
3792
3841
  holding.answer?.release();
@@ -3886,34 +3935,46 @@ async function brokerCall(request, deps, holding) {
3886
3935
  }
3887
3936
  requestBody = bodyRead.bytes;
3888
3937
  }
3889
- const connections = await deps.connectionsFor(appId, deploymentId);
3890
3938
  let connection;
3891
- if (route.slug === RESOLVE_BY_HOST) {
3892
- const target2 = request.headers.get("x-wirl-target");
3893
- if (!target2) {
3894
- await record("no_target", 400, "");
3895
- return refuse2(400, "no_target", "Resolving a connection by host needs X-Wirl-Target.");
3896
- }
3897
- path = egressAuditPath(target2);
3898
- const host2 = safeHost(target2);
3899
- const matches = connections.filter(
3900
- (candidate) => isHttpKind(candidate.kind) && host2.length > 0 && isHostAllowed(host2, candidate.allowedHosts)
3901
- );
3902
- if (matches.length === 0) {
3903
- await record("no_grant", 403, host2);
3904
- return refuse2(403, "no_grant", `No granted connection covers ${host2 || target2}.`);
3905
- }
3906
- if (matches.length > 1) {
3907
- await record("ambiguous", 403, host2);
3908
- return refuse2(
3909
- 403,
3910
- "ambiguous",
3911
- `More than one granted connection covers ${host2}: ${matches.map((m) => m.slug).join(", ")}. Call /e/<connection>/ to say which.`
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)
3912
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);
3913
3977
  }
3914
- connection = matches[0];
3915
- } else {
3916
- connection = connections.find((candidate) => candidate.slug === route.slug);
3917
3978
  }
3918
3979
  if (!connection) {
3919
3980
  await record("no_grant", 403, "");
@@ -3949,7 +4010,7 @@ async function brokerCall(request, deps, holding) {
3949
4010
  `The "${connection.slug}" connection is a ${connection.kind} credential and is not reachable over http.`
3950
4011
  );
3951
4012
  }
3952
- 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);
3953
4014
  path = egressAuditPath(target);
3954
4015
  const host = safeHost(target);
3955
4016
  if (host.length === 0 || !isHostAllowed(host, connection.allowedHosts)) {
@@ -3971,6 +4032,7 @@ async function brokerCall(request, deps, holding) {
3971
4032
  if (STRIPPED.has(lower)) return;
3972
4033
  if (STRIPPED_PREFIXES.some((prefix) => lower.startsWith(prefix))) return;
3973
4034
  if (isHopHeader(lower)) return;
4035
+ if (aiUpstream !== null && AI_KEY_HEADERS.has(lower)) return;
3974
4036
  headers.set(name, value);
3975
4037
  });
3976
4038
  attachCredential(headers, connection);
@@ -4005,10 +4067,21 @@ async function brokerCall(request, deps, holding) {
4005
4067
  await record("private_address", 403, safeHost(target));
4006
4068
  return refuse2(403, "private_address", error.message);
4007
4069
  }
4008
- if (request.signal.aborted) return callerLeft(record, safeHost(target));
4070
+ if (request.signal.aborted) {
4071
+ holding.tap?.end("cut", 499);
4072
+ return callerLeft(record, safeHost(target));
4073
+ }
4009
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
+ }
4010
4079
  return refuse2(502, "upstream_error", error instanceof Error ? error.message : String(error));
4011
4080
  }
4081
+ if (holding.tap !== null) {
4082
+ holding.vendorStatus = upstream.status;
4083
+ holding.tap.answered?.(upstream.status);
4084
+ }
4012
4085
  let upstreamBody;
4013
4086
  try {
4014
4087
  upstreamBody = upstream.body;
@@ -4027,6 +4100,8 @@ async function brokerCall(request, deps, holding) {
4027
4100
  if (upstreamBody !== null && isStreamedAnswer(upstream.status, upstream.headers)) {
4028
4101
  const body2 = holding.body;
4029
4102
  holding.body = null;
4103
+ const tap = holding.tap;
4104
+ holding.tap = null;
4030
4105
  return streamAnswer(upstream, upstreamBody, {
4031
4106
  appId,
4032
4107
  limit,
@@ -4037,7 +4112,8 @@ async function brokerCall(request, deps, holding) {
4037
4112
  sentMs: deps.streamSentMs ?? BROKER_LIMITS.cutEventSentMs,
4038
4113
  signal: request.signal,
4039
4114
  record: (outcome, status) => record(outcome, status, safeHost(target)),
4040
- held: body2
4115
+ held: body2,
4116
+ tap
4041
4117
  });
4042
4118
  }
4043
4119
  const heldAnswer = answers?.open(appId, ownerId) ?? null;
@@ -4050,7 +4126,10 @@ async function brokerCall(request, deps, holding) {
4050
4126
  return refuse2(502, refusal.error, refusal.message);
4051
4127
  }
4052
4128
  if (read.outcome === "error") {
4053
- if (request.signal.aborted) return callerLeft(record, safeHost(target));
4129
+ if (request.signal.aborted) {
4130
+ holding.tap?.end("cut", upstream.status);
4131
+ return callerLeft(record, safeHost(target));
4132
+ }
4054
4133
  await record("upstream_error", 502, safeHost(target));
4055
4134
  return refuse2(502, "upstream_error", read.error instanceof Error ? read.error.message : String(read.error));
4056
4135
  }
@@ -4059,6 +4138,11 @@ async function brokerCall(request, deps, holding) {
4059
4138
  return refuse2(502, "too_large", `The upstream returned more than ${limit} bytes.`);
4060
4139
  }
4061
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
+ }
4062
4146
  await record("allowed", upstream.status, safeHost(target));
4063
4147
  const out2 = answerHeaders(upstream.headers);
4064
4148
  const empty = upstream.status === 204 || upstream.status === 304;
@@ -4165,6 +4249,53 @@ function flowsTargetOf(request) {
4165
4249
  function noFlowsAnswer() {
4166
4250
  return refuse2(404, "no_flows", "Flows run in a preview: wirl preview, then wirl flows start <flow> --preview <name> --fast.");
4167
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
+ }
4168
4299
  function answerHeaders(upstream) {
4169
4300
  const out2 = new Headers();
4170
4301
  upstream.forEach((value, name) => {
@@ -4187,6 +4318,7 @@ function declaredLength(headers) {
4187
4318
  }
4188
4319
  async function streamAnswer(upstream, body, context) {
4189
4320
  const giveBack = () => context.held?.release();
4321
+ const tap = context.tap ?? null;
4190
4322
  let release = giveBack;
4191
4323
  if (context.slots !== null) {
4192
4324
  const slot = context.slots.acquire(context.appId);
@@ -4194,6 +4326,7 @@ async function streamAnswer(upstream, body, context) {
4194
4326
  await body.cancel().catch(() => {
4195
4327
  });
4196
4328
  giveBack();
4329
+ tap?.end("error", upstream.status);
4197
4330
  await context.record("busy", 429);
4198
4331
  return refuse2(
4199
4332
  429,
@@ -4212,17 +4345,19 @@ async function streamAnswer(upstream, body, context) {
4212
4345
  await body.cancel().catch(() => {
4213
4346
  });
4214
4347
  release();
4348
+ tap?.end("error", upstream.status);
4215
4349
  throw error;
4216
4350
  }
4217
4351
  try {
4218
- return new Response(passOn(body, context.limit, release, context.safetyMs, context.signal, context.closing, context.sent, context.sentMs), {
4219
- status: upstream.status,
4220
- headers: answerHeaders(upstream.headers)
4221
- });
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
+ );
4222
4356
  } catch (error) {
4223
4357
  await body.cancel().catch(() => {
4224
4358
  });
4225
4359
  release();
4360
+ tap?.end("error", upstream.status);
4226
4361
  throw error;
4227
4362
  }
4228
4363
  }
@@ -4247,7 +4382,7 @@ async function eventSent(sent, ms) {
4247
4382
  stop.abort();
4248
4383
  }
4249
4384
  }
4250
- 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) {
4251
4386
  const reader = body.getReader();
4252
4387
  const says = closing && limit > CUT_EVENT_RESERVE_BYTES;
4253
4388
  const room = says ? limit - CUT_EVENT_RESERVE_BYTES : limit;
@@ -4258,16 +4393,17 @@ function passOn(body, limit, release, safetyMs, signal, closing, sent, sentMs) {
4258
4393
  let safety;
4259
4394
  let appLeft = () => {
4260
4395
  };
4261
- const finish = () => {
4396
+ const finish = (how) => {
4262
4397
  if (over) return false;
4263
4398
  over = true;
4264
4399
  clearTimeout(safety);
4265
4400
  signal.removeEventListener("abort", appLeft);
4266
4401
  release();
4402
+ tap?.end(how, status);
4267
4403
  return true;
4268
4404
  };
4269
4405
  const cut2 = (controller, error, why) => {
4270
- if (!finish()) return;
4406
+ if (!finish("cut")) return;
4271
4407
  reader.cancel(error).catch(() => {
4272
4408
  });
4273
4409
  if (!says) {
@@ -4304,7 +4440,7 @@ function passOn(body, limit, release, safetyMs, signal, closing, sent, sentMs) {
4304
4440
  timer.unref?.();
4305
4441
  safety = timer;
4306
4442
  appLeft = () => {
4307
- if (!finish()) return;
4443
+ if (!finish("cut")) return;
4308
4444
  reader.cancel(signal.reason).catch(() => {
4309
4445
  });
4310
4446
  controller.close();
@@ -4327,12 +4463,12 @@ function passOn(body, limit, release, safetyMs, signal, closing, sent, sentMs) {
4327
4463
  } catch (error) {
4328
4464
  const bound = error instanceof PinnedFetchError ? error.streamBound : void 0;
4329
4465
  if (bound !== void 0) cut2(controller, error, { cause: bound.kind, ms: bound.ms });
4330
- else if (finish()) controller.error(error);
4466
+ else if (finish("error")) controller.error(error);
4331
4467
  return;
4332
4468
  }
4333
4469
  if (over) return;
4334
4470
  if (next.done) {
4335
- finish();
4471
+ finish("complete");
4336
4472
  controller.close();
4337
4473
  return;
4338
4474
  }
@@ -4347,10 +4483,11 @@ function passOn(body, limit, release, safetyMs, signal, closing, sent, sentMs) {
4347
4483
  );
4348
4484
  return;
4349
4485
  }
4486
+ tap?.feed(next.value);
4350
4487
  controller.enqueue(next.value);
4351
4488
  },
4352
4489
  cancel(reason) {
4353
- finish();
4490
+ finish("cut");
4354
4491
  clearTimeout(unread);
4355
4492
  return reader.cancel(reason).catch(() => {
4356
4493
  });
@@ -4475,10 +4612,11 @@ function refuse2(status, reason, message, extra = {}) {
4475
4612
  })
4476
4613
  });
4477
4614
  }
4478
- var RESOLVE_BY_HOST, STRIPPED_PREFIXES, STRIPPED, HOP_PREFIXES, HOP_HEADERS, isHopHeader, HAND_OUT_BYTES, AUDIT_PATH_CHARS, STALLED, SLOW, FIRST_HOLD_BYTES, CREDENTIAL_SHAPES, isHttpKind, safeHost;
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;
4479
4616
  var init_broker = __esm({
4480
4617
  "packages/core/src/broker.ts"() {
4481
4618
  "use strict";
4619
+ init_ai_vendors();
4482
4620
  init_broker_bodies();
4483
4621
  init_broker_stream();
4484
4622
  init_broker_token();
@@ -4488,6 +4626,7 @@ var init_broker = __esm({
4488
4626
  init_token();
4489
4627
  RESOLVE_BY_HOST = "_";
4490
4628
  STRIPPED_PREFIXES = ["x-wirl-"];
4629
+ AI_KEY_HEADERS = /* @__PURE__ */ new Set([...Object.values(AI_VENDOR_SPECS).map((spec) => spec.keyHeader), "api-key"]);
4491
4630
  STRIPPED = /* @__PURE__ */ new Set([
4492
4631
  "authorization",
4493
4632
  "proxy-authorization",
@@ -4608,7 +4747,11 @@ var init_retention = __esm({
4608
4747
  * The counts settle a "why was my app paused" question and the claims
4609
4748
  * ration the mail; neither is worth anything two months on.
4610
4749
  */
4611
- 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
4612
4755
  };
4613
4756
  DELETED_SHARE_MARK = "deleted; a restore brings this share back; its data is kept until";
4614
4757
  }
@@ -4666,6 +4809,7 @@ own live deploy and a person's yes. Declare a host under \`connections\`, then \
4666
4809
  wirl attaches the key. An undeclared host is refused; a public API needing no key uses \`"kind": "none"\`.
4667
4810
  Declaring one deploys before anyone approves it; the deploy says per connection whether it is usable now or
4668
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.
4669
4813
 
4670
4814
  Six headers say who is asking: \`x-wirl-user-id\`, \`x-wirl-user-email\`, \`x-wirl-user-name\`,
4671
4815
  \`x-wirl-role\`, \`x-wirl-app-id\`, \`x-wirl-org\`. Read the name with
@@ -4718,7 +4862,7 @@ var init_version = __esm({
4718
4862
  "packages/core/src/version.ts"() {
4719
4863
  "use strict";
4720
4864
  WORKSPACE_VERSION = "0.0.0";
4721
- BUILT_VERSION = true ? "0.2.3" : null;
4865
+ BUILT_VERSION = true ? "0.2.5" : null;
4722
4866
  }
4723
4867
  });
4724
4868
 
@@ -5120,6 +5264,7 @@ var init_src = __esm({
5120
5264
  init_slug();
5121
5265
  init_token();
5122
5266
  init_broker_token();
5267
+ init_broker_status();
5123
5268
  init_egress();
5124
5269
  init_manifest();
5125
5270
  init_sql_lex();
@@ -5147,6 +5292,7 @@ var init_src = __esm({
5147
5292
  init_manifest_transport();
5148
5293
  init_preview();
5149
5294
  init_visit_token();
5295
+ init_ai_vendors();
5150
5296
  }
5151
5297
  });
5152
5298
 
@@ -6406,7 +6552,7 @@ function searchGroup(text) {
6406
6552
  ]
6407
6553
  }
6408
6554
  ];
6409
- if (STATUS_TEXT.test(text)) filters.push({ key: "$workers.event.response.status", type: "number", operation: "eq", value: Number(text) });
6555
+ if (STATUS_TEXT2.test(text)) filters.push({ key: "$workers.event.response.status", type: "number", operation: "eq", value: Number(text) });
6410
6556
  return { kind: "group", filterCombination: "or", filters };
6411
6557
  }
6412
6558
  function kindFilters(kind) {
@@ -6427,7 +6573,7 @@ function kindFilters(kind) {
6427
6573
  return [];
6428
6574
  }
6429
6575
  }
6430
- var LogSourceError, DEFAULTS3, DATASET, MAX_EVENTS, RUN_HEADER, PUBLIC_HEADER, FLOW_HEADER, NOT_A_FLOW_STEP, serviceFilter, failedGroup, STATUS_TEXT, WorkersLogSource;
6576
+ var LogSourceError, DEFAULTS3, DATASET, MAX_EVENTS, RUN_HEADER, PUBLIC_HEADER, FLOW_HEADER, NOT_A_FLOW_STEP, serviceFilter, failedGroup, STATUS_TEXT2, WorkersLogSource;
6431
6577
  var init_workers_logs = __esm({
6432
6578
  "packages/runtime/src/workers-logs.ts"() {
6433
6579
  "use strict";
@@ -6456,7 +6602,7 @@ var init_workers_logs = __esm({
6456
6602
  { key: "$workers.outcome", type: "string", operation: "neq", value: "ok" }
6457
6603
  ]
6458
6604
  });
6459
- STATUS_TEXT = /^\d{3}$/;
6605
+ STATUS_TEXT2 = /^\d{3}$/;
6460
6606
  WorkersLogSource = class {
6461
6607
  #opts;
6462
6608
  constructor(options) {
@@ -7262,11 +7408,12 @@ company email domain; in any other org, including a personal one or one
7262
7408
  made with \`wirl orgs create\`, it starts private. Deploys are one
7263
7409
  command; sharing is by email; a third-party API is declared, never pasted.`;
7264
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.";
7265
- var SKILL_RULES = `1. **Never ask for or paste an API key.** Declare the connection in \`wirl.json\` and deploy. Needs no key at all (a public API)? Declare it with "kind": "none" and its hosts. If the key is missing, the deploy prints a link to the app's keys page; show the person that link. Locally, keys live in \`.wirl/keys.json\`, which git ignores. To try a vendor call locally without the key, map its connection to a server of your own in \`.wirl/mocks.json\`, like \`{ "anthropic": "http://127.0.0.1:4010" }\`: \`wirl dev\` then sends the calls to that connection's hosts there instead (\`wirl dev --help\` has the rest).
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).
7266
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.
7267
- 3. If a command names several organisations and asks which, ask the person; pass \`--org\` or put \`"org"\` in \`wirl.json\`.
7268
- 4. To keep a file out of the deploy, list it in a \`.wirlignore\` at the project root (gitignore-style lines; no \`!\` negation).
7269
- 5. **A file that can lose data waits for a person.** A deploy that would apply a destructive file (one wirl.json's \`"destructive"\` list must name) to the live database stops and names the page where a person approves each one. Show the person those pages, and deploy again once they say they approved. If \`${WIRL_BIN} deploy\` asks you to type a file's name, stop it and show the page instead, or deploy with \`--json\`, which never asks. Never type the name yourself or send the confirmation: that is the person's, and no flag skips it.`;
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.`;
7270
7417
  var SKILL_MANIFEST_EXAMPLE = `\`\`\`json
7271
7418
  { "app": "crm-sync", "server": "server.mjs", "connections": [{ "slug": "hubspot", "hosts": ["api.hubspot.com"] }] }
7272
7419
  \`\`\``;
@@ -7318,37 +7465,59 @@ init_src();
7318
7465
  var STARTER_CONFLICT = "Choose one: --server or --starter.";
7319
7466
  var SUGGESTIONS = [
7320
7467
  {
7321
- title: "Rename it to something we'd actually use",
7322
- prompt: `Rename this noticeboard to something my team would actually use. Ask me what we would use it for, suggest three names, and when I pick one, change the page's title and heading and the description in wirl.json to match. Keep "app" in wirl.json as it is: a new name there would make a second app at a new address. Then deploy it with wirl deploy.`
7323
- },
7324
- {
7325
- title: "Try a new look in a preview first",
7326
- prompt: "Give the noticeboard a warmer look: pinned notes as coloured cards, and tags as small coloured labels. Put it up with wirl preview first and give me the preview's address to check. Deploy it with wirl deploy only after I say it looks right."
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."
7327
7473
  },
7328
7474
  {
7329
- title: "Add a due date to notes",
7330
- prompt: "Add an optional due date to notes on the noticeboard. Add the column in a new file, migrations/0002_due_date.sql, and never edit 0001. Let people set the date when they post or edit a note, show it on each note, and put notes due soonest at the top. Try it with wirl dev, then deploy it with wirl deploy."
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."
7331
7480
  },
7332
7481
  {
7333
- title: "Show the weather from a public API",
7334
- prompt: `Show today's weather for our office at the top of the noticeboard, from Open-Meteo, a public weather API that needs no key. Ask me which city our office is in. Add this entry to "connections" in wirl.json: {"slug": "open-meteo", "kind": "none", "hosts": ["api.open-meteo.com"]}. Fetch Open-Meteo's own URL from server.mjs, and deploy it with wirl deploy. If the deploy says open-meteo is waiting, the connection needs approving, which your sign-in cannot do: tell me to approve it on the app's Connections page on app.wirl.dev, or to ask an admin of our organisation to. The weather shows once it is approved.`
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."
7335
7487
  },
7336
7488
  {
7337
- title: "Change when the tidy-up runs",
7338
- prompt: "Change the noticeboard's nightly tidy-up: archive notes after 14 days instead of 30, and run it at 6pm on weekdays in my time zone (ask me which). Change the schedule in wirl.json and the number in server.mjs, deploy it with wirl deploy, then run it once with wirl run tidy and tell me what it said."
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.'
7339
7495
  },
7340
7496
  {
7341
- title: "Find out what happened from the logs",
7342
- prompt: "Read this app's logs with wirl logs and tell me, in a few lines, what people did on the noticeboard today and whether anything failed."
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."
7343
7502
  },
7344
7503
  {
7345
- title: "Share it with a colleague",
7346
- prompt: `Help me share this noticeboard with a colleague. Sharing is done by me, not by a deploy: tell me to share it from the app's Access page on app.wirl.dev (the "Who can open it" row on the noticeboard links to it), where I add their email address. Then tell me what they will see when they open the address.`
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
7347
7510
  }
7348
7511
  ];
7512
+ var splitCapability = (capability) => {
7513
+ const stop = capability.indexOf(". ");
7514
+ return [capability.slice(0, stop + 1), capability.slice(stop + 2)];
7515
+ };
7349
7516
  var SUGGESTIONS_SOURCE = [
7350
7517
  "const SUGGESTIONS = [",
7351
- ...SUGGESTIONS.map(({ title, prompt }) => ` { title: ${JSON.stringify(title)}, prompt: ${JSON.stringify(prompt)} },`),
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
+ ),
7352
7521
  "];"
7353
7522
  ].join("\n");
7354
7523
  var SERVER_HEAD = String.raw`/**
@@ -7356,12 +7525,13 @@ var SERVER_HEAD = String.raw`/**
7356
7525
  *
7357
7526
  * Your team posts short notes here, tags them, pins the ones everyone should
7358
7527
  * see, and edits or removes their own. Every night a schedule archives old
7359
- * notes nobody pinned. The page also explains itself: a "How this works on
7360
- * wirl" panel, one row per feature, each linking to that page of the app on
7361
- * app.wirl.dev, and a few changes to ask your coding agent for next.
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.
7362
7531
  *
7363
7532
  * The whole app is this one file:
7364
- * - The page is HTML this module returns: plain forms, no script.
7533
+ * - The page is HTML this module returns: plain forms, and one small script
7534
+ * for the Copy prompt buttons.
7365
7535
  * - Notes live in env.DB, a SQLite database that belongs to this app alone.
7366
7536
  * Its tables are in migrations/0001_init.sql, and wirl applies each new
7367
7537
  * file in migrations/ before a version goes live. Put the next change in
@@ -7526,16 +7696,30 @@ async function page(request, url, me, env) {
7526
7696
  "<style>" + STYLE + "</style>",
7527
7697
  "<main>",
7528
7698
  "<header><h1>" + TITLE + "</h1>",
7529
- "<p class='dim'>Your first app on wirl. Signed in as <b>" + escape(me.name) + "</b>" + (me.role ? " (" + escape(me.role) + ")" : "") + ".</p></header>",
7699
+ "<p class='dim'>Signed in as <b>" + escape(me.name) + "</b>" + (me.role ? " (" + escape(me.role) + ")" : "") + ".</p></header>",
7700
+ me.role === "owner" ? welcome() : "",
7530
7701
  shown,
7531
7702
  "<ul class='notes'>" + (notes.length === 0 ? "<li class='dim'>Nothing here yet.</li>" : notes.map((note) => card(note, me, archived)).join("")) + "</ul>",
7532
- panel(request, me, lastRun),
7533
- suggestions(),
7703
+ "<p class='dim'>Nightly tidy-up, which archives notes older than " + ARCHIVE_AFTER_DAYS + " days that nobody pinned: " +
7704
+ (lastRun ? "last run " + escape(lastRun.ran_at.slice(0, 16)) + " UTC: " + escape(lastRun.note) : "Not run yet.") + "</p>",
7705
+ prompts(request),
7534
7706
  "</main>",
7707
+ COPY_SCRIPT,
7535
7708
  "</html>",
7536
7709
  ].join("\n");
7537
7710
  }
7538
7711
 
7712
+ // For the app's owner only: what this is, in a box above the note form.
7713
+ function welcome() {
7714
+ return [
7715
+ "<section class='welcome'>",
7716
+ "<p class='kicker'>Your first app on wirl</p>",
7717
+ "<h2>Your coding agent just built this, and it's live.</h2>",
7718
+ "<p>A small team noticeboard: notes, a nightly tidy-up, company sign-in, its own database. It runs at this address, it's private to you until you share it, and it's yours to change. Try out the app below and then customize it.</p>",
7719
+ "</section>",
7720
+ ].join("");
7721
+ }
7722
+
7539
7723
  function form() {
7540
7724
  return [
7541
7725
  "<form class='add' method='post' action='/notes'>",
@@ -7575,50 +7759,58 @@ function card(note, me, archived) {
7575
7759
  );
7576
7760
  }
7577
7761
 
7578
- // The app's page on the dashboard, from the address it was opened at:
7762
+ // The app's pages on the dashboard, from the address it was opened at:
7579
7763
  // <org>--<app>.wirl.run, or <org>--<app>--<preview>.wirl.run for a preview.
7580
7764
  // Under wirl dev the address is 127.0.0.1, which names no app: null.
7581
7765
  function dashboard(request) {
7582
7766
  const label = new URL(request.url).hostname.split(".")[0];
7583
7767
  const parts = label.split("--");
7584
7768
  if (parts.length < 2) return null;
7585
- const org = request.headers.get("x-wirl-org") || parts[0];
7586
- return DASHBOARD + "/apps/" + encodeURIComponent(org) + "/" + encodeURIComponent(parts[1]);
7587
- }
7588
-
7589
- function panel(request, me, lastRun) {
7590
- const base = dashboard(request);
7591
- const last = lastRun ? "Last run " + lastRun.ran_at.slice(0, 16) + " UTC: " + lastRun.note : "Not run yet.";
7592
- // One row per feature: what it is, what it does in this app, and its page.
7593
- const rows = [
7594
- ["Who can open it", "wirl signed you in before this page loaded and tells the app who you are, in headers: here you are " + me.name + (me.role ? ", " + me.role : "") + ". The app has no sign-in code of its own. It started private to you; choose who else can open it on Access.", "/access", "Access"],
7595
- ["Your data", "Notes are rows in this app's own SQLite database. Its tables are in migrations/0001_init.sql, and a deploy applies each new file in migrations/ before it goes live.", "/deployments", "Deployments"],
7596
- ["A schedule", "tidy runs every night at 02:00 UTC and archives notes older than " + ARCHIVE_AFTER_DAYS + " days that nobody pinned. " + last + " Run it now from Overview.", "", "Overview"],
7597
- ["Logs", "Each request writes a line with console.log, and so does each change to a note. Read them on Logs, or ask your agent to run wirl logs.", "/logs", "Logs"],
7598
- ["Versions", "Each wirl deploy is a new version, and the earlier ones stay: going back to one takes a click.", "/deployments", "Deployments"],
7599
- ["Previews", "wirl preview puts a change up at an address of its own, with its own database, and nothing goes live until you deploy. Open previews are listed on Overview.", "", "Overview"],
7600
- ["Outside APIs", "Declare a connection in wirl.json and fetch the service's own URL: wirl adds the key on the way out, so it never reaches this code or a chat.", "/connections", "Connections"],
7601
- ["Settings", "Describe the app for your team's list of tools, hand it to someone else, or delete it.", "/settings", "Settings"],
7769
+ const org = encodeURIComponent(request.headers.get("x-wirl-org") || parts[0]);
7770
+ return { app: DASHBOARD + "/apps/" + org + "/" + encodeURIComponent(parts[1]), org: DASHBOARD + "/orgs/" + org };
7771
+ }
7772
+
7773
+ // The onboarding path: each prompt whole, visible and ready to select and
7774
+ // copy, with what wirl gives the app and what you get. Then what the app
7775
+ // already uses, and its pages on the dashboard.
7776
+ function prompts(request) {
7777
+ const where = dashboard(request);
7778
+ const pages = [
7779
+ ["Overview", "app", ""],
7780
+ ["Data", "app", "/data"],
7781
+ ["Access", "app", "/access"],
7782
+ ["Logs", "app", "/logs"],
7783
+ ["Deployments", "app", "/deployments"],
7784
+ ["Models", "org", "/models"],
7785
+ ["Connections", "app", "/connections"],
7786
+ ["Settings", "app", "/settings"],
7602
7787
  ];
7788
+ const link = ([label, of, path]) =>
7789
+ where === null ? "<span class='dim'>" + label + "</span>" : "<a href='" + escape(where[of] + path) + "'>" + label + "</a>";
7790
+ const step = (each) => {
7791
+ const stop = each.capability.indexOf(". ");
7792
+ return (
7793
+ "<li class='step" + (each.number === 1 ? " first" : "") + (each.optional ? " optional" : "") + "'>" +
7794
+ "<h3><span class='number'>" + each.number + "</span> " + escape(each.title) +
7795
+ (each.number === 1 ? " <span class='badge start'>Start here</span>" : "") +
7796
+ (each.optional ? " <span class='badge'>Optional. The big one.</span>" : "") + "</h3>" +
7797
+ "<p class='dim'><b>" + escape(each.capability.slice(0, stop + 1)) + "</b> " + escape(each.capability.slice(stop + 2)) + "</p>" +
7798
+ "<div class='getrow'>" +
7799
+ "<button type='button' class='copy' data-copy-for='prompt-" + each.number + "'>Copy prompt</button>" +
7800
+ "<p class='dim'>You get: " + escape(each.youGet) + "</p></div>" +
7801
+ "<details class='prompt'><summary>Show the prompt</summary>" +
7802
+ "<pre id='prompt-" + each.number + "'>" + escape(each.prompt) + "</pre></details>" +
7803
+ "</li>"
7804
+ );
7805
+ };
7603
7806
  return [
7604
- "<section><h2>How this works on wirl</h2>",
7605
- "<table>",
7606
- ...rows.map(([feature, what, path, label]) =>
7607
- "<tr><td>" + feature + "</td><td>" + escape(what) + "</td><td>" +
7608
- (base === null ? "<span class='dim'>" + label + "</span>" : "<a href='" + escape(base + path) + "'>" + label + "</a>") +
7609
- "</td></tr>",
7610
- ),
7611
- "</table>",
7612
- base === null ? "<p class='dim'>Running under wirl dev, so these pages are not linked here: they open on app.wirl.dev once it is deployed.</p>" : "",
7613
- "</section>",
7614
- ].join("\n");
7615
- }
7616
-
7617
- function suggestions() {
7618
- return [
7619
- "<section><h2>Ask your agent for a first change</h2>",
7620
- "<p class='dim'>Each of these is a whole prompt. Open one, copy it, and paste it to the coding agent that made this app.</p>",
7621
- ...SUGGESTIONS.map((each) => "<details><summary>" + escape(each.title) + "</summary><pre>" + escape(each.prompt) + "</pre></details>"),
7807
+ "<section id='prompts'><h2>Make it yours</h2>",
7808
+ "<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>",
7809
+ "<ol class='steps'>",
7810
+ ...SUGGESTIONS.map(step),
7811
+ "</ol>",
7812
+ "<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>",
7813
+ 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>" : "",
7622
7814
  "</section>",
7623
7815
  ].join("\n");
7624
7816
  }
@@ -7635,6 +7827,7 @@ const STYLE = [
7635
7827
  "a { color: var(--accent); } .dim { color: var(--dim); }",
7636
7828
  "form.add, form.edit { display: grid; gap: 8px; margin: 16px 0 8px; }",
7637
7829
  "textarea, input, button { font: inherit; color: inherit; padding: 7px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--card); }",
7830
+ "button.copy { min-height: 44px; margin-top: 4px; }",
7638
7831
  "button { cursor: pointer; } button.primary { justify-self: start; background: var(--accent); border-color: var(--accent); color: var(--bg); }",
7639
7832
  "ul.notes { list-style: none; padding: 0; display: grid; gap: 10px; }",
7640
7833
  ".note { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }",
@@ -7643,13 +7836,49 @@ const STYLE = [
7643
7836
  ".meta, .actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 13px; }",
7644
7837
  ".actions { margin-top: 8px; } .actions form { margin: 0; } .actions details { flex-basis: 100%; order: 3; }",
7645
7838
  ".tag { text-decoration: none; }",
7646
- "table { width: 100%; border-collapse: collapse; font-size: 14px; }",
7647
- "td { border-top: 1px solid var(--line); padding: 8px 6px; vertical-align: top; }",
7648
- "td:first-child { font-weight: 600; }",
7649
- "pre { white-space: pre-wrap; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 10px; font-size: 13px; }",
7839
+ "ol.steps { list-style: none; padding: 0; display: grid; gap: 12px; }",
7840
+ ".step { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }",
7841
+ ".step.optional { border: 2px solid var(--accent); }",
7842
+ ".step h3 { font-size: 16px; margin: 0 0 6px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }",
7843
+ ".step p { margin: 6px 0; } .step b { color: var(--accent); }",
7844
+ ".number { font-size: 12px; background: var(--ink); color: var(--bg); padding: 2px 7px; border-radius: 5px; }",
7845
+ ".optional .number { background: var(--accent); }",
7846
+ ".badge { font-size: 12px; font-weight: 600; color: var(--dim); border: 1px solid var(--line); padding: 1px 8px; border-radius: 999px; }",
7847
+ ".step.first { border: 2px solid var(--accent); }",
7848
+ ".badge.start { color: var(--accent); border-color: var(--accent); }",
7849
+ ".welcome { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 14px 16px; margin: 16px 0 8px; }",
7850
+ ".welcome h2 { font-size: 19px; margin: 4px 0 8px; } .welcome p { margin: 0; }",
7851
+ ".welcome .kicker { font-size: 12px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--accent); }",
7852
+ ".getrow { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; } .getrow p { margin: 0; }",
7853
+ "details.prompt summary { display: flex; align-items: center; min-height: 44px; color: var(--dim); font-size: 14px; cursor: pointer; }",
7854
+ ".uses { border-top: 1px solid var(--line); padding-top: 12px; font-size: 14px; }",
7855
+ "pre { white-space: pre-wrap; user-select: all; background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 10px; font-size: 13px; }",
7650
7856
  "details { margin: 6px 0; } summary { cursor: pointer; }",
7651
7857
  ].join("\n");
7652
7858
 
7859
+ const COPY_SCRIPT = [
7860
+ "<script>",
7861
+ "document.addEventListener('click', (event) => {",
7862
+ " const button = event.target.closest('.copy');",
7863
+ " if (!button) return;",
7864
+ " const pre = document.getElementById(button.getAttribute('data-copy-for'));",
7865
+ " const flash = (label) => {",
7866
+ " button.textContent = label;",
7867
+ " setTimeout(() => { button.textContent = 'Copy prompt'; }, 1500);",
7868
+ " };",
7869
+ " const select = () => {",
7870
+ " const range = document.createRange();",
7871
+ " range.selectNodeContents(pre);",
7872
+ " window.getSelection().removeAllRanges();",
7873
+ " window.getSelection().addRange(range);",
7874
+ " flash('Select and copy');",
7875
+ " };",
7876
+ " if (!navigator.clipboard) return select();",
7877
+ " navigator.clipboard.writeText(pre.textContent).then(() => flash('Copied'), select);",
7878
+ "});",
7879
+ "</script>",
7880
+ ].join("\n");
7881
+
7653
7882
  const home = (to = "/") => new Response(null, { status: 303, headers: { location: to } });
7654
7883
 
7655
7884
  const text = (body, status = 200) => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
@@ -7687,7 +7916,7 @@ var STARTER_MIGRATION = [
7687
7916
  "-- Make the next change in 0002_<what>.sql; never edit a file that has been deployed.",
7688
7917
  "create table notes (id integer primary key, body text not null, tags text not null default '', pinned integer not null default 0, author_id text not null, author_name text not null default '', created_at text not null default (datetime('now')), archived_at text)",
7689
7918
  "create table runs (id integer primary key, schedule text not null, note text not null, ran_at text not null default (datetime('now')))",
7690
- "insert into notes (body, tags, pinned, author_id, author_name) values ('Welcome to the noticeboard. Post a note below, tag it, and pin the ones everyone should see.', 'welcome', 1, '', 'wirl')",
7919
+ "insert into notes (body, tags, pinned, author_id, author_name) values ('Welcome to the noticeboard. This note is pinned so everyone sees it first; post your own below.', 'welcome', 1, '', 'wirl')",
7691
7920
  ""
7692
7921
  ].join("\n");
7693
7922
  function starterManifest(app, org) {
@@ -7708,7 +7937,7 @@ var README = [
7708
7937
  "",
7709
7938
  "Your first app on wirl: a board where your team posts short notes, tags them, pins the ones everyone should see, and edits or removes their own. Every night a schedule archives old notes nobody pinned.",
7710
7939
  "",
7711
- 'Open it at the address `wirl deploy` prints. It starts private to you. Its page has a "How this works on wirl" panel: one row per thing wirl does for this app, each linking to that page of the app on app.wirl.dev.',
7940
+ "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.",
7712
7941
  "",
7713
7942
  "## What is where",
7714
7943
  "",
@@ -7723,11 +7952,14 @@ var README = [
7723
7952
  "",
7724
7953
  "It prints the address to open, and a `curl` line that runs the nightly tidy-up now.",
7725
7954
  "",
7726
- "## Changes to ask your agent for",
7955
+ "## Make it yours",
7727
7956
  "",
7728
- "Each is a whole prompt: paste it to your coding agent as it is.",
7957
+ "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.",
7729
7958
  "",
7730
- ...SUGGESTIONS.flatMap(({ title, prompt }) => [`### ${title}`, "", `> ${prompt}`, ""])
7959
+ ...SUGGESTIONS.flatMap(({ number, title, capability, prompt, youGet, optional }) => {
7960
+ const [bold, rest] = splitCapability(capability);
7961
+ return [`### ${String(number)}. ${title}`, "", ...optional ? ["Optional. The big one.", ""] : [], `**${bold}** ${rest}`, "", `> ${prompt}`, "", `You get: ${youGet}`, ""];
7962
+ })
7731
7963
  ].join("\n");
7732
7964
  var STARTER_FILES = {
7733
7965
  "server.mjs": SERVER_MODULE,
@@ -7798,6 +8030,14 @@ async function loadCredentials(env = process.env) {
7798
8030
  }
7799
8031
  throw new CliError("Not signed in.", `Run \`${WIRL_BIN} login\` first, or set WIRL_TOKEN.`, { code: "not_signed_in" });
7800
8032
  }
8033
+ async function loadCredentialsIfAny(env = process.env) {
8034
+ try {
8035
+ return await loadCredentials(env);
8036
+ } catch (error) {
8037
+ if (error instanceof CliError && error.code === "not_signed_in") return null;
8038
+ throw error;
8039
+ }
8040
+ }
7801
8041
  async function saveCredentials(creds, env = process.env) {
7802
8042
  const path = credentialsPath(env);
7803
8043
  await mkdir3(dirname3(path), { recursive: true });
@@ -8513,7 +8753,7 @@ async function callApiBytes(creds, path, options = {}) {
8513
8753
  body = {};
8514
8754
  }
8515
8755
  if (response.status === 401 || response.status === 403) {
8516
- throw new CliError("Not signed in, or this token cannot do that.", `Try \`${WIRL_BIN} login\`.`);
8756
+ throw new CliError("Not signed in, or this token cannot do that.", `Try \`${WIRL_BIN} login\`.`, { code: "unauthenticated" });
8517
8757
  }
8518
8758
  if (response.status === 404) throw new CliError("Not found, or you do not have access to it.");
8519
8759
  throw new CliError(body.message ?? body.error ?? `Request failed (${response.status}).`);
@@ -10693,6 +10933,8 @@ function orMock(declared) {
10693
10933
  var MOCKS_FILE = join6(".wirl", "mocks.json");
10694
10934
  var MOCKS_FILE_MAX_BYTES = 65536;
10695
10935
  var MOCK_PLACEHOLDER = "wirl-dev-mock";
10936
+ var MODELS_MOCK = "ai";
10937
+ var isModelsMock = (mock) => mock.hosts.length === 1 && mock.hosts[0] === AI_HOST;
10696
10938
  var MOCKS_EXAMPLE = 'It should look like: { "anthropic": "http://127.0.0.1:4010" }';
10697
10939
  async function readBounded2(path, limit) {
10698
10940
  let handle;
@@ -10745,7 +10987,8 @@ async function readLocalMocks(dir, manifest) {
10745
10987
  for (const [slug, value] of Object.entries(parsed)) {
10746
10988
  const name = JSON.stringify(slug);
10747
10989
  const declared = manifest.connections.find((each) => each.slug === slug);
10748
- if (declared === void 0) {
10990
+ const forModels = declared === void 0 && slug === MODELS_MOCK;
10991
+ if (declared === void 0 && !forModels) {
10749
10992
  throw new CliError(`${path} maps ${name}, and wirl.json declares no connection called that.`, "Each name in it is a connection wirl.json declares.");
10750
10993
  }
10751
10994
  if (typeof value !== "string") {
@@ -10766,6 +11009,10 @@ async function readLocalMocks(dir, manifest) {
10766
11009
  if (base.search !== "" || base.hash !== "") {
10767
11010
  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.`);
10768
11011
  }
11012
+ if (declared === void 0) {
11013
+ mocks.set(slug, { slug, hosts: [AI_HOST], base });
11014
+ continue;
11015
+ }
10769
11016
  const hosts = declared.hosts ?? [];
10770
11017
  if (hosts.length === 0) {
10771
11018
  throw new CliError(`${path} maps ${name}, which declares no hosts in wirl.json, so no call could go to its mock.`);
@@ -10836,6 +11083,111 @@ function routeToMocks(manifest, mocks, real, toMock) {
10836
11083
  return toMock(mockTarget(mock.base, url), init);
10837
11084
  };
10838
11085
  }
11086
+ function forwardedHeaders(incoming) {
11087
+ const out2 = new Headers();
11088
+ incoming.forEach((value, name) => {
11089
+ const lower = name.toLowerCase();
11090
+ if (NOT_FORWARDED.has(lower) || lower.startsWith("x-wirl-")) return;
11091
+ out2.set(name, value);
11092
+ });
11093
+ return out2;
11094
+ }
11095
+ var NOT_FORWARDED = /* @__PURE__ */ new Set([
11096
+ "authorization",
11097
+ "proxy-authorization",
11098
+ "cookie",
11099
+ "host",
11100
+ "connection",
11101
+ "keep-alive",
11102
+ "transfer-encoding",
11103
+ "upgrade",
11104
+ "te",
11105
+ "trailer",
11106
+ "content-length",
11107
+ "accept-encoding"
11108
+ ]);
11109
+ function passedOn(response) {
11110
+ const headers = new Headers();
11111
+ response.headers.forEach((value, name) => {
11112
+ const lower = name.toLowerCase();
11113
+ if (lower === "content-encoding" || lower === "content-length" || lower.startsWith("x-wirl-")) return;
11114
+ headers.append(name, value);
11115
+ });
11116
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
11117
+ }
11118
+ function reasonOf3(error) {
11119
+ const cause = error instanceof Error ? error.cause : void 0;
11120
+ if (typeof cause?.code === "string") return cause.code;
11121
+ return error instanceof Error ? error.message : String(error);
11122
+ }
11123
+ function localModels(ai, mock, log) {
11124
+ if (ai === void 0 && mock === null) return void 0;
11125
+ const credentials = ai?.credentials ?? null;
11126
+ let org = null;
11127
+ const orgOnce = () => {
11128
+ org ??= (ai?.org ?? (async () => null))().catch((error) => {
11129
+ org = null;
11130
+ throw error;
11131
+ });
11132
+ return org;
11133
+ };
11134
+ return async (request, _caller, target, body) => {
11135
+ const said = `wirl dev \xB7 ai.wirl ${request.method} ${target.pathname}`;
11136
+ const headers = forwardedHeaders(request.headers);
11137
+ const sent = body === null || request.method === "GET" || request.method === "HEAD" ? null : body;
11138
+ if (mock !== null) {
11139
+ let response2;
11140
+ try {
11141
+ response2 = await fetch(mockTarget(mock, target), { method: request.method, headers, body: sent, redirect: "manual", signal: request.signal });
11142
+ } catch (error) {
11143
+ if (request.signal.aborted) throw error;
11144
+ log(`${said} \u2192 mock unreachable (${reasonOf3(error)})`);
11145
+ return aiRefusal(502, "ai_vendor_unreachable", "The ai mock could not be reached.");
11146
+ }
11147
+ log(`${said} \u2192 mock ${response2.status}`);
11148
+ return passedOn(response2);
11149
+ }
11150
+ if (credentials === null) {
11151
+ log(`${said} \u2192 ai_no_key 402 (not signed in)`);
11152
+ return aiRefusal(402, "ai_no_key", "Run `wirl login` to use models locally.");
11153
+ }
11154
+ let slug;
11155
+ try {
11156
+ slug = await orgOnce();
11157
+ } catch (error) {
11158
+ if (error instanceof CliError && (error.code === "unauthenticated" || error.code === "not_signed_in")) {
11159
+ log(`${said} \u2192 ai_no_key 402 (login not accepted)`);
11160
+ return aiRefusal(402, "ai_no_key", "Run `wirl login` to use models locally.");
11161
+ }
11162
+ log(`${said} \u2192 ai_vendor_unreachable 502 (whoami failed)`);
11163
+ return aiRefusal(502, "ai_vendor_unreachable", "app.wirl.dev could not be reached to find your organisation.");
11164
+ }
11165
+ if (slug === null) {
11166
+ log(`${said} \u2192 ai_no_key 402 (no org in wirl.json)`);
11167
+ return aiRefusal(402, "ai_no_key", 'Add "org" to wirl.json to say which organisation local model calls count against.');
11168
+ }
11169
+ headers.set("authorization", `Bearer ${credentials.token}`);
11170
+ headers.set("x-wirl-org", slug);
11171
+ headers.set("x-wirl-client", "cli");
11172
+ let response;
11173
+ try {
11174
+ response = await (credentials.fetchImpl ?? fetch)(`${credentials.apiOrigin}/ai/_${target.pathname}${target.search}`, {
11175
+ method: request.method,
11176
+ headers,
11177
+ body: sent,
11178
+ redirect: "manual",
11179
+ signal: request.signal
11180
+ });
11181
+ } catch (error) {
11182
+ if (request.signal.aborted) throw error;
11183
+ log(`${said} \u2192 ai_vendor_unreachable 502 (${reasonOf3(error)})`);
11184
+ return aiRefusal(502, "ai_vendor_unreachable", "app.wirl.dev could not be reached for this model call.");
11185
+ }
11186
+ const answer = restoreStatus(response);
11187
+ log(`${said} \u2192 ${answer.status}`);
11188
+ return passedOn(answer);
11189
+ };
11190
+ }
10839
11191
  async function toRequest(incoming, outgoing, origin) {
10840
11192
  const left = new AbortController();
10841
11193
  outgoing.once("close", () => {
@@ -10905,7 +11257,10 @@ async function startLocalBroker(options) {
10905
11257
  heldBodyLimits: options.heldBodyLimits,
10906
11258
  heldAnswerLimits: options.heldAnswerLimits
10907
11259
  });
10908
- const mocks = options.mocks ?? /* @__PURE__ */ new Map();
11260
+ const allMocks = options.mocks ?? /* @__PURE__ */ new Map();
11261
+ const modelsMock = [...allMocks.values()].find(isModelsMock);
11262
+ const mocks = new Map([...allMocks].filter(([, mock]) => !isModelsMock(mock)));
11263
+ const models = localModels(options.ai, options.ai?.mock ?? modelsMock?.base ?? null, log);
10909
11264
  const fetchImpl = mocks.size === 0 ? transport.fetchImpl : routeToMocks(
10910
11265
  options.manifest,
10911
11266
  mocks,
@@ -10938,7 +11293,8 @@ async function startLocalBroker(options) {
10938
11293
  flows: async (request, _caller, target) => {
10939
11294
  log(`wirl dev \xB7 flows.wirl ${request.method} ${target.pathname} \u2192 no_flows 404`);
10940
11295
  return noFlowsAnswer();
10941
- }
11296
+ },
11297
+ ...models === void 0 ? {} : { ai: models }
10942
11298
  });
10943
11299
  await send2(response, outgoing);
10944
11300
  } catch (error) {
@@ -11025,7 +11381,21 @@ async function dev(dir, options = {}) {
11025
11381
  }
11026
11382
  const keys = await readLocalKeys(dir);
11027
11383
  const mocks = await readLocalMocks(dir, parsed.manifest);
11028
- const broker = await startLocalBroker({ manifest: parsed.manifest, dir, log, mocks });
11384
+ const credentials = await loadCredentialsIfAny();
11385
+ const manifestOrg = (await loadManifest(dir))?.org ?? null;
11386
+ const org = async () => {
11387
+ if (manifestOrg !== null) return manifestOrg;
11388
+ if (credentials === null) return null;
11389
+ try {
11390
+ return chooseOrg((await whoami(credentials)).orgs, null);
11391
+ } catch (error) {
11392
+ if (error instanceof CliError && (error.code === "no_org" || error.code === "missing_argument")) return null;
11393
+ throw error;
11394
+ }
11395
+ };
11396
+ const broker = await startLocalBroker({ manifest: parsed.manifest, dir, log, mocks, ai: { credentials, org } });
11397
+ const modelsMock = parsed.manifest.connections.some((each) => each.slug === MODELS_MOCK) ? void 0 : mocks.get(MODELS_MOCK);
11398
+ if (modelsMock !== void 0) log(`mock ${MODELS_MOCK}: calls to ${modelsMock.hosts.join(", ")} go to ${modelsMock.base.href}, with no token`);
11029
11399
  for (const declared of parsed.manifest.connections) {
11030
11400
  log(keyLine(declared, keys));
11031
11401
  const misshapen = keyShapeWarning(declared, keys);
@@ -11475,7 +11845,7 @@ var Restarts = class {
11475
11845
  init_src();
11476
11846
  var GRACE_DAYS = String(RETENTION.deletedAppGraceDays);
11477
11847
  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.`;
11478
- var DEV_KEYS_NOTE = `Keys come from .wirl/keys.json, in the shape this prints for each connection that has none. To try a connection without its key, map it to a server of your own in .wirl/mocks.json, like { "anthropic": "http://127.0.0.1:4010" }. A call to one of that connection's declared hosts then goes to that URL instead, its path added to the URL's own and its query kept. It carries the key from .wirl/keys.json, or, when that has none, the placeholder "wirl-dev-mock" in the shape of the connection's kind. With no key there, an http_header connection sends no key header, since only .wirl/keys.json names the header, and a none connection never sends one. A host the connection does not declare is refused as before. .wirl/mocks.json is read once, at start, and never uploaded.`;
11848
+ 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.`;
11479
11849
  var DEV_FLAGS_NOTE = "A flag not listed below, other than --help, is refused, not ignored.";
11480
11850
  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.";
11481
11851
  var PREVIEW_LINK_MINUTES = String(VISIT_TOKEN_TTL_SECONDS / 60);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wirl",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Deploy an internal app somewhere real, privately. One command; sharing by email; API keys declared, never pasted.",
5
5
  "keywords": [
6
6
  "deploy",