@wirl/mcp 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin.js +465 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ Cursor and anything else that speaks MCP take the same command in their own conf
|
|
|
30
30
|
|
|
31
31
|
If your npm registry does not carry wirl, or to run exactly the build app.wirl.dev serves, name its address instead of the package: `claude mcp add wirl -- npx -y https://app.wirl.dev/cli/wirl-mcp.tgz`.
|
|
32
32
|
|
|
33
|
-
The local server signs in the first time a wirl tool needs it: the agent calls `wirl_login`, which returns a link and a code, and the agent shows both to the person. The person approves the code in the browser, and the agent's next wirl tool call works. A machine already signed in with `npx wirl login` skips this. A local server acts with the CLI's credentials
|
|
33
|
+
The local server signs in the first time a wirl tool needs it: the agent calls `wirl_login`, which returns a link and a code, and the agent shows both to the person. The person approves the code in the browser, and the agent's next wirl tool call works. A machine already signed in with `npx wirl login` skips this. A local server acts with the CLI's credentials: build-and-update unless the person ticked full access on the approval page. Revoke them on the Connect an agent page at app.wirl.dev if an agent should lose access.
|
|
34
34
|
|
|
35
35
|
## Tools
|
|
36
36
|
|
package/bin.js
CHANGED
|
@@ -2363,6 +2363,9 @@ function parseManifest(input) {
|
|
|
2363
2363
|
}
|
|
2364
2364
|
accent = rawAccent;
|
|
2365
2365
|
}
|
|
2366
|
+
const rawVisibility = raw["visibility"];
|
|
2367
|
+
if (rawVisibility !== void 0 && rawVisibility !== "private") return refuse(VISIBILITY_REFUSED);
|
|
2368
|
+
const visibility = rawVisibility === "private" ? { visibility: "private" } : {};
|
|
2366
2369
|
const flowResult = parseFlows(raw);
|
|
2367
2370
|
if (!flowResult.ok) return refuse(flowResult.message);
|
|
2368
2371
|
const flows = flowResult.flows;
|
|
@@ -2381,7 +2384,7 @@ function parseManifest(input) {
|
|
|
2381
2384
|
if (!publicPathsResult2.ok) return refuse(publicPathsResult2.message);
|
|
2382
2385
|
return {
|
|
2383
2386
|
ok: true,
|
|
2384
|
-
manifest: { runtime, connections: [], description, accent, schedules, flows, publicPaths: publicPathsResult2.publicPaths, static: staticFiles, migrations },
|
|
2387
|
+
manifest: { runtime, connections: [], description, accent, schedules, flows, publicPaths: publicPathsResult2.publicPaths, static: staticFiles, migrations, ...visibility },
|
|
2385
2388
|
warnings
|
|
2386
2389
|
};
|
|
2387
2390
|
}
|
|
@@ -2440,11 +2443,11 @@ function parseManifest(input) {
|
|
|
2440
2443
|
if (!publicPathsResult.ok) return refuse(publicPathsResult.message);
|
|
2441
2444
|
return {
|
|
2442
2445
|
ok: true,
|
|
2443
|
-
manifest: { runtime, connections, description, accent, schedules, flows, publicPaths: publicPathsResult.publicPaths, static: staticFiles, migrations },
|
|
2446
|
+
manifest: { runtime, connections, description, accent, schedules, flows, publicPaths: publicPathsResult.publicPaths, static: staticFiles, migrations, ...visibility },
|
|
2444
2447
|
warnings
|
|
2445
2448
|
};
|
|
2446
2449
|
}
|
|
2447
|
-
var CONNECTION_KINDS, UNDELIVERED_KINDS, CREATABLE_CONNECTION_KINDS, needsNoKey, ACCENTS, SCHEDULE_LIMITS, MANIFEST_KEYS, QUIET_KEYS, UNKNOWN_KEYS_NAMED, KEY_SHOWN, squash, ONE_EDIT_MIN_LETTERS, pluralOf, SLUG, LABEL, HOST_PATTERN, refuse, asHint, SCHEDULE_NAME, TIMEOUT_PATTERN, TIMEOUT_MESSAGE, FLOW_KEYS, MIGRATIONS_KEYS;
|
|
2450
|
+
var CONNECTION_KINDS, UNDELIVERED_KINDS, CREATABLE_CONNECTION_KINDS, needsNoKey, ACCENTS, SCHEDULE_LIMITS, MANIFEST_KEYS, QUIET_KEYS, UNKNOWN_KEYS_NAMED, KEY_SHOWN, squash, ONE_EDIT_MIN_LETTERS, pluralOf, SLUG, LABEL, HOST_PATTERN, refuse, asHint, SCHEDULE_NAME, TIMEOUT_PATTERN, TIMEOUT_MESSAGE, FLOW_KEYS, MIGRATIONS_KEYS, VISIBILITY_REFUSED;
|
|
2448
2451
|
var init_manifest = __esm({
|
|
2449
2452
|
"packages/core/src/manifest.ts"() {
|
|
2450
2453
|
"use strict";
|
|
@@ -2484,7 +2487,8 @@ var init_manifest = __esm({
|
|
|
2484
2487
|
"server",
|
|
2485
2488
|
"spa",
|
|
2486
2489
|
"start",
|
|
2487
|
-
"static"
|
|
2490
|
+
"static",
|
|
2491
|
+
"visibility"
|
|
2488
2492
|
]);
|
|
2489
2493
|
QUIET_KEYS = Object.freeze(["$schema"]);
|
|
2490
2494
|
UNKNOWN_KEYS_NAMED = 10;
|
|
@@ -2506,6 +2510,7 @@ var init_manifest = __esm({
|
|
|
2506
2510
|
TIMEOUT_MESSAGE = (name) => `timeout for ${name} must be between 30s and 15m; a run longer than that should be split into resumable chunks.`;
|
|
2507
2511
|
FLOW_KEYS = ["name", "step_timeout", "retries"];
|
|
2508
2512
|
MIGRATIONS_KEYS = ["destructive"];
|
|
2513
|
+
VISIBILITY_REFUSED = `visibility can only be "private": it sets who can open a new app when a deploy creates it. Change an existing app's visibility with wirl visibility.`;
|
|
2509
2514
|
}
|
|
2510
2515
|
});
|
|
2511
2516
|
|
|
@@ -3003,7 +3008,7 @@ var init_version = __esm({
|
|
|
3003
3008
|
"packages/core/src/version.ts"() {
|
|
3004
3009
|
"use strict";
|
|
3005
3010
|
WORKSPACE_VERSION = "0.0.0";
|
|
3006
|
-
BUILT_VERSION = true ? "0.2.
|
|
3011
|
+
BUILT_VERSION = true ? "0.2.3" : null;
|
|
3007
3012
|
}
|
|
3008
3013
|
});
|
|
3009
3014
|
|
|
@@ -5241,6 +5246,428 @@ ${FLOWS_CONTRACT}
|
|
|
5241
5246
|
`;
|
|
5242
5247
|
}
|
|
5243
5248
|
|
|
5249
|
+
// packages/cli/src/starter.ts
|
|
5250
|
+
init_src();
|
|
5251
|
+
var SUGGESTIONS = [
|
|
5252
|
+
{
|
|
5253
|
+
title: "Rename it to something we'd actually use",
|
|
5254
|
+
prompt: `Rename this noticeboard to something my team would actually use. Ask me what we would use it for, suggest three names, and when I pick one, change the page's title and heading and the description in wirl.json to match. Keep "app" in wirl.json as it is: a new name there would make a second app at a new address. Then deploy it with wirl deploy.`
|
|
5255
|
+
},
|
|
5256
|
+
{
|
|
5257
|
+
title: "Try a new look in a preview first",
|
|
5258
|
+
prompt: "Give the noticeboard a warmer look: pinned notes as coloured cards, and tags as small coloured labels. Put it up with wirl preview first and give me the preview's address to check. Deploy it with wirl deploy only after I say it looks right."
|
|
5259
|
+
},
|
|
5260
|
+
{
|
|
5261
|
+
title: "Add a due date to notes",
|
|
5262
|
+
prompt: "Add an optional due date to notes on the noticeboard. Add the column in a new file, migrations/0002_due_date.sql, and never edit 0001. Let people set the date when they post or edit a note, show it on each note, and put notes due soonest at the top. Try it with wirl dev, then deploy it with wirl deploy."
|
|
5263
|
+
},
|
|
5264
|
+
{
|
|
5265
|
+
title: "Show the weather from a public API",
|
|
5266
|
+
prompt: `Show today's weather for our office at the top of the noticeboard, from Open-Meteo, a public weather API that needs no key. Ask me which city our office is in. Add this entry to "connections" in wirl.json: {"slug": "open-meteo", "kind": "none", "hosts": ["api.open-meteo.com"]}. Fetch Open-Meteo's own URL from server.mjs, and deploy it with wirl deploy. If the deploy says open-meteo is waiting, the connection needs approving, which your sign-in cannot do: tell me to approve it on the app's Connections page on app.wirl.dev, or to ask an admin of our organisation to. The weather shows once it is approved.`
|
|
5267
|
+
},
|
|
5268
|
+
{
|
|
5269
|
+
title: "Change when the tidy-up runs",
|
|
5270
|
+
prompt: "Change the noticeboard's nightly tidy-up: archive notes after 14 days instead of 30, and run it at 6pm on weekdays in my time zone (ask me which). Change the schedule in wirl.json and the number in server.mjs, deploy it with wirl deploy, then run it once with wirl run tidy and tell me what it said."
|
|
5271
|
+
},
|
|
5272
|
+
{
|
|
5273
|
+
title: "Find out what happened from the logs",
|
|
5274
|
+
prompt: "Read this app's logs with wirl logs and tell me, in a few lines, what people did on the noticeboard today and whether anything failed."
|
|
5275
|
+
},
|
|
5276
|
+
{
|
|
5277
|
+
title: "Share it with a colleague",
|
|
5278
|
+
prompt: `Help me share this noticeboard with a colleague. Sharing is done by me, not by a deploy: tell me to share it from the app's Access page on app.wirl.dev (the "Who can open it" row on the noticeboard links to it), where I add their email address. Then tell me what they will see when they open the address.`
|
|
5279
|
+
}
|
|
5280
|
+
];
|
|
5281
|
+
var SUGGESTIONS_SOURCE = [
|
|
5282
|
+
"const SUGGESTIONS = [",
|
|
5283
|
+
...SUGGESTIONS.map(({ title, prompt }) => ` { title: ${JSON.stringify(title)}, prompt: ${JSON.stringify(prompt)} },`),
|
|
5284
|
+
"];"
|
|
5285
|
+
].join("\n");
|
|
5286
|
+
var SERVER_HEAD = String.raw`/**
|
|
5287
|
+
* Team noticeboard: your first app on wirl.
|
|
5288
|
+
*
|
|
5289
|
+
* Your team posts short notes here, tags them, pins the ones everyone should
|
|
5290
|
+
* see, and edits or removes their own. Every night a schedule archives old
|
|
5291
|
+
* notes nobody pinned. The page also explains itself: a "How this works on
|
|
5292
|
+
* wirl" panel, one row per feature, each linking to that page of the app on
|
|
5293
|
+
* app.wirl.dev, and a few changes to ask your coding agent for next.
|
|
5294
|
+
*
|
|
5295
|
+
* The whole app is this one file:
|
|
5296
|
+
* - The page is HTML this module returns: plain forms, no script.
|
|
5297
|
+
* - Notes live in env.DB, a SQLite database that belongs to this app alone.
|
|
5298
|
+
* Its tables are in migrations/0001_init.sql, and wirl applies each new
|
|
5299
|
+
* file in migrations/ before a version goes live. Put the next change in
|
|
5300
|
+
* migrations/0002_<what>.sql rather than editing that one.
|
|
5301
|
+
* - Who is asking arrives in headers wirl sets after signing them in, which
|
|
5302
|
+
* the browser cannot forge. Who may change a note is a line of code here:
|
|
5303
|
+
* a check in the page is only a suggestion to whoever holds the browser.
|
|
5304
|
+
* - The nightly schedule is declared in wirl.json. wirl sends it here as a
|
|
5305
|
+
* POST to /__wirl/schedule/tidy, and what it answers becomes the run's note.
|
|
5306
|
+
* - console.log lines appear on the app's Logs page, and in wirl logs.
|
|
5307
|
+
*/
|
|
5308
|
+
|
|
5309
|
+
const TITLE = "Team noticeboard";
|
|
5310
|
+
const DASHBOARD = "https://app.wirl.dev";
|
|
5311
|
+
const ARCHIVE_AFTER_DAYS = 30;
|
|
5312
|
+
const LONGEST_NOTE = 2000;
|
|
5313
|
+
|
|
5314
|
+
export default {
|
|
5315
|
+
async fetch(request, env) {
|
|
5316
|
+
const url = new URL(request.url);
|
|
5317
|
+
// One line per request, on the Logs page.
|
|
5318
|
+
console.log(request.method, url.pathname);
|
|
5319
|
+
|
|
5320
|
+
// The nightly tidy-up, sent by wirl at the time wirl.json gives. wirl
|
|
5321
|
+
// names the schedule in x-wirl-schedule as well as in the path, and a
|
|
5322
|
+
// request without that header is not one wirl sent.
|
|
5323
|
+
if (url.pathname.startsWith("/__wirl/schedule/")) {
|
|
5324
|
+
const name = url.pathname.slice("/__wirl/schedule/".length);
|
|
5325
|
+
const fromWirl = request.headers.get("x-wirl-schedule") === name;
|
|
5326
|
+
if (name === "tidy" && request.method === "POST" && fromWirl) return tidy(env);
|
|
5327
|
+
return text("No such schedule", 404);
|
|
5328
|
+
}
|
|
5329
|
+
|
|
5330
|
+
const me = visitor(request);
|
|
5331
|
+
|
|
5332
|
+
if (request.method === "POST") {
|
|
5333
|
+
// A form posted from another site is never one of yours.
|
|
5334
|
+
if (request.headers.get("sec-fetch-site") === "cross-site") return text("Refused: this form came from another site.", 403);
|
|
5335
|
+
if (me.id === "") return text("Sign in to change the board.", 403);
|
|
5336
|
+
let form;
|
|
5337
|
+
try {
|
|
5338
|
+
form = await request.formData();
|
|
5339
|
+
} catch {
|
|
5340
|
+
return text("Send this as a form.", 400);
|
|
5341
|
+
}
|
|
5342
|
+
return change(url.pathname, form, me, env);
|
|
5343
|
+
}
|
|
5344
|
+
|
|
5345
|
+
if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) {
|
|
5346
|
+
return html(await page(request, url, me, env));
|
|
5347
|
+
}
|
|
5348
|
+
return text("Not found", 404);
|
|
5349
|
+
},
|
|
5350
|
+
};
|
|
5351
|
+
|
|
5352
|
+
// Set by wirl after it signed this person in. Never take identity from a form
|
|
5353
|
+
// or the query string: those say whatever the browser wants them to say. A
|
|
5354
|
+
// header carries only Latin-1 and a name can be anything, so wirl
|
|
5355
|
+
// percent-encodes the name; it is decoded here.
|
|
5356
|
+
function visitor(request) {
|
|
5357
|
+
const header = (name) => decode(request.headers.get(name) || "");
|
|
5358
|
+
return {
|
|
5359
|
+
id: header("x-wirl-user-id"),
|
|
5360
|
+
name: header("x-wirl-user-name") || header("x-wirl-user-email") || "someone",
|
|
5361
|
+
role: header("x-wirl-role"),
|
|
5362
|
+
};
|
|
5363
|
+
}
|
|
5364
|
+
|
|
5365
|
+
// POST /notes, and POST /notes/<id>/<edit|pin|remove|restore>.
|
|
5366
|
+
async function change(path, form, me, env) {
|
|
5367
|
+
const [, first, id, action] = path.split("/");
|
|
5368
|
+
if (first !== "notes") return text("Not found", 404);
|
|
5369
|
+
|
|
5370
|
+
if (id === undefined) {
|
|
5371
|
+
const body = clean(form.get("body"), LONGEST_NOTE);
|
|
5372
|
+
if (body === "") return text("A note needs some words.", 400);
|
|
5373
|
+
const { meta } = await env.DB.prepare("insert into notes (body, tags, author_id, author_name) values (?, ?, ?, ?)")
|
|
5374
|
+
.bind(body, tags(form.get("tags")), me.id, me.name)
|
|
5375
|
+
.run();
|
|
5376
|
+
console.log("note", meta.last_row_id, "posted by", me.id);
|
|
5377
|
+
return home();
|
|
5378
|
+
}
|
|
5379
|
+
|
|
5380
|
+
const number = Number.parseInt(id, 10);
|
|
5381
|
+
if (String(number) !== id) return text("Not found", 404);
|
|
5382
|
+
const note = await env.DB.prepare("select id, author_id from notes where id = ?").bind(number).first();
|
|
5383
|
+
if (!note) return text("No such note", 404);
|
|
5384
|
+
|
|
5385
|
+
// The rule that matters, in code: authors change their own notes, and the
|
|
5386
|
+
// app's owner can change any. Anyone on the board can pin.
|
|
5387
|
+
const mine = note.author_id === me.id || me.role === "owner";
|
|
5388
|
+
|
|
5389
|
+
if (action === "pin") {
|
|
5390
|
+
await env.DB.prepare("update notes set pinned = 1 - pinned where id = ?").bind(number).run();
|
|
5391
|
+
console.log("note", number, "pin changed by", me.id);
|
|
5392
|
+
return home();
|
|
5393
|
+
}
|
|
5394
|
+
if (!mine) return text("Only the person who posted this note, or the app's owner, can change it.", 403);
|
|
5395
|
+
|
|
5396
|
+
if (action === "edit") {
|
|
5397
|
+
const body = clean(form.get("body"), LONGEST_NOTE);
|
|
5398
|
+
if (body === "") return text("A note needs some words.", 400);
|
|
5399
|
+
await env.DB.prepare("update notes set body = ?, tags = ? where id = ?").bind(body, tags(form.get("tags")), number).run();
|
|
5400
|
+
console.log("note", number, "edited by", me.id);
|
|
5401
|
+
return home();
|
|
5402
|
+
}
|
|
5403
|
+
if (action === "remove") {
|
|
5404
|
+
await env.DB.prepare("delete from notes where id = ?").bind(number).run();
|
|
5405
|
+
console.log("note", number, "removed by", me.id);
|
|
5406
|
+
return home();
|
|
5407
|
+
}
|
|
5408
|
+
if (action === "restore") {
|
|
5409
|
+
// Back on the board as if posted today, so tonight's tidy-up leaves it be.
|
|
5410
|
+
await env.DB.prepare("update notes set archived_at = null, created_at = datetime('now') where id = ?").bind(number).run();
|
|
5411
|
+
console.log("note", number, "put back by", me.id);
|
|
5412
|
+
return home("/?view=archived");
|
|
5413
|
+
}
|
|
5414
|
+
return text("Not found", 404);
|
|
5415
|
+
}
|
|
5416
|
+
|
|
5417
|
+
// The schedule: archive notes older than ARCHIVE_AFTER_DAYS that nobody
|
|
5418
|
+
// pinned, and keep a line saying so, which the page shows as the last run.
|
|
5419
|
+
async function tidy(env) {
|
|
5420
|
+
const { meta } = await env.DB.prepare(
|
|
5421
|
+
"update notes set archived_at = datetime('now') where archived_at is null and pinned = 0 and created_at < datetime('now', ?)",
|
|
5422
|
+
)
|
|
5423
|
+
.bind("-" + ARCHIVE_AFTER_DAYS + " days")
|
|
5424
|
+
.run();
|
|
5425
|
+
const note = "Archived " + meta.changes + (meta.changes === 1 ? " note" : " notes") + " older than " + ARCHIVE_AFTER_DAYS + " days.";
|
|
5426
|
+
await env.DB.prepare("insert into runs (schedule, note) values (?, ?)").bind("tidy", note).run();
|
|
5427
|
+
console.log("tidy:", note);
|
|
5428
|
+
return text(note);
|
|
5429
|
+
}
|
|
5430
|
+
|
|
5431
|
+
async function page(request, url, me, env) {
|
|
5432
|
+
const tag = tags(url.searchParams.get("tag")).split(",")[0];
|
|
5433
|
+
const archived = url.searchParams.get("view") === "archived";
|
|
5434
|
+
let sql = "select id, body, tags, pinned, author_id, author_name, created_at from notes where archived_at is " + (archived ? "not null" : "null");
|
|
5435
|
+
const binds = [];
|
|
5436
|
+
if (tag !== "") {
|
|
5437
|
+
sql += " and (',' || tags || ',') like ?";
|
|
5438
|
+
binds.push("%," + tag + ",%");
|
|
5439
|
+
}
|
|
5440
|
+
sql += " order by pinned desc, id desc limit 200";
|
|
5441
|
+
const notes = (await env.DB.prepare(sql).bind(...binds).all()).results;
|
|
5442
|
+
const archivedCount = await env.DB.prepare("select count(*) as n from notes where archived_at is not null").first("n");
|
|
5443
|
+
const lastRun = await env.DB.prepare("select note, ran_at from runs where schedule = 'tidy' order by id desc limit 1").first();
|
|
5444
|
+
|
|
5445
|
+
const shown = archived
|
|
5446
|
+
? "<p class='dim'>Notes the nightly tidy-up moved off the board. <a href='/'>Back to the board</a></p>"
|
|
5447
|
+
: form() +
|
|
5448
|
+
"<p class='dim filters'>" +
|
|
5449
|
+
(tag === "" ? "All notes" : "Tagged <b>" + escape(tag) + "</b> · <a href='/'>show all</a>") +
|
|
5450
|
+
" · <a href='/?view=archived'>Archived (" + archivedCount + ")</a></p>";
|
|
5451
|
+
|
|
5452
|
+
return [
|
|
5453
|
+
"<!doctype html>",
|
|
5454
|
+
"<html lang='en'>",
|
|
5455
|
+
"<meta charset='utf-8'>",
|
|
5456
|
+
"<meta name='viewport' content='width=device-width, initial-scale=1'>",
|
|
5457
|
+
"<title>" + TITLE + "</title>",
|
|
5458
|
+
"<style>" + STYLE + "</style>",
|
|
5459
|
+
"<main>",
|
|
5460
|
+
"<header><h1>" + TITLE + "</h1>",
|
|
5461
|
+
"<p class='dim'>Your first app on wirl. Signed in as <b>" + escape(me.name) + "</b>" + (me.role ? " (" + escape(me.role) + ")" : "") + ".</p></header>",
|
|
5462
|
+
shown,
|
|
5463
|
+
"<ul class='notes'>" + (notes.length === 0 ? "<li class='dim'>Nothing here yet.</li>" : notes.map((note) => card(note, me, archived)).join("")) + "</ul>",
|
|
5464
|
+
panel(request, me, lastRun),
|
|
5465
|
+
suggestions(),
|
|
5466
|
+
"</main>",
|
|
5467
|
+
"</html>",
|
|
5468
|
+
].join("\n");
|
|
5469
|
+
}
|
|
5470
|
+
|
|
5471
|
+
function form() {
|
|
5472
|
+
return [
|
|
5473
|
+
"<form class='add' method='post' action='/notes'>",
|
|
5474
|
+
"<textarea name='body' rows='3' maxlength='" + LONGEST_NOTE + "' required placeholder='Post a note for the team'></textarea>",
|
|
5475
|
+
"<input name='tags' placeholder='Tags, separated by commas'>",
|
|
5476
|
+
"<button class='primary'>Post</button>",
|
|
5477
|
+
"</form>",
|
|
5478
|
+
].join("");
|
|
5479
|
+
}
|
|
5480
|
+
|
|
5481
|
+
function card(note, me, archived) {
|
|
5482
|
+
const mine = note.author_id === me.id || me.role === "owner";
|
|
5483
|
+
const action = (what, label) =>
|
|
5484
|
+
"<form method='post' action='/notes/" + note.id + "/" + what + "'><button>" + label + "</button></form>";
|
|
5485
|
+
const tagLinks = note.tags
|
|
5486
|
+
.split(",")
|
|
5487
|
+
.filter((tag) => tag !== "")
|
|
5488
|
+
.map((tag) => "<a class='tag' href='/?tag=" + encodeURIComponent(tag) + "'>#" + escape(tag) + "</a>")
|
|
5489
|
+
.join(" ");
|
|
5490
|
+
const controls = archived
|
|
5491
|
+
? (mine ? action("restore", "Put back") : "")
|
|
5492
|
+
: action("pin", note.pinned ? "Unpin" : "Pin") +
|
|
5493
|
+
(mine
|
|
5494
|
+
? "<details><summary>Edit</summary><form class='edit' method='post' action='/notes/" + note.id + "/edit'>" +
|
|
5495
|
+
"<textarea name='body' rows='3' maxlength='" + LONGEST_NOTE + "' required>" + escape(note.body) + "</textarea>" +
|
|
5496
|
+
"<input name='tags' value='" + escape(note.tags.split(",").join(", ")) + "'>" +
|
|
5497
|
+
"<button class='primary'>Save</button></form></details>" +
|
|
5498
|
+
action("remove", "Remove")
|
|
5499
|
+
: "");
|
|
5500
|
+
return (
|
|
5501
|
+
"<li class='note" + (note.pinned && !archived ? " pinned" : "") + "'>" +
|
|
5502
|
+
"<p>" + escape(note.body) + "</p>" +
|
|
5503
|
+
"<div class='meta dim'>" + (note.pinned && !archived ? "<b>Pinned</b> · " : "") +
|
|
5504
|
+
escape(note.author_name || "someone") + " · " + escape(note.created_at.slice(0, 10)) + (tagLinks ? " · " + tagLinks : "") + "</div>" +
|
|
5505
|
+
"<div class='actions'>" + controls + "</div>" +
|
|
5506
|
+
"</li>"
|
|
5507
|
+
);
|
|
5508
|
+
}
|
|
5509
|
+
|
|
5510
|
+
// The app's page on the dashboard, from the address it was opened at:
|
|
5511
|
+
// <org>--<app>.wirl.run, or <org>--<app>--<preview>.wirl.run for a preview.
|
|
5512
|
+
// Under wirl dev the address is 127.0.0.1, which names no app: null.
|
|
5513
|
+
function dashboard(request) {
|
|
5514
|
+
const label = new URL(request.url).hostname.split(".")[0];
|
|
5515
|
+
const parts = label.split("--");
|
|
5516
|
+
if (parts.length < 2) return null;
|
|
5517
|
+
const org = request.headers.get("x-wirl-org") || parts[0];
|
|
5518
|
+
return DASHBOARD + "/apps/" + encodeURIComponent(org) + "/" + encodeURIComponent(parts[1]);
|
|
5519
|
+
}
|
|
5520
|
+
|
|
5521
|
+
function panel(request, me, lastRun) {
|
|
5522
|
+
const base = dashboard(request);
|
|
5523
|
+
const last = lastRun ? "Last run " + lastRun.ran_at.slice(0, 16) + " UTC: " + lastRun.note : "Not run yet.";
|
|
5524
|
+
// One row per feature: what it is, what it does in this app, and its page.
|
|
5525
|
+
const rows = [
|
|
5526
|
+
["Who can open it", "wirl signed you in before this page loaded and tells the app who you are, in headers: here you are " + me.name + (me.role ? ", " + me.role : "") + ". The app has no sign-in code of its own. It started private to you; choose who else can open it on Access.", "/access", "Access"],
|
|
5527
|
+
["Your data", "Notes are rows in this app's own SQLite database. Its tables are in migrations/0001_init.sql, and a deploy applies each new file in migrations/ before it goes live.", "/deployments", "Deployments"],
|
|
5528
|
+
["A schedule", "tidy runs every night at 02:00 UTC and archives notes older than " + ARCHIVE_AFTER_DAYS + " days that nobody pinned. " + last + " Run it now from Overview.", "", "Overview"],
|
|
5529
|
+
["Logs", "Each request writes a line with console.log, and so does each change to a note. Read them on Logs, or ask your agent to run wirl logs.", "/logs", "Logs"],
|
|
5530
|
+
["Versions", "Each wirl deploy is a new version, and the earlier ones stay: going back to one takes a click.", "/deployments", "Deployments"],
|
|
5531
|
+
["Previews", "wirl preview puts a change up at an address of its own, with its own database, and nothing goes live until you deploy. Open previews are listed on Overview.", "", "Overview"],
|
|
5532
|
+
["Outside APIs", "Declare a connection in wirl.json and fetch the service's own URL: wirl adds the key on the way out, so it never reaches this code or a chat.", "/connections", "Connections"],
|
|
5533
|
+
["Settings", "Describe the app for your team's list of tools, hand it to someone else, or delete it.", "/settings", "Settings"],
|
|
5534
|
+
];
|
|
5535
|
+
return [
|
|
5536
|
+
"<section><h2>How this works on wirl</h2>",
|
|
5537
|
+
"<table>",
|
|
5538
|
+
...rows.map(([feature, what, path, label]) =>
|
|
5539
|
+
"<tr><td>" + feature + "</td><td>" + escape(what) + "</td><td>" +
|
|
5540
|
+
(base === null ? "<span class='dim'>" + label + "</span>" : "<a href='" + escape(base + path) + "'>" + label + "</a>") +
|
|
5541
|
+
"</td></tr>",
|
|
5542
|
+
),
|
|
5543
|
+
"</table>",
|
|
5544
|
+
base === null ? "<p class='dim'>Running under wirl dev, so these pages are not linked here: they open on app.wirl.dev once it is deployed.</p>" : "",
|
|
5545
|
+
"</section>",
|
|
5546
|
+
].join("\n");
|
|
5547
|
+
}
|
|
5548
|
+
|
|
5549
|
+
function suggestions() {
|
|
5550
|
+
return [
|
|
5551
|
+
"<section><h2>Ask your agent for a first change</h2>",
|
|
5552
|
+
"<p class='dim'>Each of these is a whole prompt. Open one, copy it, and paste it to the coding agent that made this app.</p>",
|
|
5553
|
+
...SUGGESTIONS.map((each) => "<details><summary>" + escape(each.title) + "</summary><pre>" + escape(each.prompt) + "</pre></details>"),
|
|
5554
|
+
"</section>",
|
|
5555
|
+
].join("\n");
|
|
5556
|
+
}
|
|
5557
|
+
|
|
5558
|
+
`;
|
|
5559
|
+
var SERVER_TAIL = String.raw`
|
|
5560
|
+
|
|
5561
|
+
const STYLE = [
|
|
5562
|
+
":root { color-scheme: light dark; --ink: #1d1d1b; --dim: #66655f; --line: #e2e0d9; --card: #ffffff; --bg: #f7f6f2; --pin: #fff3bf; --accent: #2b6b5a; }",
|
|
5563
|
+
"@media (prefers-color-scheme: dark) { :root { --ink: #ecebe6; --dim: #a6a59f; --line: #36352f; --card: #1f1f1c; --bg: #161614; --pin: #3b3420; --accent: #8fd1bd; } }",
|
|
5564
|
+
"body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, sans-serif; }",
|
|
5565
|
+
"main { max-width: 760px; margin: 0 auto; padding: 24px 16px 48px; }",
|
|
5566
|
+
"h1 { font-size: 24px; margin: 0 0 4px; } h2 { font-size: 17px; margin: 36px 0 8px; }",
|
|
5567
|
+
"a { color: var(--accent); } .dim { color: var(--dim); }",
|
|
5568
|
+
"form.add, form.edit { display: grid; gap: 8px; margin: 16px 0 8px; }",
|
|
5569
|
+
"textarea, input, button { font: inherit; color: inherit; padding: 7px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--card); }",
|
|
5570
|
+
"button { cursor: pointer; } button.primary { justify-self: start; background: var(--accent); border-color: var(--accent); color: var(--bg); }",
|
|
5571
|
+
"ul.notes { list-style: none; padding: 0; display: grid; gap: 10px; }",
|
|
5572
|
+
".note { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }",
|
|
5573
|
+
".note.pinned { background: var(--pin); }",
|
|
5574
|
+
".note p { margin: 0 0 6px; white-space: pre-wrap; overflow-wrap: anywhere; }",
|
|
5575
|
+
".meta, .actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 13px; }",
|
|
5576
|
+
".actions { margin-top: 8px; } .actions form { margin: 0; } .actions details { flex-basis: 100%; order: 3; }",
|
|
5577
|
+
".tag { text-decoration: none; }",
|
|
5578
|
+
"table { width: 100%; border-collapse: collapse; font-size: 14px; }",
|
|
5579
|
+
"td { border-top: 1px solid var(--line); padding: 8px 6px; vertical-align: top; }",
|
|
5580
|
+
"td:first-child { font-weight: 600; }",
|
|
5581
|
+
"pre { white-space: pre-wrap; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 10px; font-size: 13px; }",
|
|
5582
|
+
"details { margin: 6px 0; } summary { cursor: pointer; }",
|
|
5583
|
+
].join("\n");
|
|
5584
|
+
|
|
5585
|
+
const home = (to = "/") => new Response(null, { status: 303, headers: { location: to } });
|
|
5586
|
+
|
|
5587
|
+
const text = (body, status = 200) => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
5588
|
+
|
|
5589
|
+
const html = (markup) => new Response(markup, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
5590
|
+
|
|
5591
|
+
// Escapes text for HTML, in content and in quoted attribute values alike.
|
|
5592
|
+
const escape = (value) =>
|
|
5593
|
+
String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
5594
|
+
|
|
5595
|
+
const decode = (value) => {
|
|
5596
|
+
try {
|
|
5597
|
+
return decodeURIComponent(value);
|
|
5598
|
+
} catch {
|
|
5599
|
+
return value;
|
|
5600
|
+
}
|
|
5601
|
+
};
|
|
5602
|
+
|
|
5603
|
+
// A form field as trimmed text, at most max characters.
|
|
5604
|
+
const clean = (value, max) => (typeof value === "string" ? value.trim().slice(0, max) : "");
|
|
5605
|
+
|
|
5606
|
+
// "Team, Stand-up" becomes "team,stand-up": lowercase words, letters, digits
|
|
5607
|
+
// and hyphens only, so a tag is safe in a link and in the LIKE above.
|
|
5608
|
+
const tags = (value) =>
|
|
5609
|
+
clean(value, 200)
|
|
5610
|
+
.split(",")
|
|
5611
|
+
.map((tag) => tag.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((word) => word !== "").join("-"))
|
|
5612
|
+
.filter((tag, i, all) => tag !== "" && all.indexOf(tag) === i)
|
|
5613
|
+
.slice(0, 8)
|
|
5614
|
+
.join(",");
|
|
5615
|
+
`;
|
|
5616
|
+
var SERVER_MODULE = `${SERVER_HEAD}${SUGGESTIONS_SOURCE}${SERVER_TAIL}`;
|
|
5617
|
+
var STARTER_MIGRATION = [
|
|
5618
|
+
"-- The noticeboard's tables. wirl applies each new file here, in order, before a version goes live.",
|
|
5619
|
+
"-- Make the next change in 0002_<what>.sql; never edit a file that has been deployed.",
|
|
5620
|
+
"create table notes (id integer primary key, body text not null, tags text not null default '', pinned integer not null default 0, author_id text not null, author_name text not null default '', created_at text not null default (datetime('now')), archived_at text)",
|
|
5621
|
+
"create table runs (id integer primary key, schedule text not null, note text not null, ran_at text not null default (datetime('now')))",
|
|
5622
|
+
"insert into notes (body, tags, pinned, author_id, author_name) values ('Welcome to the noticeboard. Post a note below, tag it, and pin the ones everyone should see.', 'welcome', 1, '', 'wirl')",
|
|
5623
|
+
""
|
|
5624
|
+
].join("\n");
|
|
5625
|
+
function starterManifest(app, org) {
|
|
5626
|
+
const manifest = {
|
|
5627
|
+
app,
|
|
5628
|
+
...org ? { org } : {},
|
|
5629
|
+
dir: ".",
|
|
5630
|
+
server: "server.mjs",
|
|
5631
|
+
description: "A team noticeboard: your first app on wirl",
|
|
5632
|
+
visibility: "private",
|
|
5633
|
+
schedules: [{ name: "tidy", cron: "0 2 * * *", timezone: "UTC" }]
|
|
5634
|
+
};
|
|
5635
|
+
return `${JSON.stringify(manifest, null, 2)}
|
|
5636
|
+
`;
|
|
5637
|
+
}
|
|
5638
|
+
var README = [
|
|
5639
|
+
"# Team noticeboard",
|
|
5640
|
+
"",
|
|
5641
|
+
"Your first app on wirl: a board where your team posts short notes, tags them, pins the ones everyone should see, and edits or removes their own. Every night a schedule archives old notes nobody pinned.",
|
|
5642
|
+
"",
|
|
5643
|
+
'Open it at the address `wirl deploy` prints. It starts private to you. Its page has a "How this works on wirl" panel: one row per thing wirl does for this app, each linking to that page of the app on app.wirl.dev.',
|
|
5644
|
+
"",
|
|
5645
|
+
"## What is where",
|
|
5646
|
+
"",
|
|
5647
|
+
"- `server.mjs`: the whole app, one module with one `fetch`. The page, the forms, who may change a note, and the nightly tidy-up.",
|
|
5648
|
+
"- `migrations/0001_init.sql`: the tables, one statement a line. Put the next change in `migrations/0002_<what>.sql`.",
|
|
5649
|
+
"- `wirl.json`: the app's name, its module, its nightly schedule, and that it is created private.",
|
|
5650
|
+
"- `.claude/skills/wirl/SKILL.md`: how a coding agent works with wirl.",
|
|
5651
|
+
"",
|
|
5652
|
+
"## Run it on this computer",
|
|
5653
|
+
"",
|
|
5654
|
+
` ${WIRL_BIN} dev`,
|
|
5655
|
+
"",
|
|
5656
|
+
"It prints the address to open, and a `curl` line that runs the nightly tidy-up now.",
|
|
5657
|
+
"",
|
|
5658
|
+
"## Changes to ask your agent for",
|
|
5659
|
+
"",
|
|
5660
|
+
"Each is a whole prompt: paste it to your coding agent as it is.",
|
|
5661
|
+
"",
|
|
5662
|
+
...SUGGESTIONS.flatMap(({ title, prompt }) => [`### ${title}`, "", `> ${prompt}`, ""])
|
|
5663
|
+
].join("\n");
|
|
5664
|
+
var STARTER_FILES = {
|
|
5665
|
+
"server.mjs": SERVER_MODULE,
|
|
5666
|
+
"migrations/0001_init.sql": STARTER_MIGRATION,
|
|
5667
|
+
"README.md": README,
|
|
5668
|
+
"wirl.json": starterManifest("starter")
|
|
5669
|
+
};
|
|
5670
|
+
|
|
5244
5671
|
// packages/cli/src/core.ts
|
|
5245
5672
|
function credentialsPath(env = process.env) {
|
|
5246
5673
|
return env["WIRL_CREDENTIALS_PATH"] || join5(homedir(), ".config", "wirl", "credentials.json");
|
|
@@ -5809,7 +6236,7 @@ async function callApi(creds, path, options = {}) {
|
|
|
5809
6236
|
const body = parsed ?? {};
|
|
5810
6237
|
const refusal = apiRefusal(parsed, response);
|
|
5811
6238
|
if (response.status === 403 && body.error === "insufficient_scope" && body.message) {
|
|
5812
|
-
throw new CliError(body.message,
|
|
6239
|
+
throw new CliError(body.message, `Ask the person to do it in the dashboard, or run ${WIRL_BIN} login and tick full access on the approval page.`, refusal);
|
|
5813
6240
|
}
|
|
5814
6241
|
if (response.status === 403 && (body.error === "person_only" || body.error === "bad_confirmation") && body.message) {
|
|
5815
6242
|
throw new CliError(body.message, void 0, refusal);
|
|
@@ -5990,6 +6417,11 @@ function manifestWarningLines(manifest) {
|
|
|
5990
6417
|
const parsed = parseManifest(manifest);
|
|
5991
6418
|
return parsed.ok ? parsed.warnings.map((warning) => `Warning: ${warning}`) : [];
|
|
5992
6419
|
}
|
|
6420
|
+
function manifestVisibility(manifest) {
|
|
6421
|
+
if (manifest === null || manifest === void 0) return void 0;
|
|
6422
|
+
const parsed = parseManifest(manifest);
|
|
6423
|
+
return parsed.ok ? parsed.manifest.visibility : void 0;
|
|
6424
|
+
}
|
|
5993
6425
|
async function prepareDeployBundle(dir, manifest, door, projectDir) {
|
|
5994
6426
|
let server;
|
|
5995
6427
|
let rules = NO_MANIFEST;
|
|
@@ -6198,13 +6630,25 @@ function chooseOrg(orgs, given, hint = 'Pass --org <slug>, or add "org" to wirl.
|
|
|
6198
6630
|
throw new CliError(`Which organisation? You belong to ${orgs.map((o) => o.slug).join(", ")}.`, hint, { code: "missing_argument" });
|
|
6199
6631
|
}
|
|
6200
6632
|
var listApps = (creds, org) => callApi(creds, `/api/v1/orgs/${seg(org)}/apps`);
|
|
6201
|
-
var createRemoteApp = (creds, org, slug, name) => callApi(creds, `/api/v1/orgs/${seg(org)}/apps`, {
|
|
6633
|
+
var createRemoteApp = (creds, org, slug, name, visibility) => callApi(creds, `/api/v1/orgs/${seg(org)}/apps`, {
|
|
6202
6634
|
method: "POST",
|
|
6203
|
-
|
|
6635
|
+
// `visibility` rides along only when wirl.json said it: absent, the
|
|
6636
|
+
// org's default holds. It applies to the app this create makes and to
|
|
6637
|
+
// no other: an app that exists answers 409 and is left as it is.
|
|
6638
|
+
body: JSON.stringify({ slug, name: name ?? slug, ...visibility === void 0 ? {} : { visibility } }),
|
|
6204
6639
|
contentType: "application/json"
|
|
6205
6640
|
});
|
|
6206
|
-
async function ensureRemoteApp(creds, org, app) {
|
|
6207
|
-
return createRemoteApp(creds, org, app).then(() =>
|
|
6641
|
+
async function ensureRemoteApp(creds, org, app, visibility) {
|
|
6642
|
+
return createRemoteApp(creds, org, app, void 0, visibility).then((created) => {
|
|
6643
|
+
const made = created.app?.visibility;
|
|
6644
|
+
if (visibility === "private" && typeof made === "string" && made !== "private") {
|
|
6645
|
+
process.stderr.write(
|
|
6646
|
+
`${terminalSafe(`wirl: asked for a private app, but ${org}/${app} was created visible to everyone in the organisation (an older control plane, or someone created it first). Change it with: wirl visibility private --org ${org} --app ${app}`)}
|
|
6647
|
+
`
|
|
6648
|
+
);
|
|
6649
|
+
}
|
|
6650
|
+
return true;
|
|
6651
|
+
}, (error) => {
|
|
6208
6652
|
if (!(error instanceof CliError)) throw error;
|
|
6209
6653
|
if (error.fields["code"] === "app_deleted") throw deletedNameError(error, app);
|
|
6210
6654
|
if (error.fields["code"] === "app_exists" || error.fields["code"] === "app_moved") throw takenNameError(error, app);
|
|
@@ -7463,9 +7907,16 @@ function loginInstructions(login) {
|
|
|
7463
7907
|
` ${login.verificationUriComplete}`,
|
|
7464
7908
|
` Code: ${login.userCode}`,
|
|
7465
7909
|
"",
|
|
7910
|
+
"Approving gives build-and-update access: deploy, preview, logs, roll back and pull. If you will need to share, delete or manage connections, ask the person to tick full access on the approval page.",
|
|
7911
|
+
"",
|
|
7466
7912
|
`Once they have approved, call the wirl tool again. The code expires in ${Math.round(login.expiresIn / 60)} minutes.`
|
|
7467
7913
|
].join("\n");
|
|
7468
7914
|
}
|
|
7915
|
+
function alreadySignedIn(me) {
|
|
7916
|
+
const orgs = `Organisations: ${me.orgs.map((o) => o.slug).join(", ") || "none yet"}.`;
|
|
7917
|
+
if ((me.scope ?? "full") === "full") return `Already signed in, with full access. ${orgs}`;
|
|
7918
|
+
return `Already signed in, with build-and-update access. ${orgs} To share, delete or manage connections, the person runs ${WIRL_BIN} login in a terminal and ticks full access on the approval page.`;
|
|
7919
|
+
}
|
|
7469
7920
|
async function beginLogin() {
|
|
7470
7921
|
const apiOrigin = apiOriginFromEnv();
|
|
7471
7922
|
const login = await startDeviceLogin(apiOrigin, { client: `Claude Code on ${hostname()}` });
|
|
@@ -8171,8 +8622,7 @@ var LOGIN = {
|
|
|
8171
8622
|
async handler() {
|
|
8172
8623
|
try {
|
|
8173
8624
|
const creds = await loadCredentials();
|
|
8174
|
-
|
|
8175
|
-
return `Already signed in. Organisations: ${me.orgs.map((o) => o.slug).join(", ") || "none yet"}.`;
|
|
8625
|
+
return alreadySignedIn(await whoami(creds));
|
|
8176
8626
|
} catch {
|
|
8177
8627
|
}
|
|
8178
8628
|
const pending = pendingLogin;
|
|
@@ -8209,7 +8659,7 @@ var LOCAL_DEPLOY = {
|
|
|
8209
8659
|
throw withManifestWarnings(error, manifestWarnings);
|
|
8210
8660
|
});
|
|
8211
8661
|
const warnings = [...manifestWarnings, ...prepared.notices];
|
|
8212
|
-
const made = await ensureRemoteApp(ctx.creds, org, app);
|
|
8662
|
+
const made = await ensureRemoteApp(ctx.creds, org, app, manifestVisibility(manifest));
|
|
8213
8663
|
const result2 = await deploy(ctx.creds, { ...upload, prepared }).catch((error) => {
|
|
8214
8664
|
throw withManifestWarnings(withMadeApp(error, made, "wirl_deploy", app), warnings);
|
|
8215
8665
|
});
|
|
@@ -8306,7 +8756,7 @@ var LOCAL_PREVIEW = {
|
|
|
8306
8756
|
throw withManifestWarnings(error, manifestWarnings);
|
|
8307
8757
|
});
|
|
8308
8758
|
const warnings = [...manifestWarnings, ...prepared.notices];
|
|
8309
|
-
const made = await ensureRemoteApp(ctx.creds, org, app);
|
|
8759
|
+
const made = await ensureRemoteApp(ctx.creds, org, app, manifestVisibility(manifest));
|
|
8310
8760
|
const result2 = await deployPreview(ctx.creds, { ...upload, prepared }).catch((error) => {
|
|
8311
8761
|
throw withManifestWarnings(withMadeApp(error, made, "wirl_preview", app), warnings);
|
|
8312
8762
|
});
|
|
@@ -8367,7 +8817,7 @@ function npmFallbackLines() {
|
|
|
8367
8817
|
}
|
|
8368
8818
|
var REMOTE_DEPLOY = {
|
|
8369
8819
|
name: "wirl_deploy",
|
|
8370
|
-
description:
|
|
8820
|
+
description: `Deploy a directory of files to a URL that colleagues can open. This server runs at app.wirl.dev and cannot read files on the machine you are on, so it returns the exact commands to run in a terminal (and makes the app first, when you name one); the upload itself happens from the machine holding the files, never through this chat. A new app is visible to everyone in the org when the org came from a company email domain; in any other org, including a personal one or one made with wirl orgs create, it starts private. A "visibility": "private" in wirl.json does not apply when this tool creates the app; the wirl CLI's deploy honours it. Returns the URL it will get. ` + versionNumbersSentence("wirl_list_versions"),
|
|
8371
8821
|
annotations: { title: "Deploy an app", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
8372
8822
|
inputSchema: {
|
|
8373
8823
|
type: "object",
|