@lampdotdev/lamp 0.35.0 → 0.36.2-nightly.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -11
- package/Resources/GameKit/apple/LampPage.swift +202 -35
- package/Resources/GameKit/apple/LampWebGame.swift +1 -1
- package/Resources/GameKit/page/lamp-live.js +672 -0
- package/Resources/GameKit/web/lamp-game.js +7 -1
- package/Resources/share-rules.json +64 -0
- package/Resources/skills/game/SKILL.md +2 -0
- package/Resources/skills/github-projects/SKILL.md +8 -5
- package/Resources/skills/web-app/SKILL.md +49 -15
- package/Resources/skills/web-or-native/SKILL.md +6 -1
- package/cli/ai.mjs +237 -0
- package/cli/app-ai.mjs +473 -0
- package/cli/browser.mjs +18 -2
- package/cli/calls.mjs +213 -9
- package/cli/dev.mjs +61 -14
- package/cli/lamp.mjs +347 -37
- package/cli/lampapp.mjs +9 -9
- package/cli/lamps/android-host/LampPageGateway.kt +2 -2
- package/cli/lamps/lamp-for-games.js +95 -36
- package/cli/lamps/lamp.js +95 -36
- package/cli/lamps/mac-starter/.lamp/meta.json +1 -1
- package/cli/lamps/mac-starter/Sources/LampSupport.swift +403 -285
- package/cli/lamps/web-starter/lamp.js +95 -36
- package/cli/link.mjs +77 -13
- package/cli/local-ai.mjs +193 -0
- package/cli/openrouter.mjs +133 -0
- package/cli/outside.mjs +65 -2
- package/cli/page.js +160 -8
- package/cli/share.mjs +80 -12
- package/cli/skill/SKILL.md +35 -10
- package/cli/state.mjs +3 -0
- package/cli/update.mjs +117 -12
- package/package.json +1 -1
- package/site/lib/shared-wishes.js +848 -117
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 {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
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 {
|
|
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
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
177
|
-
|
|
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
|
|
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
|
-
|
|
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}\
|
|
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 ??
|
|
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
|
-
|
|
855
|
-
|
|
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
|
-
|
|
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
|
|
870
|
-
|
|
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
|
|
876
|
-
|
|
877
|
-
|
|
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 (
|
|
882
|
-
console.log(`
|
|
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
|
-
|
|
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
|
|
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
|
|
13
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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,
|
|
27
|
-
val timeout = call.optInt("timeout", 240_000).coerceIn(1_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()
|