wirl 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/bin.js +553 -39
- package/package.json +1 -1
package/bin.js
CHANGED
|
@@ -2977,6 +2977,9 @@ function parseManifest(input) {
|
|
|
2977
2977
|
}
|
|
2978
2978
|
accent = rawAccent;
|
|
2979
2979
|
}
|
|
2980
|
+
const rawVisibility = raw["visibility"];
|
|
2981
|
+
if (rawVisibility !== void 0 && rawVisibility !== "private") return refuse(VISIBILITY_REFUSED);
|
|
2982
|
+
const visibility = rawVisibility === "private" ? { visibility: "private" } : {};
|
|
2980
2983
|
const flowResult = parseFlows(raw);
|
|
2981
2984
|
if (!flowResult.ok) return refuse(flowResult.message);
|
|
2982
2985
|
const flows = flowResult.flows;
|
|
@@ -2995,7 +2998,7 @@ function parseManifest(input) {
|
|
|
2995
2998
|
if (!publicPathsResult2.ok) return refuse(publicPathsResult2.message);
|
|
2996
2999
|
return {
|
|
2997
3000
|
ok: true,
|
|
2998
|
-
manifest: { runtime, connections: [], description, accent, schedules, flows, publicPaths: publicPathsResult2.publicPaths, static: staticFiles, migrations },
|
|
3001
|
+
manifest: { runtime, connections: [], description, accent, schedules, flows, publicPaths: publicPathsResult2.publicPaths, static: staticFiles, migrations, ...visibility },
|
|
2999
3002
|
warnings
|
|
3000
3003
|
};
|
|
3001
3004
|
}
|
|
@@ -3054,11 +3057,11 @@ function parseManifest(input) {
|
|
|
3054
3057
|
if (!publicPathsResult.ok) return refuse(publicPathsResult.message);
|
|
3055
3058
|
return {
|
|
3056
3059
|
ok: true,
|
|
3057
|
-
manifest: { runtime, connections, description, accent, schedules, flows, publicPaths: publicPathsResult.publicPaths, static: staticFiles, migrations },
|
|
3060
|
+
manifest: { runtime, connections, description, accent, schedules, flows, publicPaths: publicPathsResult.publicPaths, static: staticFiles, migrations, ...visibility },
|
|
3058
3061
|
warnings
|
|
3059
3062
|
};
|
|
3060
3063
|
}
|
|
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;
|
|
3064
|
+
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
3065
|
var init_manifest = __esm({
|
|
3063
3066
|
"packages/core/src/manifest.ts"() {
|
|
3064
3067
|
"use strict";
|
|
@@ -3098,7 +3101,8 @@ var init_manifest = __esm({
|
|
|
3098
3101
|
"server",
|
|
3099
3102
|
"spa",
|
|
3100
3103
|
"start",
|
|
3101
|
-
"static"
|
|
3104
|
+
"static",
|
|
3105
|
+
"visibility"
|
|
3102
3106
|
]);
|
|
3103
3107
|
QUIET_KEYS = Object.freeze(["$schema"]);
|
|
3104
3108
|
UNKNOWN_KEYS_NAMED = 10;
|
|
@@ -3120,6 +3124,7 @@ var init_manifest = __esm({
|
|
|
3120
3124
|
TIMEOUT_MESSAGE = (name) => `timeout for ${name} must be between 30s and 15m; a run longer than that should be split into resumable chunks.`;
|
|
3121
3125
|
FLOW_KEYS = ["name", "step_timeout", "retries"];
|
|
3122
3126
|
MIGRATIONS_KEYS = ["destructive"];
|
|
3127
|
+
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
3128
|
}
|
|
3124
3129
|
});
|
|
3125
3130
|
|
|
@@ -4713,7 +4718,7 @@ var init_version = __esm({
|
|
|
4713
4718
|
"packages/core/src/version.ts"() {
|
|
4714
4719
|
"use strict";
|
|
4715
4720
|
WORKSPACE_VERSION = "0.0.0";
|
|
4716
|
-
BUILT_VERSION = true ? "0.2.
|
|
4721
|
+
BUILT_VERSION = true ? "0.2.3" : null;
|
|
4717
4722
|
}
|
|
4718
4723
|
});
|
|
4719
4724
|
|
|
@@ -4767,6 +4772,22 @@ var init_connect = __esm({
|
|
|
4767
4772
|
}
|
|
4768
4773
|
});
|
|
4769
4774
|
|
|
4775
|
+
// packages/core/src/setup-prompt.ts
|
|
4776
|
+
function starterName(email) {
|
|
4777
|
+
const handle = (email ?? "").split("@")[0] ?? "";
|
|
4778
|
+
const slug = handle.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
4779
|
+
const cut2 = slug.slice(0, SLUG_MAX - STARTER_SUFFIX.length).replace(/-+$/, "");
|
|
4780
|
+
return cut2 === "" ? "starter" : `${cut2}${STARTER_SUFFIX}`;
|
|
4781
|
+
}
|
|
4782
|
+
var STARTER_SUFFIX;
|
|
4783
|
+
var init_setup_prompt = __esm({
|
|
4784
|
+
"packages/core/src/setup-prompt.ts"() {
|
|
4785
|
+
"use strict";
|
|
4786
|
+
init_slug();
|
|
4787
|
+
STARTER_SUFFIX = "-starter";
|
|
4788
|
+
}
|
|
4789
|
+
});
|
|
4790
|
+
|
|
4770
4791
|
// packages/core/src/porting.ts
|
|
4771
4792
|
function dependencyNames(packageJson) {
|
|
4772
4793
|
if (packageJson === void 0) return [];
|
|
@@ -5119,6 +5140,7 @@ var init_src = __esm({
|
|
|
5119
5140
|
init_install();
|
|
5120
5141
|
init_share_role();
|
|
5121
5142
|
init_connect();
|
|
5143
|
+
init_setup_prompt();
|
|
5122
5144
|
init_porting();
|
|
5123
5145
|
init_throttle_report();
|
|
5124
5146
|
init_supplied_bundle();
|
|
@@ -7291,6 +7313,429 @@ ${FLOWS_CONTRACT}
|
|
|
7291
7313
|
`;
|
|
7292
7314
|
}
|
|
7293
7315
|
|
|
7316
|
+
// packages/cli/src/starter.ts
|
|
7317
|
+
init_src();
|
|
7318
|
+
var STARTER_CONFLICT = "Choose one: --server or --starter.";
|
|
7319
|
+
var SUGGESTIONS = [
|
|
7320
|
+
{
|
|
7321
|
+
title: "Rename it to something we'd actually use",
|
|
7322
|
+
prompt: `Rename this noticeboard to something my team would actually use. Ask me what we would use it for, suggest three names, and when I pick one, change the page's title and heading and the description in wirl.json to match. Keep "app" in wirl.json as it is: a new name there would make a second app at a new address. Then deploy it with wirl deploy.`
|
|
7323
|
+
},
|
|
7324
|
+
{
|
|
7325
|
+
title: "Try a new look in a preview first",
|
|
7326
|
+
prompt: "Give the noticeboard a warmer look: pinned notes as coloured cards, and tags as small coloured labels. Put it up with wirl preview first and give me the preview's address to check. Deploy it with wirl deploy only after I say it looks right."
|
|
7327
|
+
},
|
|
7328
|
+
{
|
|
7329
|
+
title: "Add a due date to notes",
|
|
7330
|
+
prompt: "Add an optional due date to notes on the noticeboard. Add the column in a new file, migrations/0002_due_date.sql, and never edit 0001. Let people set the date when they post or edit a note, show it on each note, and put notes due soonest at the top. Try it with wirl dev, then deploy it with wirl deploy."
|
|
7331
|
+
},
|
|
7332
|
+
{
|
|
7333
|
+
title: "Show the weather from a public API",
|
|
7334
|
+
prompt: `Show today's weather for our office at the top of the noticeboard, from Open-Meteo, a public weather API that needs no key. Ask me which city our office is in. Add this entry to "connections" in wirl.json: {"slug": "open-meteo", "kind": "none", "hosts": ["api.open-meteo.com"]}. Fetch Open-Meteo's own URL from server.mjs, and deploy it with wirl deploy. If the deploy says open-meteo is waiting, the connection needs approving, which your sign-in cannot do: tell me to approve it on the app's Connections page on app.wirl.dev, or to ask an admin of our organisation to. The weather shows once it is approved.`
|
|
7335
|
+
},
|
|
7336
|
+
{
|
|
7337
|
+
title: "Change when the tidy-up runs",
|
|
7338
|
+
prompt: "Change the noticeboard's nightly tidy-up: archive notes after 14 days instead of 30, and run it at 6pm on weekdays in my time zone (ask me which). Change the schedule in wirl.json and the number in server.mjs, deploy it with wirl deploy, then run it once with wirl run tidy and tell me what it said."
|
|
7339
|
+
},
|
|
7340
|
+
{
|
|
7341
|
+
title: "Find out what happened from the logs",
|
|
7342
|
+
prompt: "Read this app's logs with wirl logs and tell me, in a few lines, what people did on the noticeboard today and whether anything failed."
|
|
7343
|
+
},
|
|
7344
|
+
{
|
|
7345
|
+
title: "Share it with a colleague",
|
|
7346
|
+
prompt: `Help me share this noticeboard with a colleague. Sharing is done by me, not by a deploy: tell me to share it from the app's Access page on app.wirl.dev (the "Who can open it" row on the noticeboard links to it), where I add their email address. Then tell me what they will see when they open the address.`
|
|
7347
|
+
}
|
|
7348
|
+
];
|
|
7349
|
+
var SUGGESTIONS_SOURCE = [
|
|
7350
|
+
"const SUGGESTIONS = [",
|
|
7351
|
+
...SUGGESTIONS.map(({ title, prompt }) => ` { title: ${JSON.stringify(title)}, prompt: ${JSON.stringify(prompt)} },`),
|
|
7352
|
+
"];"
|
|
7353
|
+
].join("\n");
|
|
7354
|
+
var SERVER_HEAD = String.raw`/**
|
|
7355
|
+
* Team noticeboard: your first app on wirl.
|
|
7356
|
+
*
|
|
7357
|
+
* Your team posts short notes here, tags them, pins the ones everyone should
|
|
7358
|
+
* see, and edits or removes their own. Every night a schedule archives old
|
|
7359
|
+
* notes nobody pinned. The page also explains itself: a "How this works on
|
|
7360
|
+
* wirl" panel, one row per feature, each linking to that page of the app on
|
|
7361
|
+
* app.wirl.dev, and a few changes to ask your coding agent for next.
|
|
7362
|
+
*
|
|
7363
|
+
* The whole app is this one file:
|
|
7364
|
+
* - The page is HTML this module returns: plain forms, no script.
|
|
7365
|
+
* - Notes live in env.DB, a SQLite database that belongs to this app alone.
|
|
7366
|
+
* Its tables are in migrations/0001_init.sql, and wirl applies each new
|
|
7367
|
+
* file in migrations/ before a version goes live. Put the next change in
|
|
7368
|
+
* migrations/0002_<what>.sql rather than editing that one.
|
|
7369
|
+
* - Who is asking arrives in headers wirl sets after signing them in, which
|
|
7370
|
+
* the browser cannot forge. Who may change a note is a line of code here:
|
|
7371
|
+
* a check in the page is only a suggestion to whoever holds the browser.
|
|
7372
|
+
* - The nightly schedule is declared in wirl.json. wirl sends it here as a
|
|
7373
|
+
* POST to /__wirl/schedule/tidy, and what it answers becomes the run's note.
|
|
7374
|
+
* - console.log lines appear on the app's Logs page, and in wirl logs.
|
|
7375
|
+
*/
|
|
7376
|
+
|
|
7377
|
+
const TITLE = "Team noticeboard";
|
|
7378
|
+
const DASHBOARD = "https://app.wirl.dev";
|
|
7379
|
+
const ARCHIVE_AFTER_DAYS = 30;
|
|
7380
|
+
const LONGEST_NOTE = 2000;
|
|
7381
|
+
|
|
7382
|
+
export default {
|
|
7383
|
+
async fetch(request, env) {
|
|
7384
|
+
const url = new URL(request.url);
|
|
7385
|
+
// One line per request, on the Logs page.
|
|
7386
|
+
console.log(request.method, url.pathname);
|
|
7387
|
+
|
|
7388
|
+
// The nightly tidy-up, sent by wirl at the time wirl.json gives. wirl
|
|
7389
|
+
// names the schedule in x-wirl-schedule as well as in the path, and a
|
|
7390
|
+
// request without that header is not one wirl sent.
|
|
7391
|
+
if (url.pathname.startsWith("/__wirl/schedule/")) {
|
|
7392
|
+
const name = url.pathname.slice("/__wirl/schedule/".length);
|
|
7393
|
+
const fromWirl = request.headers.get("x-wirl-schedule") === name;
|
|
7394
|
+
if (name === "tidy" && request.method === "POST" && fromWirl) return tidy(env);
|
|
7395
|
+
return text("No such schedule", 404);
|
|
7396
|
+
}
|
|
7397
|
+
|
|
7398
|
+
const me = visitor(request);
|
|
7399
|
+
|
|
7400
|
+
if (request.method === "POST") {
|
|
7401
|
+
// A form posted from another site is never one of yours.
|
|
7402
|
+
if (request.headers.get("sec-fetch-site") === "cross-site") return text("Refused: this form came from another site.", 403);
|
|
7403
|
+
if (me.id === "") return text("Sign in to change the board.", 403);
|
|
7404
|
+
let form;
|
|
7405
|
+
try {
|
|
7406
|
+
form = await request.formData();
|
|
7407
|
+
} catch {
|
|
7408
|
+
return text("Send this as a form.", 400);
|
|
7409
|
+
}
|
|
7410
|
+
return change(url.pathname, form, me, env);
|
|
7411
|
+
}
|
|
7412
|
+
|
|
7413
|
+
if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) {
|
|
7414
|
+
return html(await page(request, url, me, env));
|
|
7415
|
+
}
|
|
7416
|
+
return text("Not found", 404);
|
|
7417
|
+
},
|
|
7418
|
+
};
|
|
7419
|
+
|
|
7420
|
+
// Set by wirl after it signed this person in. Never take identity from a form
|
|
7421
|
+
// or the query string: those say whatever the browser wants them to say. A
|
|
7422
|
+
// header carries only Latin-1 and a name can be anything, so wirl
|
|
7423
|
+
// percent-encodes the name; it is decoded here.
|
|
7424
|
+
function visitor(request) {
|
|
7425
|
+
const header = (name) => decode(request.headers.get(name) || "");
|
|
7426
|
+
return {
|
|
7427
|
+
id: header("x-wirl-user-id"),
|
|
7428
|
+
name: header("x-wirl-user-name") || header("x-wirl-user-email") || "someone",
|
|
7429
|
+
role: header("x-wirl-role"),
|
|
7430
|
+
};
|
|
7431
|
+
}
|
|
7432
|
+
|
|
7433
|
+
// POST /notes, and POST /notes/<id>/<edit|pin|remove|restore>.
|
|
7434
|
+
async function change(path, form, me, env) {
|
|
7435
|
+
const [, first, id, action] = path.split("/");
|
|
7436
|
+
if (first !== "notes") return text("Not found", 404);
|
|
7437
|
+
|
|
7438
|
+
if (id === undefined) {
|
|
7439
|
+
const body = clean(form.get("body"), LONGEST_NOTE);
|
|
7440
|
+
if (body === "") return text("A note needs some words.", 400);
|
|
7441
|
+
const { meta } = await env.DB.prepare("insert into notes (body, tags, author_id, author_name) values (?, ?, ?, ?)")
|
|
7442
|
+
.bind(body, tags(form.get("tags")), me.id, me.name)
|
|
7443
|
+
.run();
|
|
7444
|
+
console.log("note", meta.last_row_id, "posted by", me.id);
|
|
7445
|
+
return home();
|
|
7446
|
+
}
|
|
7447
|
+
|
|
7448
|
+
const number = Number.parseInt(id, 10);
|
|
7449
|
+
if (String(number) !== id) return text("Not found", 404);
|
|
7450
|
+
const note = await env.DB.prepare("select id, author_id from notes where id = ?").bind(number).first();
|
|
7451
|
+
if (!note) return text("No such note", 404);
|
|
7452
|
+
|
|
7453
|
+
// The rule that matters, in code: authors change their own notes, and the
|
|
7454
|
+
// app's owner can change any. Anyone on the board can pin.
|
|
7455
|
+
const mine = note.author_id === me.id || me.role === "owner";
|
|
7456
|
+
|
|
7457
|
+
if (action === "pin") {
|
|
7458
|
+
await env.DB.prepare("update notes set pinned = 1 - pinned where id = ?").bind(number).run();
|
|
7459
|
+
console.log("note", number, "pin changed by", me.id);
|
|
7460
|
+
return home();
|
|
7461
|
+
}
|
|
7462
|
+
if (!mine) return text("Only the person who posted this note, or the app's owner, can change it.", 403);
|
|
7463
|
+
|
|
7464
|
+
if (action === "edit") {
|
|
7465
|
+
const body = clean(form.get("body"), LONGEST_NOTE);
|
|
7466
|
+
if (body === "") return text("A note needs some words.", 400);
|
|
7467
|
+
await env.DB.prepare("update notes set body = ?, tags = ? where id = ?").bind(body, tags(form.get("tags")), number).run();
|
|
7468
|
+
console.log("note", number, "edited by", me.id);
|
|
7469
|
+
return home();
|
|
7470
|
+
}
|
|
7471
|
+
if (action === "remove") {
|
|
7472
|
+
await env.DB.prepare("delete from notes where id = ?").bind(number).run();
|
|
7473
|
+
console.log("note", number, "removed by", me.id);
|
|
7474
|
+
return home();
|
|
7475
|
+
}
|
|
7476
|
+
if (action === "restore") {
|
|
7477
|
+
// Back on the board as if posted today, so tonight's tidy-up leaves it be.
|
|
7478
|
+
await env.DB.prepare("update notes set archived_at = null, created_at = datetime('now') where id = ?").bind(number).run();
|
|
7479
|
+
console.log("note", number, "put back by", me.id);
|
|
7480
|
+
return home("/?view=archived");
|
|
7481
|
+
}
|
|
7482
|
+
return text("Not found", 404);
|
|
7483
|
+
}
|
|
7484
|
+
|
|
7485
|
+
// The schedule: archive notes older than ARCHIVE_AFTER_DAYS that nobody
|
|
7486
|
+
// pinned, and keep a line saying so, which the page shows as the last run.
|
|
7487
|
+
async function tidy(env) {
|
|
7488
|
+
const { meta } = await env.DB.prepare(
|
|
7489
|
+
"update notes set archived_at = datetime('now') where archived_at is null and pinned = 0 and created_at < datetime('now', ?)",
|
|
7490
|
+
)
|
|
7491
|
+
.bind("-" + ARCHIVE_AFTER_DAYS + " days")
|
|
7492
|
+
.run();
|
|
7493
|
+
const note = "Archived " + meta.changes + (meta.changes === 1 ? " note" : " notes") + " older than " + ARCHIVE_AFTER_DAYS + " days.";
|
|
7494
|
+
await env.DB.prepare("insert into runs (schedule, note) values (?, ?)").bind("tidy", note).run();
|
|
7495
|
+
console.log("tidy:", note);
|
|
7496
|
+
return text(note);
|
|
7497
|
+
}
|
|
7498
|
+
|
|
7499
|
+
async function page(request, url, me, env) {
|
|
7500
|
+
const tag = tags(url.searchParams.get("tag")).split(",")[0];
|
|
7501
|
+
const archived = url.searchParams.get("view") === "archived";
|
|
7502
|
+
let sql = "select id, body, tags, pinned, author_id, author_name, created_at from notes where archived_at is " + (archived ? "not null" : "null");
|
|
7503
|
+
const binds = [];
|
|
7504
|
+
if (tag !== "") {
|
|
7505
|
+
sql += " and (',' || tags || ',') like ?";
|
|
7506
|
+
binds.push("%," + tag + ",%");
|
|
7507
|
+
}
|
|
7508
|
+
sql += " order by pinned desc, id desc limit 200";
|
|
7509
|
+
const notes = (await env.DB.prepare(sql).bind(...binds).all()).results;
|
|
7510
|
+
const archivedCount = await env.DB.prepare("select count(*) as n from notes where archived_at is not null").first("n");
|
|
7511
|
+
const lastRun = await env.DB.prepare("select note, ran_at from runs where schedule = 'tidy' order by id desc limit 1").first();
|
|
7512
|
+
|
|
7513
|
+
const shown = archived
|
|
7514
|
+
? "<p class='dim'>Notes the nightly tidy-up moved off the board. <a href='/'>Back to the board</a></p>"
|
|
7515
|
+
: form() +
|
|
7516
|
+
"<p class='dim filters'>" +
|
|
7517
|
+
(tag === "" ? "All notes" : "Tagged <b>" + escape(tag) + "</b> · <a href='/'>show all</a>") +
|
|
7518
|
+
" · <a href='/?view=archived'>Archived (" + archivedCount + ")</a></p>";
|
|
7519
|
+
|
|
7520
|
+
return [
|
|
7521
|
+
"<!doctype html>",
|
|
7522
|
+
"<html lang='en'>",
|
|
7523
|
+
"<meta charset='utf-8'>",
|
|
7524
|
+
"<meta name='viewport' content='width=device-width, initial-scale=1'>",
|
|
7525
|
+
"<title>" + TITLE + "</title>",
|
|
7526
|
+
"<style>" + STYLE + "</style>",
|
|
7527
|
+
"<main>",
|
|
7528
|
+
"<header><h1>" + TITLE + "</h1>",
|
|
7529
|
+
"<p class='dim'>Your first app on wirl. Signed in as <b>" + escape(me.name) + "</b>" + (me.role ? " (" + escape(me.role) + ")" : "") + ".</p></header>",
|
|
7530
|
+
shown,
|
|
7531
|
+
"<ul class='notes'>" + (notes.length === 0 ? "<li class='dim'>Nothing here yet.</li>" : notes.map((note) => card(note, me, archived)).join("")) + "</ul>",
|
|
7532
|
+
panel(request, me, lastRun),
|
|
7533
|
+
suggestions(),
|
|
7534
|
+
"</main>",
|
|
7535
|
+
"</html>",
|
|
7536
|
+
].join("\n");
|
|
7537
|
+
}
|
|
7538
|
+
|
|
7539
|
+
function form() {
|
|
7540
|
+
return [
|
|
7541
|
+
"<form class='add' method='post' action='/notes'>",
|
|
7542
|
+
"<textarea name='body' rows='3' maxlength='" + LONGEST_NOTE + "' required placeholder='Post a note for the team'></textarea>",
|
|
7543
|
+
"<input name='tags' placeholder='Tags, separated by commas'>",
|
|
7544
|
+
"<button class='primary'>Post</button>",
|
|
7545
|
+
"</form>",
|
|
7546
|
+
].join("");
|
|
7547
|
+
}
|
|
7548
|
+
|
|
7549
|
+
function card(note, me, archived) {
|
|
7550
|
+
const mine = note.author_id === me.id || me.role === "owner";
|
|
7551
|
+
const action = (what, label) =>
|
|
7552
|
+
"<form method='post' action='/notes/" + note.id + "/" + what + "'><button>" + label + "</button></form>";
|
|
7553
|
+
const tagLinks = note.tags
|
|
7554
|
+
.split(",")
|
|
7555
|
+
.filter((tag) => tag !== "")
|
|
7556
|
+
.map((tag) => "<a class='tag' href='/?tag=" + encodeURIComponent(tag) + "'>#" + escape(tag) + "</a>")
|
|
7557
|
+
.join(" ");
|
|
7558
|
+
const controls = archived
|
|
7559
|
+
? (mine ? action("restore", "Put back") : "")
|
|
7560
|
+
: action("pin", note.pinned ? "Unpin" : "Pin") +
|
|
7561
|
+
(mine
|
|
7562
|
+
? "<details><summary>Edit</summary><form class='edit' method='post' action='/notes/" + note.id + "/edit'>" +
|
|
7563
|
+
"<textarea name='body' rows='3' maxlength='" + LONGEST_NOTE + "' required>" + escape(note.body) + "</textarea>" +
|
|
7564
|
+
"<input name='tags' value='" + escape(note.tags.split(",").join(", ")) + "'>" +
|
|
7565
|
+
"<button class='primary'>Save</button></form></details>" +
|
|
7566
|
+
action("remove", "Remove")
|
|
7567
|
+
: "");
|
|
7568
|
+
return (
|
|
7569
|
+
"<li class='note" + (note.pinned && !archived ? " pinned" : "") + "'>" +
|
|
7570
|
+
"<p>" + escape(note.body) + "</p>" +
|
|
7571
|
+
"<div class='meta dim'>" + (note.pinned && !archived ? "<b>Pinned</b> · " : "") +
|
|
7572
|
+
escape(note.author_name || "someone") + " · " + escape(note.created_at.slice(0, 10)) + (tagLinks ? " · " + tagLinks : "") + "</div>" +
|
|
7573
|
+
"<div class='actions'>" + controls + "</div>" +
|
|
7574
|
+
"</li>"
|
|
7575
|
+
);
|
|
7576
|
+
}
|
|
7577
|
+
|
|
7578
|
+
// The app's page on the dashboard, from the address it was opened at:
|
|
7579
|
+
// <org>--<app>.wirl.run, or <org>--<app>--<preview>.wirl.run for a preview.
|
|
7580
|
+
// Under wirl dev the address is 127.0.0.1, which names no app: null.
|
|
7581
|
+
function dashboard(request) {
|
|
7582
|
+
const label = new URL(request.url).hostname.split(".")[0];
|
|
7583
|
+
const parts = label.split("--");
|
|
7584
|
+
if (parts.length < 2) return null;
|
|
7585
|
+
const org = request.headers.get("x-wirl-org") || parts[0];
|
|
7586
|
+
return DASHBOARD + "/apps/" + encodeURIComponent(org) + "/" + encodeURIComponent(parts[1]);
|
|
7587
|
+
}
|
|
7588
|
+
|
|
7589
|
+
function panel(request, me, lastRun) {
|
|
7590
|
+
const base = dashboard(request);
|
|
7591
|
+
const last = lastRun ? "Last run " + lastRun.ran_at.slice(0, 16) + " UTC: " + lastRun.note : "Not run yet.";
|
|
7592
|
+
// One row per feature: what it is, what it does in this app, and its page.
|
|
7593
|
+
const rows = [
|
|
7594
|
+
["Who can open it", "wirl signed you in before this page loaded and tells the app who you are, in headers: here you are " + me.name + (me.role ? ", " + me.role : "") + ". The app has no sign-in code of its own. It started private to you; choose who else can open it on Access.", "/access", "Access"],
|
|
7595
|
+
["Your data", "Notes are rows in this app's own SQLite database. Its tables are in migrations/0001_init.sql, and a deploy applies each new file in migrations/ before it goes live.", "/deployments", "Deployments"],
|
|
7596
|
+
["A schedule", "tidy runs every night at 02:00 UTC and archives notes older than " + ARCHIVE_AFTER_DAYS + " days that nobody pinned. " + last + " Run it now from Overview.", "", "Overview"],
|
|
7597
|
+
["Logs", "Each request writes a line with console.log, and so does each change to a note. Read them on Logs, or ask your agent to run wirl logs.", "/logs", "Logs"],
|
|
7598
|
+
["Versions", "Each wirl deploy is a new version, and the earlier ones stay: going back to one takes a click.", "/deployments", "Deployments"],
|
|
7599
|
+
["Previews", "wirl preview puts a change up at an address of its own, with its own database, and nothing goes live until you deploy. Open previews are listed on Overview.", "", "Overview"],
|
|
7600
|
+
["Outside APIs", "Declare a connection in wirl.json and fetch the service's own URL: wirl adds the key on the way out, so it never reaches this code or a chat.", "/connections", "Connections"],
|
|
7601
|
+
["Settings", "Describe the app for your team's list of tools, hand it to someone else, or delete it.", "/settings", "Settings"],
|
|
7602
|
+
];
|
|
7603
|
+
return [
|
|
7604
|
+
"<section><h2>How this works on wirl</h2>",
|
|
7605
|
+
"<table>",
|
|
7606
|
+
...rows.map(([feature, what, path, label]) =>
|
|
7607
|
+
"<tr><td>" + feature + "</td><td>" + escape(what) + "</td><td>" +
|
|
7608
|
+
(base === null ? "<span class='dim'>" + label + "</span>" : "<a href='" + escape(base + path) + "'>" + label + "</a>") +
|
|
7609
|
+
"</td></tr>",
|
|
7610
|
+
),
|
|
7611
|
+
"</table>",
|
|
7612
|
+
base === null ? "<p class='dim'>Running under wirl dev, so these pages are not linked here: they open on app.wirl.dev once it is deployed.</p>" : "",
|
|
7613
|
+
"</section>",
|
|
7614
|
+
].join("\n");
|
|
7615
|
+
}
|
|
7616
|
+
|
|
7617
|
+
function suggestions() {
|
|
7618
|
+
return [
|
|
7619
|
+
"<section><h2>Ask your agent for a first change</h2>",
|
|
7620
|
+
"<p class='dim'>Each of these is a whole prompt. Open one, copy it, and paste it to the coding agent that made this app.</p>",
|
|
7621
|
+
...SUGGESTIONS.map((each) => "<details><summary>" + escape(each.title) + "</summary><pre>" + escape(each.prompt) + "</pre></details>"),
|
|
7622
|
+
"</section>",
|
|
7623
|
+
].join("\n");
|
|
7624
|
+
}
|
|
7625
|
+
|
|
7626
|
+
`;
|
|
7627
|
+
var SERVER_TAIL = String.raw`
|
|
7628
|
+
|
|
7629
|
+
const STYLE = [
|
|
7630
|
+
":root { color-scheme: light dark; --ink: #1d1d1b; --dim: #66655f; --line: #e2e0d9; --card: #ffffff; --bg: #f7f6f2; --pin: #fff3bf; --accent: #2b6b5a; }",
|
|
7631
|
+
"@media (prefers-color-scheme: dark) { :root { --ink: #ecebe6; --dim: #a6a59f; --line: #36352f; --card: #1f1f1c; --bg: #161614; --pin: #3b3420; --accent: #8fd1bd; } }",
|
|
7632
|
+
"body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, sans-serif; }",
|
|
7633
|
+
"main { max-width: 760px; margin: 0 auto; padding: 24px 16px 48px; }",
|
|
7634
|
+
"h1 { font-size: 24px; margin: 0 0 4px; } h2 { font-size: 17px; margin: 36px 0 8px; }",
|
|
7635
|
+
"a { color: var(--accent); } .dim { color: var(--dim); }",
|
|
7636
|
+
"form.add, form.edit { display: grid; gap: 8px; margin: 16px 0 8px; }",
|
|
7637
|
+
"textarea, input, button { font: inherit; color: inherit; padding: 7px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--card); }",
|
|
7638
|
+
"button { cursor: pointer; } button.primary { justify-self: start; background: var(--accent); border-color: var(--accent); color: var(--bg); }",
|
|
7639
|
+
"ul.notes { list-style: none; padding: 0; display: grid; gap: 10px; }",
|
|
7640
|
+
".note { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }",
|
|
7641
|
+
".note.pinned { background: var(--pin); }",
|
|
7642
|
+
".note p { margin: 0 0 6px; white-space: pre-wrap; overflow-wrap: anywhere; }",
|
|
7643
|
+
".meta, .actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 13px; }",
|
|
7644
|
+
".actions { margin-top: 8px; } .actions form { margin: 0; } .actions details { flex-basis: 100%; order: 3; }",
|
|
7645
|
+
".tag { text-decoration: none; }",
|
|
7646
|
+
"table { width: 100%; border-collapse: collapse; font-size: 14px; }",
|
|
7647
|
+
"td { border-top: 1px solid var(--line); padding: 8px 6px; vertical-align: top; }",
|
|
7648
|
+
"td:first-child { font-weight: 600; }",
|
|
7649
|
+
"pre { white-space: pre-wrap; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 10px; font-size: 13px; }",
|
|
7650
|
+
"details { margin: 6px 0; } summary { cursor: pointer; }",
|
|
7651
|
+
].join("\n");
|
|
7652
|
+
|
|
7653
|
+
const home = (to = "/") => new Response(null, { status: 303, headers: { location: to } });
|
|
7654
|
+
|
|
7655
|
+
const text = (body, status = 200) => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
7656
|
+
|
|
7657
|
+
const html = (markup) => new Response(markup, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
7658
|
+
|
|
7659
|
+
// Escapes text for HTML, in content and in quoted attribute values alike.
|
|
7660
|
+
const escape = (value) =>
|
|
7661
|
+
String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
7662
|
+
|
|
7663
|
+
const decode = (value) => {
|
|
7664
|
+
try {
|
|
7665
|
+
return decodeURIComponent(value);
|
|
7666
|
+
} catch {
|
|
7667
|
+
return value;
|
|
7668
|
+
}
|
|
7669
|
+
};
|
|
7670
|
+
|
|
7671
|
+
// A form field as trimmed text, at most max characters.
|
|
7672
|
+
const clean = (value, max) => (typeof value === "string" ? value.trim().slice(0, max) : "");
|
|
7673
|
+
|
|
7674
|
+
// "Team, Stand-up" becomes "team,stand-up": lowercase words, letters, digits
|
|
7675
|
+
// and hyphens only, so a tag is safe in a link and in the LIKE above.
|
|
7676
|
+
const tags = (value) =>
|
|
7677
|
+
clean(value, 200)
|
|
7678
|
+
.split(",")
|
|
7679
|
+
.map((tag) => tag.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((word) => word !== "").join("-"))
|
|
7680
|
+
.filter((tag, i, all) => tag !== "" && all.indexOf(tag) === i)
|
|
7681
|
+
.slice(0, 8)
|
|
7682
|
+
.join(",");
|
|
7683
|
+
`;
|
|
7684
|
+
var SERVER_MODULE = `${SERVER_HEAD}${SUGGESTIONS_SOURCE}${SERVER_TAIL}`;
|
|
7685
|
+
var STARTER_MIGRATION = [
|
|
7686
|
+
"-- The noticeboard's tables. wirl applies each new file here, in order, before a version goes live.",
|
|
7687
|
+
"-- Make the next change in 0002_<what>.sql; never edit a file that has been deployed.",
|
|
7688
|
+
"create table notes (id integer primary key, body text not null, tags text not null default '', pinned integer not null default 0, author_id text not null, author_name text not null default '', created_at text not null default (datetime('now')), archived_at text)",
|
|
7689
|
+
"create table runs (id integer primary key, schedule text not null, note text not null, ran_at text not null default (datetime('now')))",
|
|
7690
|
+
"insert into notes (body, tags, pinned, author_id, author_name) values ('Welcome to the noticeboard. Post a note below, tag it, and pin the ones everyone should see.', 'welcome', 1, '', 'wirl')",
|
|
7691
|
+
""
|
|
7692
|
+
].join("\n");
|
|
7693
|
+
function starterManifest(app, org) {
|
|
7694
|
+
const manifest = {
|
|
7695
|
+
app,
|
|
7696
|
+
...org ? { org } : {},
|
|
7697
|
+
dir: ".",
|
|
7698
|
+
server: "server.mjs",
|
|
7699
|
+
description: "A team noticeboard: your first app on wirl",
|
|
7700
|
+
visibility: "private",
|
|
7701
|
+
schedules: [{ name: "tidy", cron: "0 2 * * *", timezone: "UTC" }]
|
|
7702
|
+
};
|
|
7703
|
+
return `${JSON.stringify(manifest, null, 2)}
|
|
7704
|
+
`;
|
|
7705
|
+
}
|
|
7706
|
+
var README = [
|
|
7707
|
+
"# Team noticeboard",
|
|
7708
|
+
"",
|
|
7709
|
+
"Your first app on wirl: a board where your team posts short notes, tags them, pins the ones everyone should see, and edits or removes their own. Every night a schedule archives old notes nobody pinned.",
|
|
7710
|
+
"",
|
|
7711
|
+
'Open it at the address `wirl deploy` prints. It starts private to you. Its page has a "How this works on wirl" panel: one row per thing wirl does for this app, each linking to that page of the app on app.wirl.dev.',
|
|
7712
|
+
"",
|
|
7713
|
+
"## What is where",
|
|
7714
|
+
"",
|
|
7715
|
+
"- `server.mjs`: the whole app, one module with one `fetch`. The page, the forms, who may change a note, and the nightly tidy-up.",
|
|
7716
|
+
"- `migrations/0001_init.sql`: the tables, one statement a line. Put the next change in `migrations/0002_<what>.sql`.",
|
|
7717
|
+
"- `wirl.json`: the app's name, its module, its nightly schedule, and that it is created private.",
|
|
7718
|
+
"- `.claude/skills/wirl/SKILL.md`: how a coding agent works with wirl.",
|
|
7719
|
+
"",
|
|
7720
|
+
"## Run it on this computer",
|
|
7721
|
+
"",
|
|
7722
|
+
` ${WIRL_BIN} dev`,
|
|
7723
|
+
"",
|
|
7724
|
+
"It prints the address to open, and a `curl` line that runs the nightly tidy-up now.",
|
|
7725
|
+
"",
|
|
7726
|
+
"## Changes to ask your agent for",
|
|
7727
|
+
"",
|
|
7728
|
+
"Each is a whole prompt: paste it to your coding agent as it is.",
|
|
7729
|
+
"",
|
|
7730
|
+
...SUGGESTIONS.flatMap(({ title, prompt }) => [`### ${title}`, "", `> ${prompt}`, ""])
|
|
7731
|
+
].join("\n");
|
|
7732
|
+
var STARTER_FILES = {
|
|
7733
|
+
"server.mjs": SERVER_MODULE,
|
|
7734
|
+
"migrations/0001_init.sql": STARTER_MIGRATION,
|
|
7735
|
+
"README.md": README,
|
|
7736
|
+
"wirl.json": starterManifest("starter")
|
|
7737
|
+
};
|
|
7738
|
+
|
|
7294
7739
|
// packages/cli/src/core.ts
|
|
7295
7740
|
function credentialsPath(env = process.env) {
|
|
7296
7741
|
return env["WIRL_CREDENTIALS_PATH"] || join5(homedir(), ".config", "wirl", "credentials.json");
|
|
@@ -7633,23 +8078,23 @@ var INIT_MIGRATION = [
|
|
|
7633
8078
|
""
|
|
7634
8079
|
].join("\n");
|
|
7635
8080
|
var INIT_MIGRATION_PATH = `${MIGRATIONS_DIR}/${ADOPTED_FILE}`;
|
|
7636
|
-
async function refuseInitMigrations(dir) {
|
|
8081
|
+
async function refuseInitMigrations(dir, flag) {
|
|
7637
8082
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
7638
8083
|
if (entry.name.toLowerCase() !== MIGRATIONS_DIR) continue;
|
|
7639
8084
|
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
|
|
8085
|
+
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
8086
|
}
|
|
7642
8087
|
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
|
|
8088
|
+
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
8089
|
}
|
|
7645
8090
|
if (!entry.isDirectory()) {
|
|
7646
8091
|
throw new CliError(
|
|
7647
|
-
`migrations is a file, and wirl init
|
|
8092
|
+
`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
8093
|
);
|
|
7649
8094
|
}
|
|
7650
8095
|
const names = await readdir(join5(dir, entry.name));
|
|
7651
8096
|
if (names.some((name) => name.toLowerCase().endsWith(".sql"))) {
|
|
7652
|
-
throw new CliError(`migrations/ already holds .sql files; wirl init
|
|
8097
|
+
throw new CliError(`migrations/ already holds .sql files; wirl init ${flag} writes ${INIT_MIGRATION_PATH} into a folder with none.`);
|
|
7653
8098
|
}
|
|
7654
8099
|
}
|
|
7655
8100
|
}
|
|
@@ -7694,23 +8139,52 @@ async function pulledDevCommand(dir) {
|
|
|
7694
8139
|
}
|
|
7695
8140
|
return `${needsInstall ? "npm install && " : ""}${WIRL_BIN} dev`;
|
|
7696
8141
|
}
|
|
8142
|
+
function starterFile(path) {
|
|
8143
|
+
const text = STARTER_FILES[path];
|
|
8144
|
+
if (text === void 0) throw new Error(`starter.ts has no ${path}`);
|
|
8145
|
+
return text;
|
|
8146
|
+
}
|
|
8147
|
+
function moduleKit(options) {
|
|
8148
|
+
if (options.starter) {
|
|
8149
|
+
return {
|
|
8150
|
+
flag: "--starter",
|
|
8151
|
+
files: ["server.mjs", INIT_MIGRATION_PATH, "README.md"].map((path) => [path, starterFile(path)]),
|
|
8152
|
+
hint: "Run wirl init --starter in a new folder, or move the existing file."
|
|
8153
|
+
};
|
|
8154
|
+
}
|
|
8155
|
+
if (options.server) {
|
|
8156
|
+
return {
|
|
8157
|
+
flag: "--server",
|
|
8158
|
+
files: [
|
|
8159
|
+
["server.mjs", SERVER_TEMPLATE],
|
|
8160
|
+
[INIT_MIGRATION_PATH, INIT_MIGRATION]
|
|
8161
|
+
],
|
|
8162
|
+
hint: "Remove --server, or move the existing file."
|
|
8163
|
+
};
|
|
8164
|
+
}
|
|
8165
|
+
return null;
|
|
8166
|
+
}
|
|
7697
8167
|
async function scaffold(dir, options) {
|
|
8168
|
+
if (options.server && options.starter) throw new CliError(STARTER_CONFLICT);
|
|
8169
|
+
const kit = moduleKit(options);
|
|
7698
8170
|
const written = [];
|
|
7699
8171
|
await mkdir3(dir, { recursive: true });
|
|
7700
|
-
if (
|
|
7701
|
-
const
|
|
7702
|
-
|
|
7703
|
-
|
|
7704
|
-
|
|
7705
|
-
|
|
7706
|
-
|
|
7707
|
-
|
|
7708
|
-
|
|
7709
|
-
|
|
7710
|
-
|
|
7711
|
-
|
|
7712
|
-
|
|
7713
|
-
|
|
8172
|
+
if (kit !== null) {
|
|
8173
|
+
for (const [name] of kit.files) {
|
|
8174
|
+
if (name === INIT_MIGRATION_PATH) continue;
|
|
8175
|
+
try {
|
|
8176
|
+
await readFile3(join5(dir, name));
|
|
8177
|
+
throw new CliError(`${name} already exists in ${dir}.`, kit.hint);
|
|
8178
|
+
} catch (error) {
|
|
8179
|
+
if (error instanceof CliError) throw error;
|
|
8180
|
+
}
|
|
8181
|
+
}
|
|
8182
|
+
await refuseInitMigrations(dir, kit.flag);
|
|
8183
|
+
for (const [name, text] of kit.files) {
|
|
8184
|
+
await mkdir3(dirname3(join5(dir, name)), { recursive: true });
|
|
8185
|
+
await writeFile2(join5(dir, name), text, { encoding: "utf8", flag: "wx" });
|
|
8186
|
+
written.push(name);
|
|
8187
|
+
}
|
|
7714
8188
|
const pkg = join5(dir, "package.json");
|
|
7715
8189
|
if (await readFile3(pkg, "utf8").catch(() => null) === null) {
|
|
7716
8190
|
await writeFile2(pkg, `${JSON.stringify({ name: options.app, private: true, type: "module" }, null, 2)}
|
|
@@ -7724,8 +8198,9 @@ async function scaffold(dir, options) {
|
|
|
7724
8198
|
dir: ".",
|
|
7725
8199
|
...options.server ? { server: "server.mjs" } : {}
|
|
7726
8200
|
};
|
|
7727
|
-
|
|
7728
|
-
|
|
8201
|
+
const manifestText = kit?.flag === "--starter" ? starterManifest(options.app, options.org) : `${JSON.stringify(manifest, null, 2)}
|
|
8202
|
+
`;
|
|
8203
|
+
await writeFile2(join5(dir, MANIFEST_NAME), manifestText, "utf8");
|
|
7729
8204
|
const gitignore = join5(dir, ".gitignore");
|
|
7730
8205
|
const existing = await readFile3(gitignore, "utf8").catch(() => "");
|
|
7731
8206
|
const wanted = [".wirl/", ".env"].filter((line) => !existing.split("\n").includes(line));
|
|
@@ -7994,7 +8469,7 @@ async function callApi(creds, path, options = {}) {
|
|
|
7994
8469
|
const body = parsed ?? {};
|
|
7995
8470
|
const refusal = apiRefusal(parsed, response);
|
|
7996
8471
|
if (response.status === 403 && body.error === "insufficient_scope" && body.message) {
|
|
7997
|
-
throw new CliError(body.message,
|
|
8472
|
+
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
8473
|
}
|
|
7999
8474
|
if (response.status === 403 && (body.error === "person_only" || body.error === "bad_confirmation") && body.message) {
|
|
8000
8475
|
throw new CliError(body.message, void 0, refusal);
|
|
@@ -8195,6 +8670,11 @@ function manifestWarningLines(manifest) {
|
|
|
8195
8670
|
const parsed = parseManifest(manifest);
|
|
8196
8671
|
return parsed.ok ? parsed.warnings.map((warning) => `Warning: ${warning}`) : [];
|
|
8197
8672
|
}
|
|
8673
|
+
function manifestVisibility(manifest) {
|
|
8674
|
+
if (manifest === null || manifest === void 0) return void 0;
|
|
8675
|
+
const parsed = parseManifest(manifest);
|
|
8676
|
+
return parsed.ok ? parsed.manifest.visibility : void 0;
|
|
8677
|
+
}
|
|
8198
8678
|
async function prepareDeployBundle(dir, manifest, door, projectDir) {
|
|
8199
8679
|
let server;
|
|
8200
8680
|
let rules = NO_MANIFEST;
|
|
@@ -8401,6 +8881,10 @@ function formatOrgLine(org) {
|
|
|
8401
8881
|
return org.domain ? `${head} ${org.domain}: new apps start visible to everyone in it` : `${head} no company domain: new apps start private`;
|
|
8402
8882
|
}
|
|
8403
8883
|
var whoami = (creds) => callApi(creds, "/api/v1/me");
|
|
8884
|
+
function signedInSentence(me) {
|
|
8885
|
+
const gave = (me.scope ?? "full") === "full" ? "full access" : "build-and-update";
|
|
8886
|
+
return `Signed in (${gave}). Organisations: ${me.orgs.map((o) => o.slug).join(", ") || "none yet"}.`;
|
|
8887
|
+
}
|
|
8404
8888
|
function chooseOrg(orgs, given, hint = 'Pass --org <slug>, or add "org" to wirl.json.') {
|
|
8405
8889
|
const named = (given ?? "").trim();
|
|
8406
8890
|
if (named) return named;
|
|
@@ -8414,13 +8898,25 @@ async function resolveAppsOrg(creds, orgFlag, manifestOrg) {
|
|
|
8414
8898
|
return chooseOrg((await whoami(creds)).orgs, null);
|
|
8415
8899
|
}
|
|
8416
8900
|
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`, {
|
|
8901
|
+
var createRemoteApp = (creds, org, slug, name, visibility) => callApi(creds, `/api/v1/orgs/${seg(org)}/apps`, {
|
|
8418
8902
|
method: "POST",
|
|
8419
|
-
|
|
8903
|
+
// `visibility` rides along only when wirl.json said it: absent, the
|
|
8904
|
+
// org's default holds. It applies to the app this create makes and to
|
|
8905
|
+
// no other: an app that exists answers 409 and is left as it is.
|
|
8906
|
+
body: JSON.stringify({ slug, name: name ?? slug, ...visibility === void 0 ? {} : { visibility } }),
|
|
8420
8907
|
contentType: "application/json"
|
|
8421
8908
|
});
|
|
8422
|
-
async function ensureRemoteApp(creds, org, app) {
|
|
8423
|
-
return createRemoteApp(creds, org, app).then(() =>
|
|
8909
|
+
async function ensureRemoteApp(creds, org, app, visibility) {
|
|
8910
|
+
return createRemoteApp(creds, org, app, void 0, visibility).then((created) => {
|
|
8911
|
+
const made = created.app?.visibility;
|
|
8912
|
+
if (visibility === "private" && typeof made === "string" && made !== "private") {
|
|
8913
|
+
process.stderr.write(
|
|
8914
|
+
`${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}`)}
|
|
8915
|
+
`
|
|
8916
|
+
);
|
|
8917
|
+
}
|
|
8918
|
+
return true;
|
|
8919
|
+
}, (error) => {
|
|
8424
8920
|
if (!(error instanceof CliError)) throw error;
|
|
8425
8921
|
if (error.fields["code"] === "app_deleted") throw deletedNameError(error, app);
|
|
8426
8922
|
if (error.fields["code"] === "app_exists" || error.fields["code"] === "app_moved") throw takenNameError(error, app);
|
|
@@ -8466,7 +8962,7 @@ function deletedAppMessage(org, app, done) {
|
|
|
8466
8962
|
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
8963
|
}
|
|
8468
8964
|
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`;
|
|
8965
|
+
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
8966
|
}
|
|
8471
8967
|
var VISIBILITY_LEVELS = ["private", "org", "public"];
|
|
8472
8968
|
function describeVisibility(level, org) {
|
|
@@ -9973,6 +10469,7 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
|
|
|
9973
10469
|
"no-skill",
|
|
9974
10470
|
"print",
|
|
9975
10471
|
"server",
|
|
10472
|
+
"starter",
|
|
9976
10473
|
"force",
|
|
9977
10474
|
"errors",
|
|
9978
10475
|
"follow",
|
|
@@ -9994,7 +10491,7 @@ function commandFlags(listed2, everywhere = true) {
|
|
|
9994
10491
|
}
|
|
9995
10492
|
var COMMAND_FLAGS = /* @__PURE__ */ new Map([
|
|
9996
10493
|
["login", commandFlags({ values: ["token", "api"] })],
|
|
9997
|
-
["init", commandFlags({ booleans: ["server", "no-skill"] })],
|
|
10494
|
+
["init", commandFlags({ booleans: ["server", "starter", "no-skill"] })],
|
|
9998
10495
|
["skill", commandFlags({ booleans: ["print"] })],
|
|
9999
10496
|
// Only what it lists, as DEV_FLAGS in bin.ts does (OIM-887): `--json` and `--app` were always refused here.
|
|
10000
10497
|
["dev", commandFlags({ values: ["user", "name", "role", "org", "port"], booleans: ["verbose"] }, false)],
|
|
@@ -10988,6 +11485,7 @@ var USAGE = `wirl \u2014 deploy an app somewhere real, behind a sign-in
|
|
|
10988
11485
|
|
|
10989
11486
|
wirl login Sign in from this terminal (or --token wirl_...)
|
|
10990
11487
|
wirl init [dir] [--server] Create wirl.json, and a working server module with --server
|
|
11488
|
+
wirl init [dir] --starter Write your first app: a small team noticeboard, ready to deploy
|
|
10991
11489
|
wirl skill [dir] [--print] Write the skill file a coding agent reads (init does this too; --no-skill to skip)
|
|
10992
11490
|
wirl dev [dir] Run the app here, signed in as a stub user
|
|
10993
11491
|
wirl deploy [dir] Build, scan and deploy (creates the app if new)
|
|
@@ -11051,14 +11549,18 @@ var HELP = {
|
|
|
11051
11549
|
example: "wirl login"
|
|
11052
11550
|
},
|
|
11053
11551
|
init: {
|
|
11054
|
-
usage: ["wirl init [dir] [--server] [--app <name>] [--org <slug>] [--no-skill]"],
|
|
11552
|
+
usage: ["wirl init [dir] [--server|--starter] [--app <name>] [--org <slug>] [--no-skill]"],
|
|
11055
11553
|
flags: [
|
|
11056
11554
|
{ flag: "--app <name>", desc: "App slug. Defaults to the directory name." },
|
|
11057
11555
|
{ flag: "--org <slug>", desc: "Organisation to put in wirl.json." },
|
|
11058
11556
|
{ flag: "--server", desc: "Also write server.mjs, a working backend to build on." },
|
|
11557
|
+
{
|
|
11558
|
+
flag: "--starter",
|
|
11559
|
+
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."
|
|
11560
|
+
},
|
|
11059
11561
|
{ flag: "--no-skill", desc: "Skip writing the skill file a coding agent reads." }
|
|
11060
11562
|
],
|
|
11061
|
-
example: "wirl init my-app --server"
|
|
11563
|
+
example: "wirl init --starter\n wirl init my-app --server"
|
|
11062
11564
|
},
|
|
11063
11565
|
skill: {
|
|
11064
11566
|
usage: ["wirl skill [dir] [--print]"],
|
|
@@ -11502,6 +12004,13 @@ async function resolveTarget(creds, args2, dir) {
|
|
|
11502
12004
|
return { org: String(org), app };
|
|
11503
12005
|
}
|
|
11504
12006
|
var args = parseArgs(process.argv.slice(2).map((token) => token === "-h" ? "--help" : token));
|
|
12007
|
+
async function starterFolder() {
|
|
12008
|
+
const creds = await loadCredentials().catch(() => null);
|
|
12009
|
+
if (creds === null) return starterName(null);
|
|
12010
|
+
const me = await whoami(creds).catch(() => null);
|
|
12011
|
+
if (me === null) err("Could not read your account, so the starter is named starter.");
|
|
12012
|
+
return starterName(me?.email ?? null);
|
|
12013
|
+
}
|
|
11505
12014
|
async function main() {
|
|
11506
12015
|
const json2 = args.flags.has("json");
|
|
11507
12016
|
if (process.argv[2] === "--version" || process.argv[2] === "-v") {
|
|
@@ -11542,23 +12051,28 @@ async function main() {
|
|
|
11542
12051
|
const creds2 = await pollDeviceLogin(apiOrigin, login);
|
|
11543
12052
|
const path = await saveCredentials(creds2);
|
|
11544
12053
|
const me = await whoami(creds2);
|
|
11545
|
-
out(
|
|
12054
|
+
out(signedInSentence(me));
|
|
11546
12055
|
out(`Saved to ${path}`);
|
|
11547
12056
|
return 0;
|
|
11548
12057
|
}
|
|
11549
12058
|
if (args.command === "init") {
|
|
11550
12059
|
refuseBadFlags(args);
|
|
11551
12060
|
refuseExtraPositionals("init", args.positional, 1, "one folder", { hint: folderHint("The app's name goes after --app <name>.") });
|
|
11552
|
-
const
|
|
12061
|
+
const starter = args.flags.has("starter");
|
|
12062
|
+
if (starter && args.flags.has("server")) throw new CliError(STARTER_CONFLICT);
|
|
12063
|
+
const dir = resolve6(args.positional[0] ?? (starter ? await starterFolder() : "."));
|
|
11553
12064
|
const app = String(args.flags.get("app") ?? basename4(dir));
|
|
11554
12065
|
const written = await scaffold(dir, {
|
|
11555
12066
|
app,
|
|
11556
12067
|
...args.flags.has("org") ? { org: String(args.flags.get("org")) } : {},
|
|
11557
12068
|
server: args.flags.has("server"),
|
|
12069
|
+
starter,
|
|
11558
12070
|
skill: !args.flags.has("no-skill")
|
|
11559
12071
|
});
|
|
11560
12072
|
for (const file of written) out(`Wrote ${file}`);
|
|
11561
12073
|
if (args.flags.has("server")) out("Deploy it with: wirl deploy");
|
|
12074
|
+
const folder = relative3(process.cwd(), dir);
|
|
12075
|
+
if (starter) out(`Deploy it with: ${folder === "" ? "" : `cd ${shellPath(folder)} && `}wirl deploy`);
|
|
11562
12076
|
return 0;
|
|
11563
12077
|
}
|
|
11564
12078
|
if (args.command === "skill") {
|
|
@@ -11664,7 +12178,7 @@ async function main() {
|
|
|
11664
12178
|
const upload = { dir, projectDir: project.manifestDir, org: target.org, app: target.app, ...manifest === null ? {} : { manifest } };
|
|
11665
12179
|
const prepared = await prepareUpload(dir, upload.manifest, "wirl deploy", upload.projectDir);
|
|
11666
12180
|
for (const line of prepared.notices) err(line);
|
|
11667
|
-
const made = !args.flags.has("no-create") && await ensureRemoteApp(creds, target.org, target.app);
|
|
12181
|
+
const made = !args.flags.has("no-create") && await ensureRemoteApp(creds, target.org, target.app, manifestVisibility(manifest));
|
|
11668
12182
|
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
12183
|
const result = await deploy(creds, { ...upload, prepared, ...terminal === void 0 ? {} : { terminal } }).catch((error) => {
|
|
11670
12184
|
throw withMadeApp(error, made, "wirl deploy", target.app);
|
|
@@ -11725,7 +12239,7 @@ async function main() {
|
|
|
11725
12239
|
const upload = { dir, projectDir: project.manifestDir, org: target.org, app: target.app, name: resolved, ...manifest === null ? {} : { manifest } };
|
|
11726
12240
|
const prepared = await prepareUpload(dir, upload.manifest, "wirl preview", upload.projectDir);
|
|
11727
12241
|
for (const line of prepared.notices) err(line);
|
|
11728
|
-
const made = !args.flags.has("no-create") && await ensureRemoteApp(creds, target.org, target.app);
|
|
12242
|
+
const made = !args.flags.has("no-create") && await ensureRemoteApp(creds, target.org, target.app, manifestVisibility(manifest));
|
|
11729
12243
|
const result = await deployPreview(creds, { ...upload, prepared }).catch((error) => {
|
|
11730
12244
|
throw withMadeApp(error, made, "wirl preview", target.app);
|
|
11731
12245
|
});
|