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.
Files changed (2) hide show
  1. package/bin.js +553 -39
  2. 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.2" : null;
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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 --server again.`);
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 --server again.`);
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 --server writes ${INIT_MIGRATION_PATH} into a folder of that name. Move it aside, then run wirl init --server again.`
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 --server writes ${INIT_MIGRATION_PATH} into a folder with none.`);
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 (options.server) {
7701
- const target = join5(dir, "server.mjs");
7702
- try {
7703
- await readFile3(target);
7704
- throw new CliError(`server.mjs already exists in ${dir}.`, "Remove --server, or move the existing file.");
7705
- } catch (error) {
7706
- if (error instanceof CliError) throw error;
7707
- }
7708
- await refuseInitMigrations(dir);
7709
- await writeFile2(target, SERVER_TEMPLATE, "utf8");
7710
- written.push("server.mjs");
7711
- await mkdir3(join5(dir, MIGRATIONS_DIR), { recursive: true });
7712
- await writeFile2(join5(dir, MIGRATIONS_DIR, ADOPTED_FILE), INIT_MIGRATION, { encoding: "utf8", flag: "wx" });
7713
- written.push(INIT_MIGRATION_PATH);
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
- await writeFile2(join5(dir, MANIFEST_NAME), `${JSON.stringify(manifest, null, 2)}
7728
- `, "utf8");
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, "Ask the person to do it in the dashboard, or reconnect with full access.", refusal);
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
- body: JSON.stringify({ slug, name: name ?? slug }),
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(() => true, (error) => {
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(`Signed in. Organisations: ${me.orgs.map((o) => o.slug).join(", ") || "none yet"}.`);
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 dir = resolve6(args.positional[0] ?? ".");
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wirl",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Deploy an internal app somewhere real, privately. One command; sharing by email; API keys declared, never pasted.",
5
5
  "keywords": [
6
6
  "deploy",