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.
Files changed (2) hide show
  1. package/bin.js +932 -97
  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();
@@ -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
- 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;
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
- if (route.slug === RESOLVE_BY_HOST) {
3887
- const target2 = request.headers.get("x-wirl-target");
3888
- if (!target2) {
3889
- await record("no_target", 400, "");
3890
- return refuse2(400, "no_target", "Resolving a connection by host needs X-Wirl-Target.");
3891
- }
3892
- path = egressAuditPath(target2);
3893
- const host2 = safeHost(target2);
3894
- const matches = connections.filter(
3895
- (candidate) => isHttpKind(candidate.kind) && host2.length > 0 && isHostAllowed(host2, candidate.allowedHosts)
3896
- );
3897
- if (matches.length === 0) {
3898
- await record("no_grant", 403, host2);
3899
- return refuse2(403, "no_grant", `No granted connection covers ${host2 || target2}.`);
3900
- }
3901
- if (matches.length > 1) {
3902
- await record("ambiguous", 403, host2);
3903
- return refuse2(
3904
- 403,
3905
- "ambiguous",
3906
- `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)
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) return callerLeft(record, safeHost(target));
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) return callerLeft(record, safeHost(target));
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(passOn(body, context.limit, release, context.safetyMs, context.signal, context.closing, context.sent, context.sentMs), {
4214
- status: upstream.status,
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.2" : null;
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 (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) });
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, 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;
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
- STATUS_TEXT = /^\d{3}$/;
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. If a command names several organisations and asks which, ask the person; pass \`--org\` or put \`"org"\` in \`wirl.json\`.
7246
- 4. To keep a file out of the deploy, list it in a \`.wirlignore\` at the project root (gitignore-style lines; no \`!\` negation).
7247
- 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.`;
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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 --server again.`);
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 --server again.`);
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 --server writes ${INIT_MIGRATION_PATH} into a folder of that name. Move it aside, then run wirl init --server again.`
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 --server writes ${INIT_MIGRATION_PATH} into a folder with none.`);
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 (options.server) {
7701
- const target = join5(dir, "server.mjs");
7702
- try {
7703
- await readFile3(target);
7704
- throw new CliError(`server.mjs already exists in ${dir}.`, "Remove --server, or move the existing file.");
7705
- } catch (error) {
7706
- if (error instanceof CliError) throw error;
7707
- }
7708
- await refuseInitMigrations(dir);
7709
- await writeFile2(target, SERVER_TEMPLATE, "utf8");
7710
- written.push("server.mjs");
7711
- await mkdir3(join5(dir, MIGRATIONS_DIR), { recursive: true });
7712
- await writeFile2(join5(dir, MIGRATIONS_DIR, ADOPTED_FILE), INIT_MIGRATION, { encoding: "utf8", flag: "wx" });
7713
- written.push(INIT_MIGRATION_PATH);
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
- await writeFile2(join5(dir, MANIFEST_NAME), `${JSON.stringify(manifest, null, 2)}
7728
- `, "utf8");
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, "Ask the person to do it in the dashboard, or reconnect with full access.", refusal);
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
- body: JSON.stringify({ slug, name: name ?? slug }),
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(() => true, (error) => {
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
- if (declared === void 0) {
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 mocks = options.mocks ?? /* @__PURE__ */ new Map();
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 broker = await startLocalBroker({ manifest: parsed.manifest, dir, log, mocks });
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(`Signed in. Organisations: ${me.orgs.map((o) => o.slug).join(", ") || "none yet"}.`);
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 dir = resolve6(args.positional[0] ?? ".");
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
  });