@lampdotdev/lamp 0.35.0 → 0.36.2-nightly.17

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/cli/lamp.mjs CHANGED
@@ -10,6 +10,8 @@
10
10
  // lamp use [folder] … try it: a key, a click, some typing
11
11
  // lamp stop [folder] stop running it
12
12
  // lamp share [folder] a link on lamp.dev to play it, and lamp ideas for what people send back
13
+ // lamp play <link> play an app shared on lamp.dev here, its AI answered on this computer
14
+ // lamp ai the AI of pages shared on lamp.dev, played here, from the person's own Claude Code, Codex or OpenRouter key
13
15
  // lamp devices the person's devices; lamp open --on xbox, or --on android, puts a page on one
14
16
  // lamp setup teach the coding agents on this computer to work this way
15
17
  // lamp skills Lamp's guidance for each kind of work; lamp skill <name> reads one
@@ -21,12 +23,18 @@ import { basename, dirname, join, resolve, sep } from "node:path";
21
23
  import { fileURLToPath } from "node:url";
22
24
  import { Problem, RESOURCES, layoutOf, manifestOf, projectOf, stateOf, running, ask } from "./state.mjs";
23
25
  import { macActions, serveMac, writeMacStarter } from "./mac.mjs";
24
- import { exportApp, unpack } from "./lampapp.mjs";
25
- import { updateNotice } from "./update.mjs";
26
- import { editionName, giveTo, givenTo, keepPictures, reachLamp, takeBack } from "./outside.mjs";
27
- import { serve, ANY_BROWSER, CONTROLLERS, CONTROLLERS_FILE, DEVICE_FILE, lampJS } from "./dev.mjs";
26
+ import { MAX_ARCHIVE, exportApp, unpack } from "./lampapp.mjs";
27
+ import { latestOf } from "./link.mjs";
28
+ import { updateNotice, versionLine } from "./update.mjs";
29
+ import { editionName, giveTo, givenTo, keepInLamp, keepPictures, lampOwning, lampsKeptLink, reachLamp, takeBack } from "./outside.mjs";
30
+ import { serve, ANY_BROWSER, CONTROLLERS, CONTROLLERS_FILE, DEVICE_FILE, LAMP_JS_STAND_IN, lampJS } from "./dev.mjs";
31
+ import { findBrowser, noBrowser } from "./browser.mjs";
28
32
  import { allSkills } from "./skills.mjs";
29
- import { share, unshare, ideas, finishIdea, readLink, nameOf, siteOrigin, LIMITS } from "./share.mjs";
33
+ import { engine } from "./ai.mjs";
34
+ import { allowed, forgetAllowed, serveLocalAI } from "./local-ai.mjs";
35
+ import { accountFor, keepAccount } from "./share.mjs";
36
+ import { accountName, askAccount } from "./link.mjs";
37
+ import { share, unshare, ideas, finishIdea, answerIdea, makerEmail, readLink, writeLink, forgetLink, nameOf, siteOrigin, LIMITS } from "./share.mjs";
30
38
  import { Runner, allKept, fields, forget, integrationOf, isReady, look as lookForDevices, takesPages } from "./devices.mjs";
31
39
  import { placeNamed } from "./place.mjs";
32
40
  import { androidAdvice, androidDevices, androidDone, emulatorNamed, emulators, forgetAndroid, installTools, knowsAndroid, toolsThere } from "./android.mjs";
@@ -91,6 +99,9 @@ Mac app Lamp made in Swift, built and run on this Mac.
91
99
  lamp reload [folder] [--fresh]
92
100
  Open the page again from its files, a game from where the player is, or with --fresh from its beginning.
93
101
  A Mac app is quit and opened again.
102
+ lamp changing [folder] <words>
103
+ Say what you're about to change, in a few words from the person's request ("Rain on the roads"): the open
104
+ page shows it on the card it puts up as the change goes in, for the next few minutes of changes.
94
105
  lamp stop [folder] [--on <device>]
95
106
  Stop running it. A Mac app is quit. --on ends it on a device instead (and closes an emulator lamp started
96
107
  with no window).
@@ -108,10 +119,33 @@ Mac app Lamp made in Swift, built and run on this Mac.
108
119
  person asks. Again, it sends the page as it is now to the same link, with the same words unless new ones are given.
109
120
  Its app file goes with it (as lamp export makes it), so anyone can open it in Lamp too; --no-file leaves it out.
110
121
  --anyway shares a page that logged errors, or uses work that came with no license.
122
+ An app of Lamp's (its folder is in Lamp's own, or a link to it) is shared by Lamp's app, which must be running,
123
+ as its Share Link card shares it: the card's own words, the questions and what's new Lamp writes, the app's
124
+ file and its GitHub release, with --line, --by, --asks "a|b", --notes "What's new|And more", --no-file,
125
+ --no-github and --put-on-github in their place. --preview says what would go and sends nothing. Its link
126
+ stays Lamp's; --new-link has lamp make one of its own instead.
111
127
  lamp unshare [folder] Take the link down.
112
- lamp ideas [folder] [in <n> | dismiss <n>]
113
- The ideas people playing the link have sent. "in" says one is in the game now (whoever asked to hear is
114
- written to); "dismiss" lets one go.
128
+ lamp ideas [folder] [--earlier | in <n> | dismiss <n> | reply <n> | done <n> | email <address|off>] [note]
129
+ The ideas people playing the link have sent, with a picture of the app as they saw it when they kept one
130
+ in, and what's been said about each since. "in" says one is in the game now (whoever asked to hear is
131
+ written to); "dismiss" lets one go. Words after the number are a note to whoever sent it, on the link's
132
+ page and in that email; they can answer it by email. "reply" says something more, and "done" leaves
133
+ their answer without one. --earlier lists those seen to, to look back on or answer again. "email"
134
+ sends new ideas and replies to your own address too (once you confirm it), and you can answer from there.
135
+ lamp account [username] [--email you@example.com]
136
+ Your account on lamp.dev, which your links go under (lamp.dev/<username>/<app>): with a username and an email,
137
+ asks for it, and an email's link confirms it; with only your email, signs in to the one you have. On its own,
138
+ says which you're signed in as.
139
+ lamp play <link> [--to folder] [--newest]
140
+ Play an app shared on lamp.dev on this computer: its file, unpacked into a folder of its own in
141
+ ~/Lamp Games (or where --to says), run and opened as lamp dev runs any page, so its AI is answered here in any
142
+ browser, Safari too. The same link again plays the same folder; --newest takes the link's latest version
143
+ into a new one.
144
+ lamp ai [--forget]
145
+ Answer the AI of pages shared on lamp.dev, played in a browser on this computer, with your own Claude Code
146
+ or Codex, or OpenRouter with your key in OPENROUTER_API_KEY, until stopped. Lamp does the same while it's
147
+ open, with the AI you set up there. Each page asks here once, and you allow it or not. --forget takes back
148
+ every page allowed, so each asks again.
115
149
  lamp setup [--remove] Teach Claude Code and Codex on this computer to work this way (a skill for each).
116
150
  lamp guide What that skill says, for any other agent.
117
151
  lamp skills [word] Lamp's guidance for each kind of work, those about a word; lamp skill <name> reads one.
@@ -130,7 +164,8 @@ const SKILL_MARK = "<!-- Written by lamp setup: run it again to update, or with
130
164
  function lampCommand() {
131
165
  const me = fileURLToPath(import.meta.url);
132
166
  // The copy in Lamp's app is run by its launcher beside it, on the app's own runtime, and updates with Lamp.
133
- if (process.env.LAMP_FROM_APP === "1") { const launcher = join(here, "lamp"); return /\s/.test(launcher) ? JSON.stringify(launcher) : launcher; }
167
+ // A newer copy fetched from npm is run by that same launcher (lamp.sh names it): the app's, never one beside this.
168
+ if (process.env.LAMP_FROM_APP === "1") { const launcher = process.env.LAMP_LAUNCHER_PATH || join(here, "lamp"); return /\s/.test(launcher) ? JSON.stringify(launcher) : launcher; }
134
169
  const found = spawnSync(process.platform === "win32" ? "where" : "which", ["lamp"], { encoding: "utf8" });
135
170
  const onPath = found.status === 0 && found.stdout.trim().split("\n").some((path) => { try { return realpathSync(path.trim()) === realpathSync(me); } catch { return false; } });
136
171
  return onPath ? "lamp" : `node ${/\s/.test(me) ? JSON.stringify(me) : me}`;
@@ -155,7 +190,8 @@ const quoted = (path) => (/\s/.test(path) ? JSON.stringify(path) : path);
155
190
  * Lamp's own files beside a page, which its app writes at every build and a shared file never carries: for a game the
156
191
  * runtime, the controllers' list and the device it's on; for any page lamp.js, Lamp's building blocks (a stand-in where
157
192
  * Lamp's own isn't beside lamp, never over one); and Lamp's libraries its import map names from beside it. Only those:
158
- * the page and its code are the project's. `update` writes the game's again over what's there.
193
+ * the page and its code are the project's. `update` writes again those of them the folder has (lamp.js too), and adds
194
+ * the game's only to a game: a page that never had them gets none.
159
195
  */
160
196
  function lampsFilesBeside(root, { game, update = false }) {
161
197
  const runtime = join(RESOURCES, "GameKit", "web");
@@ -173,10 +209,13 @@ function lampsFilesBeside(root, { game, update = false }) {
173
209
  wrote.push(name);
174
210
  }
175
211
  }
176
- if (!existsSync(join(root, "lamp.js"))) {
177
- writeFileSync(join(root, "lamp.js"), lampJS(game));
212
+ // Updating, only over Lamp's own: a copy of lamp with only the stand-in (a checkout whose generated files aren't
213
+ // written) never replaces a page's working lamp.js with it.
214
+ const js = lampJS(game);
215
+ if (!existsSync(join(root, "lamp.js")) || (update && js !== LAMP_JS_STAND_IN)) {
216
+ writeFileSync(join(root, "lamp.js"), js);
178
217
  wrote.push("lamp.js");
179
- }
218
+ } else if (update) kept.push("lamp.js");
180
219
  wrote.push(...addLibraries(root));
181
220
  return { wrote, kept };
182
221
  }
@@ -216,6 +255,26 @@ function parse(args, valued) {
216
255
  return { options, rest };
217
256
  }
218
257
 
258
+ /**
259
+ * Who a link says it's by when nothing says, as Lamp's Share Link card fills it (`WishLink.sharer`): the name last given
260
+ * with `--by`, else the one Lamp's app last shared with, else this computer's account name. The share says it, and
261
+ * `--by` changes it.
262
+ */
263
+ function sharer() {
264
+ try { const kept = readFileSync(sharerFile(), "utf8").trim(); if (kept) return kept; } catch {}
265
+ for (const bundle of ["dev.lamp.host", "dev.lamp.host.nightly"]) {
266
+ const said = spawnSync("/usr/bin/defaults", ["read", bundle, "wishLinkSharer"], { encoding: "utf8" });
267
+ if (said.status === 0 && said.stdout.trim()) return said.stdout.trim();
268
+ }
269
+ const name = spawnSync("/usr/bin/id", ["-F"], { encoding: "utf8" });
270
+ return name.status === 0 ? [...name.stdout.trim()].slice(0, LIMITS.by).join("") : "";
271
+ }
272
+ const sharerFile = () => join(process.env.LAMP_DEV_HOME || join(homedir(), ".lamp", "dev"), "sharer");
273
+ function keepSharer(name) {
274
+ mkdirSync(dirname(sharerFile()), { recursive: true });
275
+ writeFileSync(sharerFile(), name);
276
+ }
277
+
219
278
  /** The server for the folder: the one running, or one started for this command that ends when nothing uses it. */
220
279
  async function serverFor(folder) {
221
280
  const live = await running(folder);
@@ -392,7 +451,7 @@ const WHERE = {
392
451
  mac: "the app as it runs on this Mac",
393
452
  window: "the window the person has open",
394
453
  copy: "lamp's own copy of the page, which nobody sees (no window is open)",
395
- page: "a page open in a browser lamp can't look into",
454
+ page: "a page open in a browser lamp can't take pictures of or play",
396
455
  };
397
456
 
398
457
  function report(seen) {
@@ -437,6 +496,34 @@ async function inLamp(layout) {
437
496
  return lamp;
438
497
  }
439
498
 
499
+ /**
500
+ * A folder whose link is Lamp's app's, not lamp's: one of Lamp's apps that lamp has no link of its own for. Its ideas
501
+ * and taking it down are on Lamp's Share Link card, said so rather than "no link".
502
+ */
503
+ function lampsLink(folder) {
504
+ let real = resolve(folder);
505
+ try { real = realpathSync(real); } catch {}
506
+ const owner = lampOwning(real);
507
+ if (!owner) return null;
508
+ // The link Lamp keeps for its app is lamp's to use too, Lamp open or not.
509
+ lampsCopy(real);
510
+ return readLink(real) ? null : editionName(owner);
511
+ }
512
+
513
+ /**
514
+ * For an app in a Lamp's own folder: brings lamp's copy of its link in step with the one that Lamp keeps, which is the
515
+ * link's own record (the person may have changed its words, or stopped sharing and made a new one, in Lamp since).
516
+ * False when the folder has a link of lamp's own instead (`share --new-link`), which is left as it is.
517
+ */
518
+ function lampsCopy(folder) {
519
+ const mine = readLink(folder);
520
+ if (mine && !mine.fromLamp) return false;
521
+ const kept = lampsKeptLink(folder);
522
+ if (kept) writeLink(folder, { ...(mine?.id === kept.id ? mine : {}), ...kept, fromLamp: true });
523
+ else if (mine) forgetLink(folder);
524
+ return true;
525
+ }
526
+
440
527
  /** What Lamp's app answered, as lamp says things: how the change went in, its words, and where its pictures are. */
441
528
  function lampReport(folder, lamp, answer) {
442
529
  const lines = [`${lamp.name}: ${answer.outcome ?? "answered"}.`];
@@ -457,18 +544,26 @@ function letGo(layout, lamp, answer) {
457
544
  return true;
458
545
  }
459
546
 
547
+ /** What the agent last said it's changing in a folder Lamp's app has (`lamp changing`), for half an hour after. */
548
+ function changingFor(folder) {
549
+ try {
550
+ const said = JSON.parse(readFileSync(join(stateOf(folder), "changing.json"), "utf8"));
551
+ return typeof said.words === "string" && Date.now() - said.at < 30 * 60_000 ? { words: said.words } : {};
552
+ } catch { return {}; }
553
+ }
554
+
460
555
  /**
461
556
  * Asks Lamp's app to put what the folder holds into the app (`reopen`: opened again for it), and says how it went.
462
557
  * False when Lamp no longer has the folder: the caller runs its own copy instead.
463
558
  */
464
559
  async function applyInLamp(layout, lamp, { reopen = false } = {}) {
465
560
  try {
466
- let answer = await lamp.call("apply_change", { path: layout.folder, ...(reopen ? { reopen: true } : {}) });
561
+ let answer = await lamp.call("apply_change", { path: layout.folder, ...(reopen ? { reopen: true } : {}), ...changingFor(layout.folder) });
467
562
  if (letGo(layout, lamp, answer)) return false;
468
563
  // Lamp started again since the last apply: it asks for a look at the app as it is first, and then takes the change.
469
564
  if (answer.facts?.restarted) {
470
565
  await lamp.call("look_at_app", { path: layout.folder });
471
- answer = await lamp.call("apply_change", { path: layout.folder, ...(reopen ? { reopen: true } : {}) });
566
+ answer = await lamp.call("apply_change", { path: layout.folder, ...(reopen ? { reopen: true } : {}), ...changingFor(layout.folder) });
472
567
  }
473
568
  // Nothing new to put in brings no picture: the app as it is now, which the person may have used since, is looked at.
474
569
  if (!answer.pictures.length && ["unchanged", "held"].includes(answer.outcome)) {
@@ -511,8 +606,8 @@ async function devInLamp(layout, lamp) {
511
606
  try {
512
607
  lamp = lamp ?? (await reachLamp([givenTo(layout.folder)]));
513
608
  if (!lamp) { print(`${editionName(givenTo(layout.folder))} isn't running: the change goes in once it is and the next save is made.`); return; }
514
- let answer = await lamp.call("apply_change", { path: layout.folder });
515
- if (answer.facts?.restarted) { await lamp.call("look_at_app", { path: layout.folder }); answer = await lamp.call("apply_change", { path: layout.folder }); }
609
+ let answer = await lamp.call("apply_change", { path: layout.folder, ...changingFor(layout.folder) });
610
+ if (answer.facts?.restarted) { await lamp.call("look_at_app", { path: layout.folder }); answer = await lamp.call("apply_change", { path: layout.folder, ...changingFor(layout.folder) }); }
516
611
  if (letGo(layout, lamp, answer)) { stopWatching(false); return; }
517
612
  // A folder that changed while Lamp built it is applied again by the next save, or now when it's said so.
518
613
  // A folder that changed while Lamp built it goes in again now, saved or not.
@@ -555,6 +650,7 @@ async function devInLamp(layout, lamp) {
555
650
  async function main() {
556
651
  const [command, ...args] = process.argv.slice(2);
557
652
  if (!command || command === "--help" || command === "-h" || command === "help") { console.log(USAGE); return; }
653
+ if (command === "--version" || command === "-v" || command === "version") { console.log(versionLine()); return; }
558
654
 
559
655
  if (command === "new") {
560
656
  const { options, rest } = parse(args, ["name"]);
@@ -653,10 +749,12 @@ async function main() {
653
749
  if (command === "init") {
654
750
  const { options, rest } = parse(args, []);
655
751
  const layout = layoutOf(rest[0] ?? ".");
656
- const { wrote, kept } = lampsFilesBeside(layout.root, { game: true, update: !!options.update });
752
+ // Updating, a page is a game only if it has the runtime already; setting one up, it becomes one.
753
+ const game = options.update ? existsSync(join(layout.root, "lamp-game.js")) : true;
754
+ const { wrote, kept } = lampsFilesBeside(layout.root, { game, update: !!options.update });
657
755
  console.log(wrote.length ? `Lamp's own files are beside the page in ${layout.root}: ${wrote.join(", ")}.` : `Lamp's own files were beside the page already in ${layout.root}.`);
658
756
  if (kept.length) console.log(`Left as they were: ${kept.join(", ")} (lamp init --update writes them again).`);
659
- console.log(`The game takes the runtime up in its own code. Lamp's game-live skill says how (${lampCommand()} skill game-live), and lamp-game.js's own comments are the whole interface.`);
757
+ if (game) console.log(`The game takes the runtime up in its own code. Lamp's game-live skill says how (${lampCommand()} skill game-live), and lamp-game.js's own comments are the whole interface.`);
660
758
  return;
661
759
  }
662
760
 
@@ -790,7 +888,7 @@ async function main() {
790
888
  console.log(opened.running ? `${opened.url} is open.` : opened.building ? `${opened.url} is being built, and opens when it is: lamp look waits for it.` : `${opened.url} didn't build: lamp look says why.`);
791
889
  return;
792
890
  }
793
- console.log(opened.opened === "window" ? `Opened in a window: ${opened.url}` : `Opened in the browser: ${opened.url}\nlamp found no browser it can look into (Chrome, Edge, Brave or Chromium), so looks have no picture.`);
891
+ console.log(opened.opened === "window" ? `Opened in a window: ${opened.url}` : `Opened in the browser: ${opened.url}\n${noBrowser()}`);
794
892
  return;
795
893
  }
796
894
 
@@ -819,20 +917,74 @@ async function main() {
819
917
  }
820
918
  }
821
919
  if (!any) console.log(options.remove ? "There was no skill of lamp's to take away." : "Neither Claude Code nor Codex is set up on this computer. `lamp guide` prints what to give another agent.");
920
+ // Web apps and games are looked at and played in a browser lamp drives: said now, before a first look comes back
921
+ // without a picture. Only said, never a failure: a Mac app needs no browser, and one can be installed later.
922
+ if (!options.remove && !findBrowser()) console.log(`For web apps and games: ${noBrowser()}`);
822
923
  return;
823
924
  }
824
925
 
825
926
  if (command === "share") {
826
- const { options, rest } = parse(args, ["name", "words", "line", "by", "asks", "shape"]);
927
+ const { options, rest } = parse(args, ["name", "words", "line", "by", "asks", "shape", "notes"]);
928
+ // An app of Lamp's, with no link of lamp's own: shared by Lamp's app, as its Share Link card shares it, filled as the
929
+ // card fills itself (its last words, the questions and what's new Lamp writes, its file, its GitHub), with what's
930
+ // given here in their place. Its link's secret stays in Lamp.
931
+ // By its folder alone: Lamp's app shares kinds lamp doesn't run itself (an iPhone app, a wish of several). Only a
932
+ // folder that's a Lamp's: one in its own folder, or one given to it (`lamp open --in-lamp`). Any other folder is
933
+ // lamp's to share, whatever Lamp is running.
934
+ const project = { folder: (() => { const given = resolve(rest[0] ?? "."); try { return realpathSync(given); } catch { return given; } })() };
935
+ const owner = lampOwning(project.folder), given = owner ? null : givenTo(project.folder);
936
+ const hasPage = (() => { try { layoutOf(project.folder); return true; } catch { return false; } })();
937
+ // An app with a page in a Lamp's own folder is lamp's to share, whether that Lamp is open or not, to the link the
938
+ // Lamp keeps for it, and its Share Link card is told what went (`keepInLamp`). Only what lamp doesn't pack itself
939
+ // (a Mac app, an iPhone app, a wish of several) goes through Lamp's app.
940
+ // A link of lamp's own (--new-link) is a new one, never lamp's copy of the link Lamp keeps.
941
+ if (options["new-link"] && readLink(project.folder)?.fromLamp) forgetLink(project.folder);
942
+ const lampsOwn = owner && hasPage && !options["new-link"] && lampsCopy(project.folder);
943
+ if (!lampsOwn && !readLink(project.folder) && !options["new-link"] && (owner || given)) {
944
+ const lamp = await reachLamp([owner ?? given]);
945
+ if (lamp) {
946
+ const list = (text) => String(text).split("|").map((item) => item.trim()).filter(Boolean);
947
+ const values = {
948
+ path: project.folder, ...(options.preview ? { preview: true } : {}),
949
+ ...(options.by !== undefined ? { by: String(options.by) } : {}),
950
+ ...(options.line ?? options.words) !== undefined ? { line: String(options.line ?? options.words) } : {},
951
+ ...(options.asks !== undefined ? { asks: list(options.asks) } : {}), ...(options.notes !== undefined ? { notes: list(options.notes) } : {}),
952
+ ...(options["no-file"] ? { file: false } : {}), ...(options["no-github"] ? { github: false } : {}),
953
+ ...(options["put-on-github"] ? { put_on_github: true } : {}),
954
+ };
955
+ console.log(options.preview ? `This is an app of ${lamp.name}'s: it shares it, to the link it has, as its Share Link card would.`
956
+ : `Sharing through ${lamp.name}, as its Share Link does…`);
957
+ let answer;
958
+ try { answer = await lamp.call("share_link", values); } finally { lamp.close(); }
959
+ // A Lamp from before it shared for lamp: an app of its own waits for it to be updated, since a link of lamp's
960
+ // would be a second one; a folder only given to it is lamp's to share.
961
+ // lamp shares only a page, so a Mac app given to it waits for Lamp too.
962
+ const older = /has no tool share_link\b/.test(answer.words ?? "");
963
+ if (older && (owner || !hasPage)) throw new Problem(`${lamp.name} is older than this lamp and can't share for it: update ${lamp.name}, then run this again${hasPage ? " (or --new-link to make lamp a link of its own)" : ""}.`);
964
+ if (older) console.log(`${lamp.name} can't share for lamp yet, so lamp shares it with a link of its own.`);
965
+ else if (answer.facts?.taken_on !== false) {
966
+ console.log(answer.words);
967
+ if (answer.failed) process.exitCode = 1;
968
+ return;
969
+ }
970
+ } else if (owner) {
971
+ throw new Problem(`${editionName(owner)} packs this kind of app for its link: open ${editionName(owner)}, then run this again.`);
972
+ }
973
+ }
827
974
  const layout = layoutOf(rest[0] ?? ".");
828
975
  const before = readLink(layout.folder)?.site === siteOrigin() ? readLink(layout.folder) : null;
829
976
  // The link's words: what's given now, else what it went out with before. Its name, where nothing says, is the page's.
830
977
  const words = {
831
978
  name: String(options.name ?? before?.name ?? nameOf(layout)).trim(), words: String(options.words ?? before?.words ?? "").trim(),
832
- line: String(options.line ?? before?.line ?? "").trim(), by: String(options.by ?? before?.by ?? "").trim(),
979
+ line: String(options.line ?? before?.line ?? "").trim(), by: String(options.by ?? before?.by ?? sharer()).trim(),
833
980
  asks: options.asks !== undefined ? String(options.asks).split("|").map((ask) => ask.trim()).filter(Boolean) : before?.asks ?? [],
834
981
  };
835
982
  const count = (text) => [...new Intl.Segmenter("en", { granularity: "grapheme" }).segment(text)].length;
983
+ if (options.preview) {
984
+ console.log(`Nothing was shared. This is what would go:\n${before ? `The link ${before.url}` : "A new link"} for "${words.name}"${words.by ? ` by ${words.by}` : ""}: ${words.line || words.words || "(no words yet: give --words)"}`);
985
+ console.log(`Questions its page asks: ${words.asks.length ? words.asks.join(" · ") : "the page's own"}\nThe app's file: ${options["no-file"] ? "doesn't go" : "goes with it"}`);
986
+ return;
987
+ }
836
988
  if (!words.words) throw new Problem('a link says what the page is: share it with --words "What it is, in a sentence or a few"');
837
989
  if (!words.name || count(words.name) > LIMITS.name) throw new Problem(`a link's name is at most ${LIMITS.name} characters: --name "Its Name"`);
838
990
  if (count(words.words) > LIMITS.words) throw new Problem(`a link's words are at most ${LIMITS.words} characters`);
@@ -851,39 +1003,174 @@ async function main() {
851
1003
  if (options.shape !== "fill" && !ratio) throw new Problem("--shape is fill, for a page that takes any window, or a shape like 16:9");
852
1004
  shape = ratio ? Math.round((Number(ratio[1]) / Number(ratio[2])) * 10000) / 10000 : "fill";
853
1005
  }
854
- const sent = await share({ layout, words, shape, picture: card.picture ? Buffer.from(card.picture, "base64") : null, said: (line) => console.log(line),
855
- file: !options["no-file"], anyway: !!options.anyway });
1006
+ // A name given is the one every later link says it's by, as Lamp's Share Link card keeps it: kept once the share is
1007
+ // checked and about to go, so a share refused leaves it as it was.
1008
+ if (options.by !== undefined) keepSharer(words.by);
1009
+ let sent;
1010
+ try {
1011
+ sent = await share({ layout, words, shape, picture: card.picture ? Buffer.from(card.picture, "base64") : null, said: (line) => console.log(line),
1012
+ file: !options["no-file"], anyway: !!options.anyway, fromLamp: !!lampsOwn });
1013
+ } finally {
1014
+ // Lamp is told of the link even when its page didn't go, so its card has the link that's out, not a second one.
1015
+ const link = lampsOwn ? readLink(layout.folder) : null;
1016
+ if (link) keepInLamp(layout.folder, link);
1017
+ }
856
1018
  console.log(`${before ? "Sent again to its link" : "Shared"}: ${sent.url}`);
857
- console.log(`"${words.name}"${words.by ? ` by ${words.by}` : ""}: ${sent.files} files, ${(sent.bytes / 1_000_000).toFixed(1)} MB packed, ${shape === "fill" ? "filling the window" : "on its own stage"}${sent.picture ? ", with a picture of it" : ", with no picture"}${sent.file ? ", and its app file, for anyone to open in Lamp" : ""}.`);
1019
+ console.log(`"${words.name}"${words.by ? ` by ${words.by}` : ""}: ${sent.files} files, ${(sent.bytes / 1_000_000).toFixed(1)} MB packed, ${shape === "fill" ? "filling the window" : "on its own stage"}${sent.picture ? ", with a picture of it" : ", with no picture"}${sent.file ? ", and its app file, for anyone to open in Lamp" : ""}${sent.github ? `, with a link to its code on GitHub (${sent.github})` : ""}.`);
858
1020
  console.log("Anyone with the link can play it and send an idea: lamp ideas shows them. lamp unshare takes it down.");
1021
+ if (words.by && options.by === undefined) console.log(`It says it's by ${words.by}; --by "Another Name" changes that, for this link and the next.`);
1022
+ if (/\/w\//.test(sent.url)) console.log(`${lampCommand()} account <username> --email you@example.com gives your links lamp.dev/<username>/<app>, this one at its next share.`);
859
1023
  return;
860
1024
  }
861
1025
 
862
1026
  if (command === "unshare") {
863
1027
  const { rest } = parse(args, []);
864
- console.log(`Taken down: ${await unshare(layoutOf(rest[0] ?? ".").folder)}`);
1028
+ const lamps = lampsLink(rest[0] ?? ".");
1029
+ if (lamps) throw new Problem(`${lamps} keeps this app's link: take it down from its Share Link card (Stop Sharing).`);
1030
+ const folder = layoutOf(rest[0] ?? ".").folder;
1031
+ // Lamp's record goes only when the link taken down is the one it keeps, not a link of lamp's own beside it.
1032
+ const taken = readLink(folder);
1033
+ const lampsKept = lampOwning(folder) ? lampsKeptLink(folder) : null;
1034
+ console.log(`Taken down: ${await unshare(folder)}`);
1035
+ if (lampsKept && taken?.id === lampsKept.id) keepInLamp(folder, null);
865
1036
  return;
866
1037
  }
867
1038
 
1039
+ if (command === "account") {
1040
+ const { options, rest } = parse(args, ["email"]);
1041
+ const site = siteOrigin();
1042
+ if (options.email === undefined) {
1043
+ const kept = accountFor(site);
1044
+ if (!kept) { console.log(`No account yet: ${lampCommand()} account <username> --email you@example.com asks for one.`); return; }
1045
+ const name = await accountName({ site, key: kept.key });
1046
+ console.log(name ? `Signed in as ${name}: your links go under ${new URL(site).host}/${name}.`
1047
+ : `Waiting for you to open the link in the email for ${kept.username}. Until then, links are shared as before.`);
1048
+ return;
1049
+ }
1050
+ const asked = await askAccount({ site, username: rest[0], email: String(options.email) });
1051
+ keepAccount(site, { username: asked.username, key: asked.key, email: String(options.email) });
1052
+ console.log(`${asked.existing ? `Signing in as ${asked.username}` : `Asking for ${asked.username}`}: open the link in the email sent to ${options.email} to confirm it.`
1053
+ + (asked.sent === false ? " (The site couldn't send it just now: ask again in a moment.)" : ""));
1054
+ return;
1055
+ }
1056
+
1057
+ if (command === "play") {
1058
+ const { options, rest } = parse(args, ["to"]);
1059
+ let id = /(?:^|\/w\/)([A-Za-z0-9]{10})(?:-|$|\/|\?|#)/.exec(rest[0] ?? "")?.[1];
1060
+ // A link by its maker's username and its name there (lamp.dev/<username>/<app>): its id, asked of the site.
1061
+ let latest;
1062
+ if (!id) {
1063
+ const named = /^https?:\/\/[^/]+\/([a-z0-9-]{1,60})\/([a-z0-9-]{1,60})\/?(?:[?#]|$)/.exec(rest[0] ?? "");
1064
+ if (named) { latest = await latestOf({ user: named[1], app: named[2] }); id = latest?.id ?? null; }
1065
+ if (named && !id) throw new Problem("that link isn't on lamp.dev, or it has been taken down");
1066
+ }
1067
+ if (!id) throw new Problem("play takes a link to an app shared on lamp.dev: lamp play https://lamp.dev/matt-rubens/bricks");
1068
+ // Which folder each link was played into, so playing it again picks up where it was, saves and all: a file of its
1069
+ // own for each link, so two links played at once never write over each other's.
1070
+ const playedFile = join(process.env.LAMP_DEV_HOME || join(homedir(), ".lamp", "dev"), "played", id);
1071
+ let recorded = null;
1072
+ try { recorded = readFileSync(playedFile, "utf8").trim() || null; } catch {}
1073
+ const wanted = options.to ? resolve(options.to) : null;
1074
+ let folder = !options.newest && recorded && (!wanted || wanted === recorded) && existsSync(join(recorded, "lamp-app.json")) ? recorded : null;
1075
+ if (folder) console.log(`Playing the copy in ${folder}. --newest takes the link's latest version instead.`);
1076
+ else {
1077
+ latest ??= await latestOf({ id });
1078
+ if (!latest) throw new Problem("that link isn't on lamp.dev, or it has been taken down");
1079
+ if (!latest.file) throw new Problem(`"${latest.name ?? "That app"}" was shared without its file, so it plays only on lamp.dev`);
1080
+ const answer = await fetch(latest.file);
1081
+ const bytes = answer.ok ? Buffer.from(await answer.arrayBuffer()) : null;
1082
+ if (!bytes) throw new Problem("its file couldn't be downloaded; try again in a moment");
1083
+ if (bytes.length > MAX_ARCHIVE) throw new Problem("its file is bigger than a Lamp app file is let be");
1084
+ const name = String(latest.name || "Shared app").replace(/[\r\n/:\\]+/g, " ").trim().slice(0, 60) || "Shared app";
1085
+ const games = join(homedir(), "Lamp Games");
1086
+ folder = wanted ?? join(games, name);
1087
+ for (let n = 2; !options.to && existsSync(folder) && readdirSync(folder).length; n++) folder = join(games, `${name} ${n}`);
1088
+ const work = join(stateOf(folder), "download.lampapp");
1089
+ mkdirSync(dirname(work), { recursive: true });
1090
+ writeFileSync(work, bytes);
1091
+ let opened;
1092
+ try { opened = unpack(work, folder); } finally { rmSync(work, { force: true }); }
1093
+ if (opened.refused) throw new Problem(`it's unpacked in ${folder}, but lamp can't run it: ${opened.refused}. Open it in Lamp instead`);
1094
+ if (opened.project.kind === "page") lampsFilesBeside(opened.project.root, { game: opened.project.game || manifestOf(folder)?.platform === "game" });
1095
+ mkdirSync(dirname(playedFile), { recursive: true });
1096
+ writeFileSync(playedFile, folder);
1097
+ console.log(`"${opened.name}", version ${latest.version}, is in ${folder}.`);
1098
+ }
1099
+ const opened = await ask(await serverFor(projectOf(folder).folder), "open", {}, { timeout: 30_000 });
1100
+ if (opened.opened === "app") console.log(opened.running ? `${opened.url} is open.` : `${opened.url} is being built, and opens when it is.`);
1101
+ else console.log(opened.opened === "window" ? `Opened in a window: ${opened.url}` : `Opened in the browser: ${opened.url}`);
1102
+ console.log("Its AI is answered here, with your own Claude Code, Codex or OpenRouter key. A change you make stays in this copy.");
1103
+ return;
1104
+ }
1105
+
1106
+ if (command === "ai") {
1107
+ const { options } = parse(args, []);
1108
+ if (options.forget) { forgetAllowed(); console.log("Every shared page asks again before it uses your AI here."); return; }
1109
+ const which = engine();
1110
+ if (!which) throw new Problem("AI here needs Claude Code or Codex on this computer, signed in, or an OpenRouter key in OPENROUTER_API_KEY.");
1111
+ try { await serveLocalAI(); } catch (error) {
1112
+ if (error?.code === "EADDRINUSE") throw new Problem("Lamp, or lamp ai in another terminal, already answers shared pages' AI on this computer.");
1113
+ throw error;
1114
+ }
1115
+ const allowedNow = Object.values(allowed());
1116
+ console.log(`Answering the AI of pages shared on lamp.dev with your ${which === "codex" ? "Codex" : which === "openrouter" ? "OpenRouter key" : "Claude Code"}, as they're played in a browser on this computer. Ctrl-C stops.`);
1117
+ if (allowedNow.length) console.log(`Allowed: ${allowedNow.map((page) => (page.name ? `"${page.name}"` : `lamp.dev/w/${page.id}`)).join(", ")}.`);
1118
+ await new Promise(() => {});
1119
+ }
1120
+
868
1121
  if (command === "ideas") {
869
- const { rest } = parse(args, []);
870
- const outcomes = ["in", "dismiss"];
1122
+ const outcomes = ["in", "dismiss", "reply", "done", "email"];
1123
+ // Everything after the outcome's number is a note to whoever sent it, word for word: never read as options, so
1124
+ // "--fullscreen" or "--help" in it stays in it.
1125
+ const at = args.findIndex((arg) => outcomes.includes(arg));
1126
+ const { options, rest } = parse(at < 0 ? args : args.slice(0, at + 2), []);
1127
+ const noted = at < 0 ? [] : args.slice(at + 2);
871
1128
  const folder = rest.length && !outcomes.includes(rest[0]) ? rest.shift() : ".";
1129
+ const lamps = lampsLink(folder);
1130
+ if (lamps) throw new Problem(`${lamps} keeps this app's link, and the ideas people send to it: they're on its Share Link card in ${lamps}.`);
872
1131
  const layout = layoutOf(folder);
1132
+ if (rest[0] === "email") {
1133
+ // The maker's own address for this link's ideas and replies: confirmed by an email first; "off" stops them.
1134
+ const given = [...rest.slice(1), ...noted].join(" ").trim();
1135
+ if (!given) throw new Problem("email takes your address, or off: lamp ideas email you@example.com");
1136
+ const answer = await makerEmail(layout.folder, given === "off" ? "" : given);
1137
+ if (given !== "off" && !answer.confirmed && answer.sent === false) throw new Problem(`lamp.dev couldn't send the confirmation to ${answer.email} just now: try again in a minute`);
1138
+ console.log(given === "off" ? "No more emails about this link's ideas."
1139
+ : answer.confirmed ? `Ideas and replies go to ${answer.email}.` : `Sent a confirmation to ${answer.email}: ideas and replies come there once you confirm it.`);
1140
+ return;
1141
+ }
873
1142
  if (rest.length) {
874
1143
  const n = Number(rest[1]);
875
- if (!outcomes.includes(rest[0]) || !Number.isInteger(n) || n < 1) throw new Problem("ideas takes in <number> or dismiss <number>: lamp ideas lists them with their numbers");
876
- const answer = await finishIdea(layout.folder, n, rest[0] === "in");
877
- console.log(rest[0] === "in" ? `Idea ${n} is in.${answer.notified ? " Whoever sent it asked to hear, and has been written to." : ""}` : `Idea ${n} is let go.`);
1144
+ if (!outcomes.includes(rest[0]) || !Number.isInteger(n) || n < 1) throw new Problem("ideas takes in, dismiss, reply or done, and a number: lamp ideas lists them with their numbers");
1145
+ // On the link's page, and in the email if they asked for one.
1146
+ const note = [...rest.slice(2), ...noted].join(" ").trim() || undefined;
1147
+ if (rest[0] === "reply" || rest[0] === "done") {
1148
+ if (rest[0] === "reply" && !note) throw new Problem("reply takes what to say after the number: lamp ideas reply <number> <words>");
1149
+ const answer = await answerIdea(layout.folder, n, rest[0] === "reply" ? note : undefined);
1150
+ console.log(rest[0] === "done" ? `Idea ${n} is seen to.` : `Said to whoever sent idea ${n}${answer.notified ? ", by email too" : ", on the link's page"}.`);
1151
+ return;
1152
+ }
1153
+ const answer = await finishIdea(layout.folder, n, rest[0] === "in", note);
1154
+ console.log(rest[0] === "in" ? `Idea ${n} is in.${answer.notified ? " Whoever sent it asked to hear, and has been written to." : ""}` : `Idea ${n} is let go.${note ? " Your note is on the link's page for them." : ""}`);
878
1155
  return;
879
1156
  }
880
- const sent = await ideas(layout.folder);
881
- if (!sent.ideas.length) { console.log(`No ideas waiting for ${sent.url}`); return; }
882
- console.log(`Ideas sent to ${sent.url}`);
1157
+ const [sent, maker] = await Promise.all([ideas(layout.folder, { earlier: options.earlier === true }), makerEmail(layout.folder).catch(() => null)]);
1158
+ if (maker?.email) console.log(maker.confirmed ? `Ideas and replies go to ${maker.email} too.` : `Waiting for ${maker.email} to be confirmed: the link is in the email lamp.dev sent it.`);
1159
+ if (!sent.ideas.length) { console.log(options.earlier ? `Nothing seen to yet on ${sent.url}` : `No ideas waiting for ${sent.url}`); return; }
1160
+ console.log(options.earlier ? `Ideas seen to on ${sent.url}, the latest first` : `Ideas sent to ${sent.url}`);
883
1161
  for (const idea of sent.ideas) {
884
- console.log(` ${idea.n}. ${String(idea.text).split("\n").join("\n ")}${idea.wantsNews ? " (wants to hear when it's in)" : ""}`);
1162
+ const state = options.earlier ? ` (${idea.status === "in" ? "in" : "dismissed"})` : idea.waiting ? ` (${idea.status === "in" ? "in" : idea.status === "dismissed" ? "dismissed" : "open"}, they answered)` : idea.wantsNews ? " (wants to hear back)" : "";
1163
+ const from = typeof idea.name === "string" && idea.name ? idea.name : null;
1164
+ console.log(` ${idea.n}. ${from ? `${from}: ` : ""}${String(idea.text).split("\n").join("\n ")}${state}`);
1165
+ // What's been said since, each way.
1166
+ for (const said of Array.isArray(idea.thread) ? idea.thread : [])
1167
+ console.log(` ${said.by === "maker" ? "You" : from ?? "They"}: ${String(said.text).split("\n").join("\n ")}`);
1168
+ // The app as they saw it when they opened the box, if they kept it in: there until the idea has its outcome.
1169
+ if (typeof idea.picture === "string" && idea.picture.startsWith("https://")) console.log(` picture: ${idea.picture}`);
885
1170
  }
886
- console.log("Say what became of one with: lamp ideas in <number>, or lamp ideas dismiss <number>.");
1171
+ if (options.earlier) { console.log("Say something more with: lamp ideas reply <number> <words>."); return; }
1172
+ console.log("Say what became of one with: lamp ideas in <number>, or lamp ideas dismiss <number>, with a note to them after it if you like.");
1173
+ if (sent.ideas.some((idea) => idea.waiting)) console.log("Answer one with: lamp ideas reply <number> <words>, or lamp ideas done <number> to leave it.");
887
1174
  return;
888
1175
  }
889
1176
 
@@ -897,6 +1184,29 @@ async function main() {
897
1184
 
898
1185
  if (command === "devices") return await devices(args);
899
1186
 
1187
+ if (command === "changing") {
1188
+ // What the next change is, in the person's words, for the card the page shows as it goes in.
1189
+ const named = args.length > 1 && existsSync(args[0]) && statSync(args[0]).isDirectory();
1190
+ const folder = named ? args[0] : ".";
1191
+ const words = (named ? args.slice(1) : args).join(" ").trim();
1192
+ if (!words) throw new Problem('changing takes what you\'re about to change, in a few words: lamp changing "Rain on the roads"');
1193
+ const layout = projectOf(folder);
1194
+ // For a folder Lamp's app has, it goes with the next apply_change (`changingFor`), and its card says it.
1195
+ if (givenTo(layout.folder)) {
1196
+ mkdirSync(stateOf(layout.folder), { recursive: true });
1197
+ writeFileSync(join(stateOf(layout.folder), "changing.json"), JSON.stringify({ words, at: Date.now() }));
1198
+ console.log("The app says so as the change goes in.");
1199
+ return;
1200
+ }
1201
+ // A Mac app lamp runs itself has no card for a change: nothing to say it on, and nothing wrong.
1202
+ if (layout.kind === "mac") { console.log("A Mac app lamp runs itself shows no card for a change: nothing says it."); return; }
1203
+ const live = await running(layout.folder);
1204
+ if (!live) { console.log("lamp dev isn't running for this folder: nothing shows it."); return; }
1205
+ await ask(live, "changing", { words });
1206
+ console.log("The page says so as the change goes in.");
1207
+ return;
1208
+ }
1209
+
900
1210
  if (command === "reload" || command === "stop") {
901
1211
  const { options, rest } = parse(args, ["on"]);
902
1212
  if (options.on !== undefined) {
package/cli/lampapp.mjs CHANGED
@@ -6,11 +6,13 @@ import { randomUUID } from "node:crypto";
6
6
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
7
7
  import { basename, dirname, join, resolve, sep } from "node:path";
8
8
  import { inflateRawSync } from "node:zlib";
9
- import { Problem, manifestOf, projectOf, stateOf } from "./state.mjs";
9
+ import { Problem, RESOURCES, manifestOf, projectOf, stateOf } from "./state.mjs";
10
10
  import { LIMITS, nameOf, zipOf } from "./share.mjs";
11
11
 
12
- // What another Lamp accepts in a file (`Sharing.maxFiles`, `maxUnpackedBytes`, `maxArchiveBytes`).
13
- export const MAX_FILES = 2000, MAX_UNPACKED = 256 * 1024 * 1024, MAX_ARCHIVE = 64 * 1024 * 1024;
12
+ // What a Lamp app file may hold, written once in Resources/share-rules.json, which Lamp's app reads too (`ShareRules`):
13
+ // the sizes another Lamp opens, the names a license goes by, and the shapes of keys a shared file never carries.
14
+ const RULES = JSON.parse(readFileSync(join(RESOURCES, "share-rules.json"), "utf8"));
15
+ export const MAX_FILES = RULES.limits.files, MAX_UNPACKED = RULES.limits.unpackedBytes, MAX_ARCHIVE = RULES.limits.archiveBytes;
14
16
 
15
17
  /**
16
18
  * The files of a zip, by path: each a regular file's bytes, in the folder it names. A path that leaves the folder, a
@@ -93,17 +95,15 @@ const SERVED_HIDDEN = new Set([".nojekyll", ".well-known"]);
93
95
  const SWIFT_LAMPS = new Set(["LampSupport.swift", "LampScene.swift", "LampWidgetSupport.swift", "LampShareSupport.swift"]);
94
96
 
95
97
  // Shapes of keys and tokens whoever's they are (`ShareCheck.tokenShapes`).
96
- const TOKEN_SHAPES = [
97
- /-----BEGIN [A-Z ]*PRIVATE KEY-----/, /\bsk-(or-v1-|ant-|proj-)?[A-Za-z0-9_\-]{32,}/, /\bgh[pousr]_[A-Za-z0-9]{36,}/, /\bgithub_pat_[A-Za-z0-9_]{60,}/,
98
- /\bAKIA[0-9A-Z]{16}\b/, /\bAIza[0-9A-Za-z_\-]{35}\b/, /\bxox[abprs]-[A-Za-z0-9\-]{20,}/, /\b(sk|rk)_live_[A-Za-z0-9]{20,}/,
99
- ];
98
+ const TOKEN_SHAPES = RULES.tokenShapes.map((shape) => new RegExp(shape.pattern));
100
99
 
101
100
  /** A license text by its name (`Credits.isLicenceFile`): LICENSE, LICENCE, COPYING, NOTICE or UNLICENSE, never code. */
102
101
  export function isLicenceFile(path) {
103
102
  if (typeof path !== "string" || !path || path.startsWith("/") || path.split("/").some((part) => !part || part === ".." || part === ".")) return false;
104
103
  const name = path.split("/").pop(), dot = name.lastIndexOf("."), ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
105
- return ["LICENSE", "LICENCE", "COPYING", "NOTICE", "UNLICENSE"].some((start) => name.toUpperCase().startsWith(start))
106
- && ext.length <= 10 && /^[a-z0-9]*$/.test(ext) && !["sh", "command", "app", "js", "py", "rb", "pl", "swift", "html", "htm"].includes(ext);
104
+ const rules = RULES.licenseFiles;
105
+ return rules.startsWith.some((start) => name.toUpperCase().startsWith(start))
106
+ && ext.length <= rules.longestExtension && /^[a-z0-9]*$/.test(ext) && !rules.neverExtensions.includes(ext);
107
107
  }
108
108
 
109
109
  /**
@@ -23,8 +23,8 @@ object LampPageGateway {
23
23
  val id = call.optInt("id")
24
24
  val path = call.optString("path")
25
25
  val body = call.optString("body", "{}")
26
- // The page's own wait for an answer (a status check's few seconds, a question's minutes).
27
- val timeout = call.optInt("timeout", 240_000).coerceIn(1_000, 240_000)
26
+ // The page's own wait for an answer (a status check's few seconds, an action step's ten minutes at most).
27
+ val timeout = call.optInt("timeout", 240_000).coerceIn(1_000, 600_000)
28
28
  thread {
29
29
  val (status, text) = if (path in routes) post(files, path, body, timeout) else 404 to JSONObject().put("error", "Not found").toString()
30
30
  val answer = JSONObject().put("id", id).put("status", status).put("body", text).toString()