@wirl/mcp 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin.js +514 -22
- 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";
|
|
@@ -2460,7 +2463,7 @@ var init_manifest = __esm({
|
|
|
2460
2463
|
};
|
|
2461
2464
|
CREATABLE_CONNECTION_KINDS = CONNECTION_KINDS.filter((kind) => !(kind in UNDELIVERED_KINDS));
|
|
2462
2465
|
needsNoKey = (kind) => kind === "none";
|
|
2463
|
-
ACCENTS = ["blue", "green", "sun", "purple", "red", "sky", "orange", "lime"];
|
|
2466
|
+
ACCENTS = ["blue", "green", "sun", "purple", "red", "sky", "orange", "lime", "mint", "butter", "blush", "lilac"];
|
|
2464
2467
|
SCHEDULE_LIMITS = {
|
|
2465
2468
|
perApp: 10,
|
|
2466
2469
|
minTimeoutMs: 3e4,
|
|
@@ -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
|
|
|
@@ -2918,7 +2923,11 @@ var init_retention = __esm({
|
|
|
2918
2923
|
* The counts settle a "why was my app paused" question and the claims
|
|
2919
2924
|
* ration the mail; neither is worth anything two months on.
|
|
2920
2925
|
*/
|
|
2921
|
-
flowDayRowsDays: 60
|
|
2926
|
+
flowDayRowsDays: 60,
|
|
2927
|
+
/** A row of the AI call ledger is kept this long, then removed. */
|
|
2928
|
+
aiCallDays: 90,
|
|
2929
|
+
/** An AI monthly total (the rows the caps read) is kept this long, 24 months. */
|
|
2930
|
+
aiMonthDays: 730
|
|
2922
2931
|
};
|
|
2923
2932
|
}
|
|
2924
2933
|
});
|
|
@@ -2951,6 +2960,7 @@ own live deploy and a person's yes. Declare a host under \`connections\`, then \
|
|
|
2951
2960
|
wirl attaches the key. An undeclared host is refused; a public API needing no key uses \`"kind": "none"\`.
|
|
2952
2961
|
Declaring one deploys before anyone approves it; the deploy says per connection whether it is usable now or
|
|
2953
2962
|
waiting on an admin. A vendor has 60 seconds to answer, whole, unless it streams: ask a model for a stream.
|
|
2963
|
+
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.
|
|
2954
2964
|
|
|
2955
2965
|
Six headers say who is asking: \`x-wirl-user-id\`, \`x-wirl-user-email\`, \`x-wirl-user-name\`,
|
|
2956
2966
|
\`x-wirl-role\`, \`x-wirl-app-id\`, \`x-wirl-org\`. Read the name with
|
|
@@ -3003,7 +3013,7 @@ var init_version = __esm({
|
|
|
3003
3013
|
"packages/core/src/version.ts"() {
|
|
3004
3014
|
"use strict";
|
|
3005
3015
|
WORKSPACE_VERSION = "0.0.0";
|
|
3006
|
-
BUILT_VERSION = true ? "0.2.
|
|
3016
|
+
BUILT_VERSION = true ? "0.2.4" : null;
|
|
3007
3017
|
}
|
|
3008
3018
|
});
|
|
3009
3019
|
|
|
@@ -5190,11 +5200,12 @@ company email domain; in any other org, including a personal one or one
|
|
|
5190
5200
|
made with \`wirl orgs create\`, it starts private. Deploys are one
|
|
5191
5201
|
command; sharing is by email; a third-party API is declared, never pasted.`;
|
|
5192
5202
|
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.";
|
|
5193
|
-
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).
|
|
5203
|
+
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).
|
|
5194
5204
|
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.
|
|
5195
|
-
3.
|
|
5196
|
-
4.
|
|
5197
|
-
5.
|
|
5205
|
+
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.
|
|
5206
|
+
4. If a command names several organisations and asks which, ask the person; pass \`--org\` or put \`"org"\` in \`wirl.json\`.
|
|
5207
|
+
5. To keep a file out of the deploy, list it in a \`.wirlignore\` at the project root (gitignore-style lines; no \`!\` negation).
|
|
5208
|
+
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.`;
|
|
5198
5209
|
var SKILL_MANIFEST_EXAMPLE = `\`\`\`json
|
|
5199
5210
|
{ "app": "crm-sync", "server": "server.mjs", "connections": [{ "slug": "hubspot", "hosts": ["api.hubspot.com"] }] }
|
|
5200
5211
|
\`\`\``;
|
|
@@ -5241,6 +5252,464 @@ ${FLOWS_CONTRACT}
|
|
|
5241
5252
|
`;
|
|
5242
5253
|
}
|
|
5243
5254
|
|
|
5255
|
+
// packages/cli/src/starter.ts
|
|
5256
|
+
init_src();
|
|
5257
|
+
var SUGGESTIONS = [
|
|
5258
|
+
{
|
|
5259
|
+
number: 1,
|
|
5260
|
+
title: "Summarise the week",
|
|
5261
|
+
capability: "A model comes with every app. Your agent just uses it; nothing to sign up for or paste.",
|
|
5262
|
+
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.`,
|
|
5263
|
+
youGet: "a summary card above the notes."
|
|
5264
|
+
},
|
|
5265
|
+
{
|
|
5266
|
+
number: 2,
|
|
5267
|
+
title: "Give notes a due date",
|
|
5268
|
+
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.",
|
|
5269
|
+
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.",
|
|
5270
|
+
youGet: "a date on each note, the soonest first, and a new column on the Data page."
|
|
5271
|
+
},
|
|
5272
|
+
{
|
|
5273
|
+
number: 3,
|
|
5274
|
+
title: "Pull in something from outside",
|
|
5275
|
+
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.",
|
|
5276
|
+
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.`,
|
|
5277
|
+
youGet: "a weather line under the notes, and the host listed on the app's Connections page."
|
|
5278
|
+
},
|
|
5279
|
+
{
|
|
5280
|
+
number: 4,
|
|
5281
|
+
title: "Try a new look before anyone sees it",
|
|
5282
|
+
// The mock says "Previews are free."; the starter carries no price words (its test forbids "free").
|
|
5283
|
+
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.",
|
|
5284
|
+
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.",
|
|
5285
|
+
youGet: 'a second address to open next to this one. Say "deploy it" when you like it.'
|
|
5286
|
+
},
|
|
5287
|
+
{
|
|
5288
|
+
number: 5,
|
|
5289
|
+
title: "Share it with a colleague",
|
|
5290
|
+
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.",
|
|
5291
|
+
prompt: "Help me share this app with a colleague: tell me how to give them access and the link they open.",
|
|
5292
|
+
youGet: "their name on the Access page, and a link they open with their work account."
|
|
5293
|
+
},
|
|
5294
|
+
{
|
|
5295
|
+
number: 6,
|
|
5296
|
+
title: "Connect it to Slack",
|
|
5297
|
+
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.",
|
|
5298
|
+
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.",
|
|
5299
|
+
youGet: "a DM from the noticeboard in Slack. Everything above works without it.",
|
|
5300
|
+
optional: true
|
|
5301
|
+
}
|
|
5302
|
+
];
|
|
5303
|
+
var splitCapability = (capability) => {
|
|
5304
|
+
const stop = capability.indexOf(". ");
|
|
5305
|
+
return [capability.slice(0, stop + 1), capability.slice(stop + 2)];
|
|
5306
|
+
};
|
|
5307
|
+
var SUGGESTIONS_SOURCE = [
|
|
5308
|
+
"const SUGGESTIONS = [",
|
|
5309
|
+
...SUGGESTIONS.map(
|
|
5310
|
+
({ 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" : ""} },`
|
|
5311
|
+
),
|
|
5312
|
+
"];"
|
|
5313
|
+
].join("\n");
|
|
5314
|
+
var SERVER_HEAD = String.raw`/**
|
|
5315
|
+
* Team noticeboard: your first app on wirl.
|
|
5316
|
+
*
|
|
5317
|
+
* Your team posts short notes here, tags them, pins the ones everyone should
|
|
5318
|
+
* see, and edits or removes their own. Every night a schedule archives old
|
|
5319
|
+
* notes nobody pinned. Below the notes, "Make it yours": six prompts to
|
|
5320
|
+
* paste to your coding agent, in order, each saying what wirl gives the app
|
|
5321
|
+
* and what you get. Under them, the app's pages on app.wirl.dev.
|
|
5322
|
+
*
|
|
5323
|
+
* The whole app is this one file:
|
|
5324
|
+
* - The page is HTML this module returns: plain forms, no script.
|
|
5325
|
+
* - Notes live in env.DB, a SQLite database that belongs to this app alone.
|
|
5326
|
+
* Its tables are in migrations/0001_init.sql, and wirl applies each new
|
|
5327
|
+
* file in migrations/ before a version goes live. Put the next change in
|
|
5328
|
+
* migrations/0002_<what>.sql rather than editing that one.
|
|
5329
|
+
* - Who is asking arrives in headers wirl sets after signing them in, which
|
|
5330
|
+
* the browser cannot forge. Who may change a note is a line of code here:
|
|
5331
|
+
* a check in the page is only a suggestion to whoever holds the browser.
|
|
5332
|
+
* - The nightly schedule is declared in wirl.json. wirl sends it here as a
|
|
5333
|
+
* POST to /__wirl/schedule/tidy, and what it answers becomes the run's note.
|
|
5334
|
+
* - console.log lines appear on the app's Logs page, and in wirl logs.
|
|
5335
|
+
*/
|
|
5336
|
+
|
|
5337
|
+
const TITLE = "Team noticeboard";
|
|
5338
|
+
const DASHBOARD = "https://app.wirl.dev";
|
|
5339
|
+
const ARCHIVE_AFTER_DAYS = 30;
|
|
5340
|
+
const LONGEST_NOTE = 2000;
|
|
5341
|
+
|
|
5342
|
+
export default {
|
|
5343
|
+
async fetch(request, env) {
|
|
5344
|
+
const url = new URL(request.url);
|
|
5345
|
+
// One line per request, on the Logs page.
|
|
5346
|
+
console.log(request.method, url.pathname);
|
|
5347
|
+
|
|
5348
|
+
// The nightly tidy-up, sent by wirl at the time wirl.json gives. wirl
|
|
5349
|
+
// names the schedule in x-wirl-schedule as well as in the path, and a
|
|
5350
|
+
// request without that header is not one wirl sent.
|
|
5351
|
+
if (url.pathname.startsWith("/__wirl/schedule/")) {
|
|
5352
|
+
const name = url.pathname.slice("/__wirl/schedule/".length);
|
|
5353
|
+
const fromWirl = request.headers.get("x-wirl-schedule") === name;
|
|
5354
|
+
if (name === "tidy" && request.method === "POST" && fromWirl) return tidy(env);
|
|
5355
|
+
return text("No such schedule", 404);
|
|
5356
|
+
}
|
|
5357
|
+
|
|
5358
|
+
const me = visitor(request);
|
|
5359
|
+
|
|
5360
|
+
if (request.method === "POST") {
|
|
5361
|
+
// A form posted from another site is never one of yours.
|
|
5362
|
+
if (request.headers.get("sec-fetch-site") === "cross-site") return text("Refused: this form came from another site.", 403);
|
|
5363
|
+
if (me.id === "") return text("Sign in to change the board.", 403);
|
|
5364
|
+
let form;
|
|
5365
|
+
try {
|
|
5366
|
+
form = await request.formData();
|
|
5367
|
+
} catch {
|
|
5368
|
+
return text("Send this as a form.", 400);
|
|
5369
|
+
}
|
|
5370
|
+
return change(url.pathname, form, me, env);
|
|
5371
|
+
}
|
|
5372
|
+
|
|
5373
|
+
if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) {
|
|
5374
|
+
return html(await page(request, url, me, env));
|
|
5375
|
+
}
|
|
5376
|
+
return text("Not found", 404);
|
|
5377
|
+
},
|
|
5378
|
+
};
|
|
5379
|
+
|
|
5380
|
+
// Set by wirl after it signed this person in. Never take identity from a form
|
|
5381
|
+
// or the query string: those say whatever the browser wants them to say. A
|
|
5382
|
+
// header carries only Latin-1 and a name can be anything, so wirl
|
|
5383
|
+
// percent-encodes the name; it is decoded here.
|
|
5384
|
+
function visitor(request) {
|
|
5385
|
+
const header = (name) => decode(request.headers.get(name) || "");
|
|
5386
|
+
return {
|
|
5387
|
+
id: header("x-wirl-user-id"),
|
|
5388
|
+
name: header("x-wirl-user-name") || header("x-wirl-user-email") || "someone",
|
|
5389
|
+
role: header("x-wirl-role"),
|
|
5390
|
+
};
|
|
5391
|
+
}
|
|
5392
|
+
|
|
5393
|
+
// POST /notes, and POST /notes/<id>/<edit|pin|remove|restore>.
|
|
5394
|
+
async function change(path, form, me, env) {
|
|
5395
|
+
const [, first, id, action] = path.split("/");
|
|
5396
|
+
if (first !== "notes") return text("Not found", 404);
|
|
5397
|
+
|
|
5398
|
+
if (id === undefined) {
|
|
5399
|
+
const body = clean(form.get("body"), LONGEST_NOTE);
|
|
5400
|
+
if (body === "") return text("A note needs some words.", 400);
|
|
5401
|
+
const { meta } = await env.DB.prepare("insert into notes (body, tags, author_id, author_name) values (?, ?, ?, ?)")
|
|
5402
|
+
.bind(body, tags(form.get("tags")), me.id, me.name)
|
|
5403
|
+
.run();
|
|
5404
|
+
console.log("note", meta.last_row_id, "posted by", me.id);
|
|
5405
|
+
return home();
|
|
5406
|
+
}
|
|
5407
|
+
|
|
5408
|
+
const number = Number.parseInt(id, 10);
|
|
5409
|
+
if (String(number) !== id) return text("Not found", 404);
|
|
5410
|
+
const note = await env.DB.prepare("select id, author_id from notes where id = ?").bind(number).first();
|
|
5411
|
+
if (!note) return text("No such note", 404);
|
|
5412
|
+
|
|
5413
|
+
// The rule that matters, in code: authors change their own notes, and the
|
|
5414
|
+
// app's owner can change any. Anyone on the board can pin.
|
|
5415
|
+
const mine = note.author_id === me.id || me.role === "owner";
|
|
5416
|
+
|
|
5417
|
+
if (action === "pin") {
|
|
5418
|
+
await env.DB.prepare("update notes set pinned = 1 - pinned where id = ?").bind(number).run();
|
|
5419
|
+
console.log("note", number, "pin changed by", me.id);
|
|
5420
|
+
return home();
|
|
5421
|
+
}
|
|
5422
|
+
if (!mine) return text("Only the person who posted this note, or the app's owner, can change it.", 403);
|
|
5423
|
+
|
|
5424
|
+
if (action === "edit") {
|
|
5425
|
+
const body = clean(form.get("body"), LONGEST_NOTE);
|
|
5426
|
+
if (body === "") return text("A note needs some words.", 400);
|
|
5427
|
+
await env.DB.prepare("update notes set body = ?, tags = ? where id = ?").bind(body, tags(form.get("tags")), number).run();
|
|
5428
|
+
console.log("note", number, "edited by", me.id);
|
|
5429
|
+
return home();
|
|
5430
|
+
}
|
|
5431
|
+
if (action === "remove") {
|
|
5432
|
+
await env.DB.prepare("delete from notes where id = ?").bind(number).run();
|
|
5433
|
+
console.log("note", number, "removed by", me.id);
|
|
5434
|
+
return home();
|
|
5435
|
+
}
|
|
5436
|
+
if (action === "restore") {
|
|
5437
|
+
// Back on the board as if posted today, so tonight's tidy-up leaves it be.
|
|
5438
|
+
await env.DB.prepare("update notes set archived_at = null, created_at = datetime('now') where id = ?").bind(number).run();
|
|
5439
|
+
console.log("note", number, "put back by", me.id);
|
|
5440
|
+
return home("/?view=archived");
|
|
5441
|
+
}
|
|
5442
|
+
return text("Not found", 404);
|
|
5443
|
+
}
|
|
5444
|
+
|
|
5445
|
+
// The schedule: archive notes older than ARCHIVE_AFTER_DAYS that nobody
|
|
5446
|
+
// pinned, and keep a line saying so, which the page shows as the last run.
|
|
5447
|
+
async function tidy(env) {
|
|
5448
|
+
const { meta } = await env.DB.prepare(
|
|
5449
|
+
"update notes set archived_at = datetime('now') where archived_at is null and pinned = 0 and created_at < datetime('now', ?)",
|
|
5450
|
+
)
|
|
5451
|
+
.bind("-" + ARCHIVE_AFTER_DAYS + " days")
|
|
5452
|
+
.run();
|
|
5453
|
+
const note = "Archived " + meta.changes + (meta.changes === 1 ? " note" : " notes") + " older than " + ARCHIVE_AFTER_DAYS + " days.";
|
|
5454
|
+
await env.DB.prepare("insert into runs (schedule, note) values (?, ?)").bind("tidy", note).run();
|
|
5455
|
+
console.log("tidy:", note);
|
|
5456
|
+
return text(note);
|
|
5457
|
+
}
|
|
5458
|
+
|
|
5459
|
+
async function page(request, url, me, env) {
|
|
5460
|
+
const tag = tags(url.searchParams.get("tag")).split(",")[0];
|
|
5461
|
+
const archived = url.searchParams.get("view") === "archived";
|
|
5462
|
+
let sql = "select id, body, tags, pinned, author_id, author_name, created_at from notes where archived_at is " + (archived ? "not null" : "null");
|
|
5463
|
+
const binds = [];
|
|
5464
|
+
if (tag !== "") {
|
|
5465
|
+
sql += " and (',' || tags || ',') like ?";
|
|
5466
|
+
binds.push("%," + tag + ",%");
|
|
5467
|
+
}
|
|
5468
|
+
sql += " order by pinned desc, id desc limit 200";
|
|
5469
|
+
const notes = (await env.DB.prepare(sql).bind(...binds).all()).results;
|
|
5470
|
+
const archivedCount = await env.DB.prepare("select count(*) as n from notes where archived_at is not null").first("n");
|
|
5471
|
+
const lastRun = await env.DB.prepare("select note, ran_at from runs where schedule = 'tidy' order by id desc limit 1").first();
|
|
5472
|
+
|
|
5473
|
+
const shown = archived
|
|
5474
|
+
? "<p class='dim'>Notes the nightly tidy-up moved off the board. <a href='/'>Back to the board</a></p>"
|
|
5475
|
+
: form() +
|
|
5476
|
+
"<p class='dim filters'>" +
|
|
5477
|
+
(tag === "" ? "All notes" : "Tagged <b>" + escape(tag) + "</b> · <a href='/'>show all</a>") +
|
|
5478
|
+
" · <a href='/?view=archived'>Archived (" + archivedCount + ")</a></p>";
|
|
5479
|
+
|
|
5480
|
+
return [
|
|
5481
|
+
"<!doctype html>",
|
|
5482
|
+
"<html lang='en'>",
|
|
5483
|
+
"<meta charset='utf-8'>",
|
|
5484
|
+
"<meta name='viewport' content='width=device-width, initial-scale=1'>",
|
|
5485
|
+
"<title>" + TITLE + "</title>",
|
|
5486
|
+
"<style>" + STYLE + "</style>",
|
|
5487
|
+
"<main>",
|
|
5488
|
+
"<header><h1>" + TITLE + "</h1>",
|
|
5489
|
+
"<p class='dim'>Your first app on wirl. Signed in as <b>" + escape(me.name) + "</b>" + (me.role ? " (" + escape(me.role) + ")" : "") + ".</p></header>",
|
|
5490
|
+
shown,
|
|
5491
|
+
"<ul class='notes'>" + (notes.length === 0 ? "<li class='dim'>Nothing here yet.</li>" : notes.map((note) => card(note, me, archived)).join("")) + "</ul>",
|
|
5492
|
+
"<p class='dim'>Nightly tidy-up, which archives notes older than " + ARCHIVE_AFTER_DAYS + " days that nobody pinned: " +
|
|
5493
|
+
(lastRun ? "last run " + escape(lastRun.ran_at.slice(0, 16)) + " UTC: " + escape(lastRun.note) : "Not run yet.") + "</p>",
|
|
5494
|
+
prompts(request),
|
|
5495
|
+
"</main>",
|
|
5496
|
+
"</html>",
|
|
5497
|
+
].join("\n");
|
|
5498
|
+
}
|
|
5499
|
+
|
|
5500
|
+
function form() {
|
|
5501
|
+
return [
|
|
5502
|
+
"<form class='add' method='post' action='/notes'>",
|
|
5503
|
+
"<textarea name='body' rows='3' maxlength='" + LONGEST_NOTE + "' required placeholder='Post a note for the team'></textarea>",
|
|
5504
|
+
"<input name='tags' placeholder='Tags, separated by commas'>",
|
|
5505
|
+
"<button class='primary'>Post</button>",
|
|
5506
|
+
"</form>",
|
|
5507
|
+
].join("");
|
|
5508
|
+
}
|
|
5509
|
+
|
|
5510
|
+
function card(note, me, archived) {
|
|
5511
|
+
const mine = note.author_id === me.id || me.role === "owner";
|
|
5512
|
+
const action = (what, label) =>
|
|
5513
|
+
"<form method='post' action='/notes/" + note.id + "/" + what + "'><button>" + label + "</button></form>";
|
|
5514
|
+
const tagLinks = note.tags
|
|
5515
|
+
.split(",")
|
|
5516
|
+
.filter((tag) => tag !== "")
|
|
5517
|
+
.map((tag) => "<a class='tag' href='/?tag=" + encodeURIComponent(tag) + "'>#" + escape(tag) + "</a>")
|
|
5518
|
+
.join(" ");
|
|
5519
|
+
const controls = archived
|
|
5520
|
+
? (mine ? action("restore", "Put back") : "")
|
|
5521
|
+
: action("pin", note.pinned ? "Unpin" : "Pin") +
|
|
5522
|
+
(mine
|
|
5523
|
+
? "<details><summary>Edit</summary><form class='edit' method='post' action='/notes/" + note.id + "/edit'>" +
|
|
5524
|
+
"<textarea name='body' rows='3' maxlength='" + LONGEST_NOTE + "' required>" + escape(note.body) + "</textarea>" +
|
|
5525
|
+
"<input name='tags' value='" + escape(note.tags.split(",").join(", ")) + "'>" +
|
|
5526
|
+
"<button class='primary'>Save</button></form></details>" +
|
|
5527
|
+
action("remove", "Remove")
|
|
5528
|
+
: "");
|
|
5529
|
+
return (
|
|
5530
|
+
"<li class='note" + (note.pinned && !archived ? " pinned" : "") + "'>" +
|
|
5531
|
+
"<p>" + escape(note.body) + "</p>" +
|
|
5532
|
+
"<div class='meta dim'>" + (note.pinned && !archived ? "<b>Pinned</b> · " : "") +
|
|
5533
|
+
escape(note.author_name || "someone") + " · " + escape(note.created_at.slice(0, 10)) + (tagLinks ? " · " + tagLinks : "") + "</div>" +
|
|
5534
|
+
"<div class='actions'>" + controls + "</div>" +
|
|
5535
|
+
"</li>"
|
|
5536
|
+
);
|
|
5537
|
+
}
|
|
5538
|
+
|
|
5539
|
+
// The app's pages on the dashboard, from the address it was opened at:
|
|
5540
|
+
// <org>--<app>.wirl.run, or <org>--<app>--<preview>.wirl.run for a preview.
|
|
5541
|
+
// Under wirl dev the address is 127.0.0.1, which names no app: null.
|
|
5542
|
+
function dashboard(request) {
|
|
5543
|
+
const label = new URL(request.url).hostname.split(".")[0];
|
|
5544
|
+
const parts = label.split("--");
|
|
5545
|
+
if (parts.length < 2) return null;
|
|
5546
|
+
const org = encodeURIComponent(request.headers.get("x-wirl-org") || parts[0]);
|
|
5547
|
+
return { app: DASHBOARD + "/apps/" + org + "/" + encodeURIComponent(parts[1]), org: DASHBOARD + "/orgs/" + org };
|
|
5548
|
+
}
|
|
5549
|
+
|
|
5550
|
+
// The onboarding path: each prompt whole, visible and ready to select and
|
|
5551
|
+
// copy, with what wirl gives the app and what you get. Then what the app
|
|
5552
|
+
// already uses, and its pages on the dashboard.
|
|
5553
|
+
function prompts(request) {
|
|
5554
|
+
const where = dashboard(request);
|
|
5555
|
+
const pages = [
|
|
5556
|
+
["Overview", "app", ""],
|
|
5557
|
+
["Data", "app", "/data"],
|
|
5558
|
+
["Access", "app", "/access"],
|
|
5559
|
+
["Logs", "app", "/logs"],
|
|
5560
|
+
["Deployments", "app", "/deployments"],
|
|
5561
|
+
["Models", "org", "/models"],
|
|
5562
|
+
["Connections", "app", "/connections"],
|
|
5563
|
+
["Settings", "app", "/settings"],
|
|
5564
|
+
];
|
|
5565
|
+
const link = ([label, of, path]) =>
|
|
5566
|
+
where === null ? "<span class='dim'>" + label + "</span>" : "<a href='" + escape(where[of] + path) + "'>" + label + "</a>";
|
|
5567
|
+
const step = (each) => {
|
|
5568
|
+
const stop = each.capability.indexOf(". ");
|
|
5569
|
+
return (
|
|
5570
|
+
"<li class='step" + (each.optional ? " optional" : "") + "'>" +
|
|
5571
|
+
"<h3><span class='number'>" + each.number + "</span> " + escape(each.title) +
|
|
5572
|
+
(each.optional ? " <span class='badge'>Optional. The big one.</span>" : "") + "</h3>" +
|
|
5573
|
+
"<p class='dim'><b>" + escape(each.capability.slice(0, stop + 1)) + "</b> " + escape(each.capability.slice(stop + 2)) + "</p>" +
|
|
5574
|
+
"<pre>" + escape(each.prompt) + "</pre>" +
|
|
5575
|
+
"<p class='dim'>You get: " + escape(each.youGet) + "</p>" +
|
|
5576
|
+
"</li>"
|
|
5577
|
+
);
|
|
5578
|
+
};
|
|
5579
|
+
return [
|
|
5580
|
+
"<section id='prompts'><h2>Make it yours</h2>",
|
|
5581
|
+
"<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>",
|
|
5582
|
+
"<ol class='steps'>",
|
|
5583
|
+
...SUGGESTIONS.map(step),
|
|
5584
|
+
"</ol>",
|
|
5585
|
+
"<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>",
|
|
5586
|
+
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>" : "",
|
|
5587
|
+
"</section>",
|
|
5588
|
+
].join("\n");
|
|
5589
|
+
}
|
|
5590
|
+
|
|
5591
|
+
`;
|
|
5592
|
+
var SERVER_TAIL = String.raw`
|
|
5593
|
+
|
|
5594
|
+
const STYLE = [
|
|
5595
|
+
":root { color-scheme: light dark; --ink: #1d1d1b; --dim: #66655f; --line: #e2e0d9; --card: #ffffff; --bg: #f7f6f2; --pin: #fff3bf; --accent: #2b6b5a; }",
|
|
5596
|
+
"@media (prefers-color-scheme: dark) { :root { --ink: #ecebe6; --dim: #a6a59f; --line: #36352f; --card: #1f1f1c; --bg: #161614; --pin: #3b3420; --accent: #8fd1bd; } }",
|
|
5597
|
+
"body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, sans-serif; }",
|
|
5598
|
+
"main { max-width: 760px; margin: 0 auto; padding: 24px 16px 48px; }",
|
|
5599
|
+
"h1 { font-size: 24px; margin: 0 0 4px; } h2 { font-size: 17px; margin: 36px 0 8px; }",
|
|
5600
|
+
"a { color: var(--accent); } .dim { color: var(--dim); }",
|
|
5601
|
+
"form.add, form.edit { display: grid; gap: 8px; margin: 16px 0 8px; }",
|
|
5602
|
+
"textarea, input, button { font: inherit; color: inherit; padding: 7px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--card); }",
|
|
5603
|
+
"button { cursor: pointer; } button.primary { justify-self: start; background: var(--accent); border-color: var(--accent); color: var(--bg); }",
|
|
5604
|
+
"ul.notes { list-style: none; padding: 0; display: grid; gap: 10px; }",
|
|
5605
|
+
".note { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }",
|
|
5606
|
+
".note.pinned { background: var(--pin); }",
|
|
5607
|
+
".note p { margin: 0 0 6px; white-space: pre-wrap; overflow-wrap: anywhere; }",
|
|
5608
|
+
".meta, .actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 13px; }",
|
|
5609
|
+
".actions { margin-top: 8px; } .actions form { margin: 0; } .actions details { flex-basis: 100%; order: 3; }",
|
|
5610
|
+
".tag { text-decoration: none; }",
|
|
5611
|
+
"ol.steps { list-style: none; padding: 0; display: grid; gap: 12px; }",
|
|
5612
|
+
".step { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }",
|
|
5613
|
+
".step.optional { border: 2px solid var(--accent); }",
|
|
5614
|
+
".step h3 { font-size: 16px; margin: 0 0 6px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }",
|
|
5615
|
+
".step p { margin: 6px 0; } .step b { color: var(--accent); }",
|
|
5616
|
+
".number { font-size: 12px; background: var(--ink); color: var(--bg); padding: 2px 7px; border-radius: 5px; }",
|
|
5617
|
+
".optional .number { background: var(--accent); }",
|
|
5618
|
+
".badge { font-size: 12px; font-weight: 600; color: var(--dim); border: 1px solid var(--line); padding: 1px 8px; border-radius: 999px; }",
|
|
5619
|
+
".uses { border-top: 1px solid var(--line); padding-top: 12px; font-size: 14px; }",
|
|
5620
|
+
"pre { white-space: pre-wrap; user-select: all; background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 10px; font-size: 13px; }",
|
|
5621
|
+
"details { margin: 6px 0; } summary { cursor: pointer; }",
|
|
5622
|
+
].join("\n");
|
|
5623
|
+
|
|
5624
|
+
const home = (to = "/") => new Response(null, { status: 303, headers: { location: to } });
|
|
5625
|
+
|
|
5626
|
+
const text = (body, status = 200) => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
5627
|
+
|
|
5628
|
+
const html = (markup) => new Response(markup, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
5629
|
+
|
|
5630
|
+
// Escapes text for HTML, in content and in quoted attribute values alike.
|
|
5631
|
+
const escape = (value) =>
|
|
5632
|
+
String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
5633
|
+
|
|
5634
|
+
const decode = (value) => {
|
|
5635
|
+
try {
|
|
5636
|
+
return decodeURIComponent(value);
|
|
5637
|
+
} catch {
|
|
5638
|
+
return value;
|
|
5639
|
+
}
|
|
5640
|
+
};
|
|
5641
|
+
|
|
5642
|
+
// A form field as trimmed text, at most max characters.
|
|
5643
|
+
const clean = (value, max) => (typeof value === "string" ? value.trim().slice(0, max) : "");
|
|
5644
|
+
|
|
5645
|
+
// "Team, Stand-up" becomes "team,stand-up": lowercase words, letters, digits
|
|
5646
|
+
// and hyphens only, so a tag is safe in a link and in the LIKE above.
|
|
5647
|
+
const tags = (value) =>
|
|
5648
|
+
clean(value, 200)
|
|
5649
|
+
.split(",")
|
|
5650
|
+
.map((tag) => tag.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((word) => word !== "").join("-"))
|
|
5651
|
+
.filter((tag, i, all) => tag !== "" && all.indexOf(tag) === i)
|
|
5652
|
+
.slice(0, 8)
|
|
5653
|
+
.join(",");
|
|
5654
|
+
`;
|
|
5655
|
+
var SERVER_MODULE = `${SERVER_HEAD}${SUGGESTIONS_SOURCE}${SERVER_TAIL}`;
|
|
5656
|
+
var STARTER_MIGRATION = [
|
|
5657
|
+
"-- The noticeboard's tables. wirl applies each new file here, in order, before a version goes live.",
|
|
5658
|
+
"-- Make the next change in 0002_<what>.sql; never edit a file that has been deployed.",
|
|
5659
|
+
"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)",
|
|
5660
|
+
"create table runs (id integer primary key, schedule text not null, note text not null, ran_at text not null default (datetime('now')))",
|
|
5661
|
+
"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')",
|
|
5662
|
+
""
|
|
5663
|
+
].join("\n");
|
|
5664
|
+
function starterManifest(app, org) {
|
|
5665
|
+
const manifest = {
|
|
5666
|
+
app,
|
|
5667
|
+
...org ? { org } : {},
|
|
5668
|
+
dir: ".",
|
|
5669
|
+
server: "server.mjs",
|
|
5670
|
+
description: "A team noticeboard: your first app on wirl",
|
|
5671
|
+
visibility: "private",
|
|
5672
|
+
schedules: [{ name: "tidy", cron: "0 2 * * *", timezone: "UTC" }]
|
|
5673
|
+
};
|
|
5674
|
+
return `${JSON.stringify(manifest, null, 2)}
|
|
5675
|
+
`;
|
|
5676
|
+
}
|
|
5677
|
+
var README = [
|
|
5678
|
+
"# Team noticeboard",
|
|
5679
|
+
"",
|
|
5680
|
+
"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.",
|
|
5681
|
+
"",
|
|
5682
|
+
"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.",
|
|
5683
|
+
"",
|
|
5684
|
+
"## What is where",
|
|
5685
|
+
"",
|
|
5686
|
+
"- `server.mjs`: the whole app, one module with one `fetch`. The page, the forms, who may change a note, and the nightly tidy-up.",
|
|
5687
|
+
"- `migrations/0001_init.sql`: the tables, one statement a line. Put the next change in `migrations/0002_<what>.sql`.",
|
|
5688
|
+
"- `wirl.json`: the app's name, its module, its nightly schedule, and that it is created private.",
|
|
5689
|
+
"- `.claude/skills/wirl/SKILL.md`: how a coding agent works with wirl.",
|
|
5690
|
+
"",
|
|
5691
|
+
"## Run it on this computer",
|
|
5692
|
+
"",
|
|
5693
|
+
` ${WIRL_BIN} dev`,
|
|
5694
|
+
"",
|
|
5695
|
+
"It prints the address to open, and a `curl` line that runs the nightly tidy-up now.",
|
|
5696
|
+
"",
|
|
5697
|
+
"## Make it yours",
|
|
5698
|
+
"",
|
|
5699
|
+
"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.",
|
|
5700
|
+
"",
|
|
5701
|
+
...SUGGESTIONS.flatMap(({ number, title, capability, prompt, youGet, optional }) => {
|
|
5702
|
+
const [bold, rest] = splitCapability(capability);
|
|
5703
|
+
return [`### ${String(number)}. ${title}`, "", ...optional ? ["Optional. The big one.", ""] : [], `**${bold}** ${rest}`, "", `> ${prompt}`, "", `You get: ${youGet}`, ""];
|
|
5704
|
+
})
|
|
5705
|
+
].join("\n");
|
|
5706
|
+
var STARTER_FILES = {
|
|
5707
|
+
"server.mjs": SERVER_MODULE,
|
|
5708
|
+
"migrations/0001_init.sql": STARTER_MIGRATION,
|
|
5709
|
+
"README.md": README,
|
|
5710
|
+
"wirl.json": starterManifest("starter")
|
|
5711
|
+
};
|
|
5712
|
+
|
|
5244
5713
|
// packages/cli/src/core.ts
|
|
5245
5714
|
function credentialsPath(env = process.env) {
|
|
5246
5715
|
return env["WIRL_CREDENTIALS_PATH"] || join5(homedir(), ".config", "wirl", "credentials.json");
|
|
@@ -5809,7 +6278,7 @@ async function callApi(creds, path, options = {}) {
|
|
|
5809
6278
|
const body = parsed ?? {};
|
|
5810
6279
|
const refusal = apiRefusal(parsed, response);
|
|
5811
6280
|
if (response.status === 403 && body.error === "insufficient_scope" && body.message) {
|
|
5812
|
-
throw new CliError(body.message,
|
|
6281
|
+
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
6282
|
}
|
|
5814
6283
|
if (response.status === 403 && (body.error === "person_only" || body.error === "bad_confirmation") && body.message) {
|
|
5815
6284
|
throw new CliError(body.message, void 0, refusal);
|
|
@@ -5847,7 +6316,7 @@ async function callApiBytes(creds, path, options = {}) {
|
|
|
5847
6316
|
body = {};
|
|
5848
6317
|
}
|
|
5849
6318
|
if (response.status === 401 || response.status === 403) {
|
|
5850
|
-
throw new CliError("Not signed in, or this token cannot do that.", `Try \`${WIRL_BIN} login
|
|
6319
|
+
throw new CliError("Not signed in, or this token cannot do that.", `Try \`${WIRL_BIN} login\`.`, { code: "unauthenticated" });
|
|
5851
6320
|
}
|
|
5852
6321
|
if (response.status === 404) throw new CliError("Not found, or you do not have access to it.");
|
|
5853
6322
|
throw new CliError(body.message ?? body.error ?? `Request failed (${response.status}).`);
|
|
@@ -5990,6 +6459,11 @@ function manifestWarningLines(manifest) {
|
|
|
5990
6459
|
const parsed = parseManifest(manifest);
|
|
5991
6460
|
return parsed.ok ? parsed.warnings.map((warning) => `Warning: ${warning}`) : [];
|
|
5992
6461
|
}
|
|
6462
|
+
function manifestVisibility(manifest) {
|
|
6463
|
+
if (manifest === null || manifest === void 0) return void 0;
|
|
6464
|
+
const parsed = parseManifest(manifest);
|
|
6465
|
+
return parsed.ok ? parsed.manifest.visibility : void 0;
|
|
6466
|
+
}
|
|
5993
6467
|
async function prepareDeployBundle(dir, manifest, door, projectDir) {
|
|
5994
6468
|
let server;
|
|
5995
6469
|
let rules = NO_MANIFEST;
|
|
@@ -6198,13 +6672,25 @@ function chooseOrg(orgs, given, hint = 'Pass --org <slug>, or add "org" to wirl.
|
|
|
6198
6672
|
throw new CliError(`Which organisation? You belong to ${orgs.map((o) => o.slug).join(", ")}.`, hint, { code: "missing_argument" });
|
|
6199
6673
|
}
|
|
6200
6674
|
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`, {
|
|
6675
|
+
var createRemoteApp = (creds, org, slug, name, visibility) => callApi(creds, `/api/v1/orgs/${seg(org)}/apps`, {
|
|
6202
6676
|
method: "POST",
|
|
6203
|
-
|
|
6677
|
+
// `visibility` rides along only when wirl.json said it: absent, the
|
|
6678
|
+
// org's default holds. It applies to the app this create makes and to
|
|
6679
|
+
// no other: an app that exists answers 409 and is left as it is.
|
|
6680
|
+
body: JSON.stringify({ slug, name: name ?? slug, ...visibility === void 0 ? {} : { visibility } }),
|
|
6204
6681
|
contentType: "application/json"
|
|
6205
6682
|
});
|
|
6206
|
-
async function ensureRemoteApp(creds, org, app) {
|
|
6207
|
-
return createRemoteApp(creds, org, app).then(() =>
|
|
6683
|
+
async function ensureRemoteApp(creds, org, app, visibility) {
|
|
6684
|
+
return createRemoteApp(creds, org, app, void 0, visibility).then((created) => {
|
|
6685
|
+
const made = created.app?.visibility;
|
|
6686
|
+
if (visibility === "private" && typeof made === "string" && made !== "private") {
|
|
6687
|
+
process.stderr.write(
|
|
6688
|
+
`${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}`)}
|
|
6689
|
+
`
|
|
6690
|
+
);
|
|
6691
|
+
}
|
|
6692
|
+
return true;
|
|
6693
|
+
}, (error) => {
|
|
6208
6694
|
if (!(error instanceof CliError)) throw error;
|
|
6209
6695
|
if (error.fields["code"] === "app_deleted") throw deletedNameError(error, app);
|
|
6210
6696
|
if (error.fields["code"] === "app_exists" || error.fields["code"] === "app_moved") throw takenNameError(error, app);
|
|
@@ -7463,9 +7949,16 @@ function loginInstructions(login) {
|
|
|
7463
7949
|
` ${login.verificationUriComplete}`,
|
|
7464
7950
|
` Code: ${login.userCode}`,
|
|
7465
7951
|
"",
|
|
7952
|
+
"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.",
|
|
7953
|
+
"",
|
|
7466
7954
|
`Once they have approved, call the wirl tool again. The code expires in ${Math.round(login.expiresIn / 60)} minutes.`
|
|
7467
7955
|
].join("\n");
|
|
7468
7956
|
}
|
|
7957
|
+
function alreadySignedIn(me) {
|
|
7958
|
+
const orgs = `Organisations: ${me.orgs.map((o) => o.slug).join(", ") || "none yet"}.`;
|
|
7959
|
+
if ((me.scope ?? "full") === "full") return `Already signed in, with full access. ${orgs}`;
|
|
7960
|
+
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.`;
|
|
7961
|
+
}
|
|
7469
7962
|
async function beginLogin() {
|
|
7470
7963
|
const apiOrigin = apiOriginFromEnv();
|
|
7471
7964
|
const login = await startDeviceLogin(apiOrigin, { client: `Claude Code on ${hostname()}` });
|
|
@@ -8171,8 +8664,7 @@ var LOGIN = {
|
|
|
8171
8664
|
async handler() {
|
|
8172
8665
|
try {
|
|
8173
8666
|
const creds = await loadCredentials();
|
|
8174
|
-
|
|
8175
|
-
return `Already signed in. Organisations: ${me.orgs.map((o) => o.slug).join(", ") || "none yet"}.`;
|
|
8667
|
+
return alreadySignedIn(await whoami(creds));
|
|
8176
8668
|
} catch {
|
|
8177
8669
|
}
|
|
8178
8670
|
const pending = pendingLogin;
|
|
@@ -8209,7 +8701,7 @@ var LOCAL_DEPLOY = {
|
|
|
8209
8701
|
throw withManifestWarnings(error, manifestWarnings);
|
|
8210
8702
|
});
|
|
8211
8703
|
const warnings = [...manifestWarnings, ...prepared.notices];
|
|
8212
|
-
const made = await ensureRemoteApp(ctx.creds, org, app);
|
|
8704
|
+
const made = await ensureRemoteApp(ctx.creds, org, app, manifestVisibility(manifest));
|
|
8213
8705
|
const result2 = await deploy(ctx.creds, { ...upload, prepared }).catch((error) => {
|
|
8214
8706
|
throw withManifestWarnings(withMadeApp(error, made, "wirl_deploy", app), warnings);
|
|
8215
8707
|
});
|
|
@@ -8306,7 +8798,7 @@ var LOCAL_PREVIEW = {
|
|
|
8306
8798
|
throw withManifestWarnings(error, manifestWarnings);
|
|
8307
8799
|
});
|
|
8308
8800
|
const warnings = [...manifestWarnings, ...prepared.notices];
|
|
8309
|
-
const made = await ensureRemoteApp(ctx.creds, org, app);
|
|
8801
|
+
const made = await ensureRemoteApp(ctx.creds, org, app, manifestVisibility(manifest));
|
|
8310
8802
|
const result2 = await deployPreview(ctx.creds, { ...upload, prepared }).catch((error) => {
|
|
8311
8803
|
throw withManifestWarnings(withMadeApp(error, made, "wirl_preview", app), warnings);
|
|
8312
8804
|
});
|
|
@@ -8367,7 +8859,7 @@ function npmFallbackLines() {
|
|
|
8367
8859
|
}
|
|
8368
8860
|
var REMOTE_DEPLOY = {
|
|
8369
8861
|
name: "wirl_deploy",
|
|
8370
|
-
description:
|
|
8862
|
+
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
8863
|
annotations: { title: "Deploy an app", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
8372
8864
|
inputSchema: {
|
|
8373
8865
|
type: "object",
|