dsh-claude-whiteboard 2.14.0 → 2.15.0
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/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// dsh-claude-whiteboard 2.
|
|
1
|
+
// dsh-claude-whiteboard 2.15.0: built from baoyu-design.
|
|
2
2
|
var define_PLUGIN_default = { name: "claude-whiteboard", provider: "dsh-claude-whiteboard", skills: [{ name: "claude-whiteboard", description: "Use this for a local Whiteboard: a shared space for laying out thinking in whatever form it needs (a diagram, a flow, a map of ideas, a plan, a comparison, a drawn explanation, a small working embed) that opens in the bundled board editor, where people move, draw, comment and send comments to the agent. Its content lives in the board's own store, written with this skill's script, never in files you edit by hand. Read it before you create a board or change one. Not for a slide deck, a document, a film or a static HTML page.", scriptAlias: "A", comments: true }] };
|
|
3
3
|
|
|
4
4
|
import { readFile } from "node:fs/promises";
|
|
@@ -110,13 +110,14 @@ function runtimeSection(skill, skillDir, node, platform = process.platform, watc
|
|
|
110
110
|
skill.commentsBy === "mention" ? "(read the block, change the doc with `update`, then `comment-reply`)." : "(change the files, then `comment-reply`).",
|
|
111
111
|
"So do not start `comments --wait` yourself, where Comments says to: run `open` in the foreground (not",
|
|
112
112
|
"`run_in_background`), and the wait starts with it. It stops when the editor stops; after `stop`, the next `open`",
|
|
113
|
-
"starts it again.",
|
|
113
|
+
"starts it again. Harness starts the next wait when your turn ends, which is where this skill says to wait again:",
|
|
114
|
+
"finish what a message asks for in the turn that takes it.",
|
|
114
115
|
...skill.commentsBy === "mention" ? ["", "When you first give the link, tell the user that a comment with `@agent` in it reaches you (type `@` and pick agent)."] : [],
|
|
115
116
|
...skill.requests ? [
|
|
116
117
|
"",
|
|
117
118
|
"The page's buttons reach you the same way: a request one of them sends arrives as such a message, under",
|
|
118
|
-
"`requests` (
|
|
119
|
-
"
|
|
119
|
+
"`requests` (the button in `label`, what it asks in `text`), as `comments <root> --wait` printed it for that",
|
|
120
|
+
"page. It is a request from the page wherever this skill speaks of one: handle it as this skill says for those."
|
|
120
121
|
] : []
|
|
121
122
|
);
|
|
122
123
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-claude-whiteboard",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.15.0",
|
|
4
4
|
"description": "DeepSeek Harness plugin: the claude-whiteboard skill from baoyu-design. The agent lays out diagrams, flows, maps of ideas and plans on a whiteboard and opens it in a local board editor where you move, draw and comment.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -24,6 +24,18 @@
|
|
|
24
24
|
"icon": "./icon.svg",
|
|
25
25
|
"baoyuDesign": {
|
|
26
26
|
"releaseNotes": [
|
|
27
|
+
{
|
|
28
|
+
"version": "2.15.0",
|
|
29
|
+
"date": "2026-11-11",
|
|
30
|
+
"en": "### Features\n- **`claude-motion` follows claude.ai's 2026-11-10 update.** A film that has a `narration.js` but no voice track of its own gets a **Voiceover** section under Tweaks in the side panel and a voiceover lane under the timeline. Change a line and click **Record changes**: the edit reaches the agent as a request (`comments --wait` prints it under `requests`; with no agent listening, the text is there to copy). A film with a voice track of its own (`modules/voice.json`) keeps its **Script** tab and lanes. `write-db` now refuses to write the records the page keeps of what you set by hand (`modules/retyped.json`, `recolored.json`, `recut.json`, `refaced.json`, `retouched.json`, `fetched-fonts.json`) and says `PAGE_ONLY`.\n- **`claude-design-system` follows claude.ai's 2026-11-10 update.** Edits are saved as soon as the page is hidden (you switch tabs, say) instead of waiting for the autosave. A toast you close hands the focus back to where it was, and F6 reaches it.\n- **`claude-slides` follows claude.ai's 2026-11-10 update.** A slide that can't be read, or whose markup may be partly hidden, says so on the slide and in the inspector, with **Ask the agent to fix** (with no conversation listening it copies a prompt to paste). A selected SVG image can't be set to Cover or Hug, and the inspector says why: it would change when the presentation reopens. While presenting, a swipe that starts on the page-number strip no longer turns the slide. A `deck.json` with a `format` this version doesn't know opens read-only, with a banner that it was saved by a newer version of Slides, and is not rewritten. Tables are saved with a wrapper for each row and with table, row and cell roles, so columns line up and screen readers read them as tables.\n- **Every editor draws Markdown in comments.** Markdown in a comment or an agent's reply (bold, code, lists, quotes and so on) is drawn, not shown as characters; the **All comments** list keeps each row to flat text. The new-comment card shows a focus ring while you type in it.\n- **DeepSeek Harness: the Artifacts page's type tiles follow claude.ai's \"Make something new\" row.** Each type is a tile with a pastel wash and a chalk doodle (dark mode swaps wash and ink), with redrawn type icons and a Beta badge on Data, Whiteboard and Motion. Rest the pointer on a tile, or Tab to it, and a card shows the type's name, what it makes and whether it is installed, with a button to start one, install it, or see the install. Escape closes the card; touch skips it.\n\n### Fixes\n- `claude-slides`: its third-party licenses notice now carries the OfficeGen attribution that pptxgenjs's source header names.\n\n### Documentation\n- `claude-motion`'s `SKILL.md` and reference follow the same update. The agent ends with the film's url. A film for a loop, a GIF or a lobby screen has no score unless one is asked for. A request that says both what the film is for and how it should look skips the questions form. Plain films start from Inter and Space Grotesk. `by-hand.md` covers the page's records of lines retyped and colors set by hand.",
|
|
31
|
+
"zh": "### 新功能\n- **`claude-motion` 跟上了 claude.ai 2026-11-10 的更新。** 只有 `narration.js`、没有自己配音轨的影片,侧边栏 Tweaks 里多了 **Voiceover** 一节,时间轴下多了一条 voiceover 轨。改一行台词,点 **Record changes**,这处改动会作为一个请求发给 agent(`comments --wait` 在 `requests` 下打印它;没有 agent 在听时,给出文字让你复制)。有自己配音轨(`modules/voice.json`)的影片,保持原来的 **Script** 标签页和轨道。`write-db` 现在拒绝写页面自己记的手改记录(`modules/retyped.json`、`recolored.json`、`recut.json`、`refaced.json`、`retouched.json`、`fetched-fonts.json`),报 `PAGE_ONLY`。\n- **`claude-design-system` 跟上了 claude.ai 2026-11-10 的更新。** 页面一被隐藏(比如切到别的标签页)就立刻保存,不再等自动保存。关掉 toast 后焦点回到原来的位置,F6 也能走到 toast。\n- **`claude-slides` 跟上了 claude.ai 2026-11-10 的更新。** 读不出来的幻灯片,或标记可能被部分隐藏的幻灯片,会在幻灯片上和属性面板里说明,并带 **Ask the agent to fix**(没有对话在听时,复制一段提示词让你贴给 agent)。选中的 SVG 图片不能设成 Cover 或 Hug,属性面板会说明原因:重新打开演示稿时它会变样。放映时从页码条上开始的滑动不再翻页。`deck.json` 带着这个版本不认识的 `format` 时,只读打开,顶上提示它由更新版本的 Slides 保存,文件不会被改写。表格按行包一层,并带 table、row、cell 的 role,列对得齐,读屏软件也按表格来读。\n- **所有编辑器的评论按 Markdown 显示。** 评论或 agent 回复里的 Markdown(粗体、代码、列表、引用等)直接画出来,不再显示成原样字符;**All comments** 列表里每一行仍是平铺的文字。新评论卡在你输入时有焦点环。\n- **DeepSeek Harness:「作品」页的类型磁贴跟 claude.ai 的「Make something new」一行一致。** 每个类型一块磁贴:淡色底加粉笔涂鸦(深色模式下底色和线条互换),类型图标重画,Data、Whiteboard、Motion 带 Beta 角标。鼠标停在磁贴上,或者用 Tab 走到它,会出现一张卡片,写着类型名、它做什么、装没装,以及开始新建、安装或查看安装的按钮。Esc 关闭卡片,触屏不出卡片。\n\n### 修复\n- `claude-slides`:第三方许可说明补上了 pptxgenjs 源码文件头写明的 OfficeGen 署名。\n\n### 文档\n- `claude-motion` 的 `SKILL.md` 和 reference 也跟着这次更新。agent 收尾时给出影片的地址。循环播放、GIF、展厅屏幕这类影片,不要求配乐就不配。请求里既说了影片干什么用、又说了要什么样子,就不再弹问题表单。普通的影片从 Inter 和 Space Grotesk 起手。`by-hand.md` 讲了页面记录的手改台词和手改颜色。"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"version": "2.14.1",
|
|
35
|
+
"date": "2026-11-11",
|
|
36
|
+
"en": "### Features\n- **DeepSeek Harness: open your work from another device.** When Harness runs on one computer and you use it from another (`dsh web` on a server, opened in a browser on your laptop or phone over the same network or a tailnet), opening an item from **Artifacts**, or **View thread** on a comment card, gave a `http://127.0.0.1:…` link that only that computer can open. The plugin now gives the device you are on a link of its own, at the address your browser reaches Harness by, and opens it in a new tab. Through it you are the editor's owner, as on that computer: you edit, and **Send to Agent** is there. Behind a proxy or a domain of your own, set `BAOYU_DESIGN_REMOTE_HOST` to the address other devices reach that computer at; `off` turns this off. Every editor's `open` takes `--remote[=<host>]` to print that owner's link (`--remote=off` closes it), and `share <folder> --reset owner` replaces its key. See [Use it from another device](https://github.com/JimLiu/baoyu-design/blob/main/dsh-plugins/README.md#use-it-from-another-device) (requested by @akaDeyve, [#40](https://github.com/JimLiu/baoyu-design/issues/40)).\n- **DeepSeek Harness: `dsh-claude-dashboard` updates to `dsh-claude-data`.** The Artifacts page names the type **Data**, and offers an installed `dsh-claude-dashboard` as an update, `claude-dashboard → claude-data`: **Update** installs `dsh-claude-data`, switched on or off as the old one was, then uninstalls `dsh-claude-dashboard`. A preset that names `dsh-claude-dashboard` keeps it, and the page says what to change. 2.14.0 said `dsh-claude-dashboard` would get no more updates; instead it keeps getting releases with no skill in them. An Artifacts page from 2.13.0 or earlier offers that release as an update, and it brings the page that offers the move, so click **Update** once more when the move shows.\n\n### Fixes\n- `claude-design-system`: a guest on a **Share** link could not add an asset or a font. The page stayed at **Adding …**, because a page served over plain http on the network lacks the browser's hashing that the upload used. It now hashes the file itself.\n- DeepSeek Harness: uninstalling the plugin that was running the Artifacts page left the page at a 404 until the app restarted. Another installed plugin now takes it over at once.",
|
|
37
|
+
"zh": "### 新功能\n- **DeepSeek Harness:在另一台设备上打开你的作品。** Harness 跑在一台电脑上、你在另一台设备上用时(比如服务器上跑 `dsh web`,在笔记本或手机的浏览器里通过同一个局域网或 tailnet 打开),从 **Artifacts**(「作品」)打开,或者点评论卡片上的 **View thread**,拿到的是 `http://127.0.0.1:…`,只有那台电脑打得开。现在插件会给你手上这台设备一个它自己的链接,地址就是浏览器访问 Harness 用的那个,在新标签页里打开。通过它打开,你就是编辑器的主人,和在那台电脑上一样:能编辑,也有 **Send to Agent**。经过代理或用自己的域名访问时,把 `BAOYU_DESIGN_REMOTE_HOST` 设成其他设备访问那台电脑用的地址;设成 `off` 就关掉这个功能。每个编辑器的 `open` 都可以加 `--remote[=<host>]`,打出这个主人链接(`--remote=off` 关掉),`share <文件夹> --reset owner` 换一把钥匙。见[在另一台设备上用](https://github.com/JimLiu/baoyu-design/blob/main/dsh-plugins/README.zh-CN.md#在另一台设备上用)(@akaDeyve 提出,[#40](https://github.com/JimLiu/baoyu-design/issues/40))。\n- **DeepSeek Harness:`dsh-claude-dashboard` 可以更新成 `dsh-claude-data`。** 「作品」页上这个类型叫 **数据**(Data),装着 `dsh-claude-dashboard` 的,会把换到新插件当作一项更新来提示,写作 `claude-dashboard → claude-data`:点 **Update**(更新)会先装上 `dsh-claude-data`(开关和旧插件一样),再卸载 `dsh-claude-dashboard`。预设里写了 `dsh-claude-dashboard` 的,旧插件会留着,页面上会说要改哪里。2.14.0 说 `dsh-claude-dashboard` 不再更新;现在改成它继续发不带技能的版本。2.13.0 或更早的「作品」页会把这个版本当作更新来提示,装上它就有了会提示换插件的新「作品」页,提示出来后再点一次 **Update**。\n\n### 修复\n- `claude-design-system`:通过 **Share**(分享)链接进来的访客加不了资源和字体,页面一直停在 **Adding …**。局域网上用明文 http 打开的页面没有浏览器自带的哈希功能,上传要用它。现在由页面自己算。\n- DeepSeek Harness:卸载正在提供「作品」页的那个插件后,这个页面一直是 404,要重启应用才好。现在另一个装着的插件会马上接手。"
|
|
38
|
+
},
|
|
27
39
|
{
|
|
28
40
|
"version": "2.14.0",
|
|
29
41
|
"date": "2026-11-11",
|
|
@@ -59,18 +71,6 @@
|
|
|
59
71
|
"date": "2026-11-06",
|
|
60
72
|
"en": "### Features\n- **Comments, in every editor, follow claude.ai's comment layer more closely.** A thread whose pin is out of the window gets a **More comments** tab with its count at that edge instead of a pin, and a click opens the nearest one. Pins on the same spot sit side by side. The **All comments** panel has **Send all to Agent**, which gives the agent every thread at once (`comments --wait` returns them together). An edited message says **edited**, a resolved thread says **Reply to reopen…**, and the panel has an empty state and, when the filter hides everything, **Show N resolved**.\n- **`claude-slides`, `claude-design`, `claude-design-system`, `claude-dashboard` and `claude-motion` follow claude.ai's 2026-11-05 update.** In all five: a button shows that it is working while its action runs, a dialog gives focus back to where it was, toasts can be reached from the keyboard, tabs have an underline style, and the icon set has 34 more icons. Properties can be copied and pasted in slides, design and the dashboard's edit mode.\n- `claude-slides`: speaker notes can be edited in the Canvas view, and the presentation has jump keys (Home, End).\n- `claude-design`: grid cells can be dragged and moved with the keyboard, and a tall artboard's PDF export is split across pages.\n- `claude-design-system`: a system holds up to 4,096 files a version, 4,080 of them its own (1,024 before), and `agent.md` and `gemini.md` are refused as file names, like `claude.md` and `agents.md`.\n- `claude-dashboard`: an attached file can be up to 15 MiB, a document written by the agent carries an `edit` stamp the page reads, and `check` knows the new rules for `dash/look`.\n- `claude-motion`: `check` warns about a module named `scene-…` or `layer-…`, which are the player's own names.\n\n### Documentation\n- Every editor's `SKILL.md` and reference follow the same update. `claude-slides` and `claude-design` choose typefaces from the brief and the subject, Inter with Space Grotesk when it is neutral. `claude-dashboard` has new looks and questions pages and reworked pages on data sources, features, design systems and reading aloud.\n- `claude-motion` is reworked: a new film is asked about before anything is made, a film left half-way is taken up again on a later turn instead of started over, a named design system is read before the first scene, and a few kinds of film are refused (for or against a political party, candidate or ballot measure; a promo for gambling or a crypto coin, token or exchange; a fight or combat scene). New reference pages cover each.",
|
|
61
73
|
"zh": "### 新功能\n- **所有编辑器的评论层更接近 claude.ai 的评论层。** 图钉不在窗口里的评论,不再画图钉,改成在那一侧边缘贴一个带数量的 **More comments** 标签,点一下就打开离得最近的一条。同一个位置的图钉并排排开。**All comments** 面板里有 **Send all to Agent**,一次把所有评论串发给 Agent(`comments --wait` 一起返回)。改过的消息会标 **edited**,已解决的评论串显示 **Reply to reopen…**,面板有了空状态;筛选把评论全藏起来时,显示 **Show N resolved**。\n- **`claude-slides`、`claude-design`、`claude-design-system`、`claude-dashboard` 和 `claude-motion` 跟上了 claude.ai 2026-11-05 的更新。** 五个编辑器都有:按钮在操作进行时显示忙的状态,对话框关闭后把焦点还给原来的位置,提示条可以用键盘切过去,标签页多了下划线样式,图标多了 34 个。幻灯片、设计稿和仪表盘的编辑模式里可以复制、粘贴属性。\n- `claude-slides`:演讲者备注可以在 Canvas 视图里直接编辑,演示时多了跳转键(Home、End)。\n- `claude-design`:网格的单元格可以拖动,也可以用键盘移动;很高的画板导出 PDF 时拆成多页。\n- `claude-design-system`:一个设计系统每个版本最多 4,096 个文件,其中自己的 4,080 个(以前是 1,024);`agent.md` 和 `gemini.md` 和 `claude.md`、`agents.md` 一样,不能用作文件名。\n- `claude-dashboard`:附件最大 15 MiB;Agent 写的文档带 `edit` 戳,页面会读它;`check` 认识 `dash/look` 的新规则。\n- `claude-motion`:`check` 对名字以 `scene-` 或 `layer-` 开头的模块给出警告,这两类是播放器自己的名字。\n\n### 文档\n- 每个编辑器的 `SKILL.md` 和 reference 都跟着这次更新。`claude-slides` 和 `claude-design` 按需求和主题选字体,中性的就用 Inter 搭 Space Grotesk。`claude-dashboard` 新增了 looks 和 questions 两页,数据来源、功能、设计系统和朗读几页重写了。\n- `claude-motion` 重写:新片先问再做,做到一半的影片下一轮会接着做,不重来;指定了设计系统,先读它再画第一场;还有几类片子不做(支持或反对某个政党、公职候选人或公投议案;赌博、加密币、代币或交易所的宣传片;打斗或战斗场面)。各有新的 reference 页。"
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
"version": "2.11.1",
|
|
65
|
-
"date": "2026-11-05",
|
|
66
|
-
"en": "### Features\n- **DeepSeek Harness: the Artifacts page offers the plugins you haven't installed.** Beside the tiles for the skills you have, each other baoyu-design plugin gets a dimmed tile marked **Not installed**. Click it to see what the plugin makes and install it from there, at the same version as the ones you have, usually without a restart. Once it's in, **Start** opens a session with the skill. In an app that can't install from the page, the tile points you to the Plugins page. A plugin you installed and switched off still gets no tile.\n\n### Fixes\n- With `--agent-hook`, a comment could reach the agent twice: a second send could start the hook again after the first run had finished but before the comment was marked sent.",
|
|
67
|
-
"zh": "### 新功能\n- **DeepSeek Harness:「作品」页会列出你还没装的插件。** 已装技能的入口旁边,其他 baoyu-design 插件也各有一个入口,颜色淡一些,标着 **未安装**。点开会说明这个插件做什么,可以直接在这里安装,版本和你已装的一致,多数情况不用重启。装好后点 **新建** 就用这个技能开一个会话。应用不支持在页面里安装时,会引导你去插件页。装了但被你关掉的插件,仍然没有入口。\n\n### 修复\n- 用 `--agent-hook` 时,一条评论可能发给 agent 两次:第一次运行已经结束、评论还没标成已发送时,第二次发送会再启动一次 hook。"
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
"version": "2.11.0",
|
|
71
|
-
"date": "2026-11-05",
|
|
72
|
-
"en": "### Features\n- **DeepSeek Harness: a new version no longer pops up over the app.** The **Artifacts** row in the left sidebar gets an **Update** label instead (a dot on the icon, where a collapsed sidebar keeps its icons), and a panel at the top of the Artifacts page shows the versions, each plugin and what the release changes. **Update all** updates them together; **Update** on a plugin's line takes just that one. **Later** folds the panel to one line and removes the label until the next version.\n- While an update runs you can leave the Artifacts page: the label says **Updating**, **Failed** or **Restart**. **Try again** picks up where it stopped: the rest after **Update all**, that plugin after its own **Update**.\n- The **Update** button on a plugin's page now updates only that plugin, and a page that was open when its plugin was replaced comes back by itself.",
|
|
73
|
-
"zh": "### 新功能\n- **DeepSeek Harness:新版本不再弹出来打断你。** 左侧栏的 **Artifacts**(「作品」)这一行会多一个 **更新** 标签(侧栏收起后还留着图标的话,是图标上的小圆点),「作品」页顶部的面板写着版本、每个插件和这次改了什么。**全部更新** 一起更新;每个插件那一行的 **更新** 只更新它自己。**稍后** 把面板收成一行,标签也消失,等下一个版本再提示。\n- 更新时可以离开「作品」页:标签会显示 **更新中**、**更新失败** 或 **待重启**。**重试** 从停下的地方接着来:**全部更新** 失败就接着更新剩下的,单独更新失败就再试这一个。\n- 插件详情页的 **更新** 按钮现在只更新这一个插件;更新时开着的页面被替换后,会自己回来。"
|
|
74
74
|
}
|
|
75
75
|
]
|
|
76
76
|
},
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
"dsh": ">=0.2.0-0"
|
|
97
97
|
},
|
|
98
98
|
"dependencies": {
|
|
99
|
-
"dsh-claude-artifacts": "^2.
|
|
99
|
+
"dsh-claude-artifacts": "^2.15.0"
|
|
100
100
|
},
|
|
101
101
|
"dsh": {
|
|
102
102
|
"bundle": {
|
|
@@ -242,6 +242,7 @@ function sendFile(request, response, file, headers) {
|
|
|
242
242
|
var errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
243
243
|
|
|
244
244
|
import { spawn } from "node:child_process";
|
|
245
|
+
import dns from "node:dns/promises";
|
|
245
246
|
import { EventEmitter } from "node:events";
|
|
246
247
|
import fs3 from "node:fs";
|
|
247
248
|
import http from "node:http";
|
|
@@ -297,6 +298,7 @@ var Sharing = class _Sharing {
|
|
|
297
298
|
if (isRecord(link) && typeof link.token === "string" && link.token.length >= 16) state.links[role] = { token: link.token, on: link.on !== false };
|
|
298
299
|
}
|
|
299
300
|
}
|
|
301
|
+
if (isRecord(raw.owner) && typeof raw.owner.token === "string" && raw.owner.token.length >= 32) state.owner = { token: raw.owner.token };
|
|
300
302
|
if (Array.isArray(raw.guests)) {
|
|
301
303
|
for (const g of raw.guests) {
|
|
302
304
|
if (!isRecord(g) || typeof g.id !== "string" || typeof g.secret !== "string" || !isGuestRole(g.role) || typeof g.link !== "string") continue;
|
|
@@ -327,6 +329,27 @@ var Sharing = class _Sharing {
|
|
|
327
329
|
}
|
|
328
330
|
return null;
|
|
329
331
|
}
|
|
332
|
+
/** The owner's link token (`state.owner`), made and saved the first time it is asked for. */
|
|
333
|
+
ownerToken() {
|
|
334
|
+
if (!this.state.owner) {
|
|
335
|
+
this.state.owner = { token: token(32) };
|
|
336
|
+
this.save();
|
|
337
|
+
}
|
|
338
|
+
return this.state.owner.token;
|
|
339
|
+
}
|
|
340
|
+
/** Whether `given` is the owner's link token (a cookie or a link's). */
|
|
341
|
+
isOwnerToken(given) {
|
|
342
|
+
const own = this.state.owner?.token;
|
|
343
|
+
if (!given || !own) return false;
|
|
344
|
+
const a = Buffer.from(given);
|
|
345
|
+
const b = Buffer.from(own);
|
|
346
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
347
|
+
}
|
|
348
|
+
/** A new owner's link: the old one, and every browser that came in through it, stop working. */
|
|
349
|
+
resetOwner() {
|
|
350
|
+
this.state.owner = { token: token(32) };
|
|
351
|
+
this.save();
|
|
352
|
+
}
|
|
330
353
|
/** The guest a cookie names, if they still have access (their link on and not reset). */
|
|
331
354
|
guest(secret) {
|
|
332
355
|
if (!secret) return null;
|
|
@@ -588,6 +611,8 @@ var Hub = class {
|
|
|
588
611
|
hosts = /* @__PURE__ */ new Set();
|
|
589
612
|
/** One listener per LAN address while any site is open to the network. */
|
|
590
613
|
lanServers = /* @__PURE__ */ new Map();
|
|
614
|
+
/** Names that resolve to an address listened on, asked for by `allowName` (lower case, no port). */
|
|
615
|
+
lanNames = /* @__PURE__ */ new Set();
|
|
591
616
|
network;
|
|
592
617
|
constructor(options) {
|
|
593
618
|
this.echo = options.echo;
|
|
@@ -603,7 +628,8 @@ var Hub = class {
|
|
|
603
628
|
},
|
|
604
629
|
addresses: () => [...this.lanServers.keys()],
|
|
605
630
|
open: () => this.openLan(),
|
|
606
|
-
release: () => this.releaseLan()
|
|
631
|
+
release: () => this.releaseLan(),
|
|
632
|
+
allowName: (name) => this.allowName(name)
|
|
607
633
|
};
|
|
608
634
|
}
|
|
609
635
|
createServer() {
|
|
@@ -722,9 +748,9 @@ var Hub = class {
|
|
|
722
748
|
}
|
|
723
749
|
return [...this.lanServers.keys()];
|
|
724
750
|
}
|
|
725
|
-
/** A site stopped sharing: the listeners stay while another is shared. */
|
|
751
|
+
/** A site stopped sharing: the listeners stay while another is shared, or open to its owner on another device. */
|
|
726
752
|
releaseLan() {
|
|
727
|
-
if (![...this.sites.values()].some((site) => site.share.
|
|
753
|
+
if (![...this.sites.values()].some((site) => site.share.listening)) this.closeLan();
|
|
728
754
|
}
|
|
729
755
|
closeLan() {
|
|
730
756
|
for (const server of this.lanServers.values()) {
|
|
@@ -732,8 +758,30 @@ var Hub = class {
|
|
|
732
758
|
server.closeAllConnections?.();
|
|
733
759
|
}
|
|
734
760
|
this.lanServers.clear();
|
|
761
|
+
this.lanNames.clear();
|
|
762
|
+
}
|
|
763
|
+
/** The machine's own names, as hostAllowed takes them. */
|
|
764
|
+
isMachineName(name) {
|
|
765
|
+
const machine = os2.hostname().toLowerCase();
|
|
766
|
+
return name === machine || name === `${machine.replace(/\.local$/, "")}.local`;
|
|
735
767
|
}
|
|
736
|
-
/**
|
|
768
|
+
/**
|
|
769
|
+
* Answer at `name` too while listening: an address listened on, the machine's name, or a name that resolves to an
|
|
770
|
+
* address listened on (looked up once, now: Tailscale's MagicDNS name, a hosts entry).
|
|
771
|
+
*/
|
|
772
|
+
async allowName(name) {
|
|
773
|
+
const lower = name.toLowerCase();
|
|
774
|
+
if (!lower || !this.lanServers.size) return false;
|
|
775
|
+
if (this.lanServers.has(lower) || this.isMachineName(lower) || this.lanNames.has(lower)) return true;
|
|
776
|
+
const found = await dns.lookup(lower, { all: true, family: 4 }).catch(() => []);
|
|
777
|
+
if (!found.some((entry) => this.lanServers.has(entry.address))) return false;
|
|
778
|
+
this.lanNames.add(lower);
|
|
779
|
+
return true;
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* A Host header this hub answers: its own names, and while something is shared (or open to its owner on another
|
|
783
|
+
* device), its LAN addresses, its machine name and the names allowName took.
|
|
784
|
+
*/
|
|
737
785
|
hostAllowed(request) {
|
|
738
786
|
const host = request.headers.host ?? "";
|
|
739
787
|
if (this.hosts.has(host)) return true;
|
|
@@ -741,8 +789,7 @@ var Hub = class {
|
|
|
741
789
|
const suffix = `:${this.port}`;
|
|
742
790
|
if (!host.endsWith(suffix)) return false;
|
|
743
791
|
const name = host.slice(0, -suffix.length).toLowerCase();
|
|
744
|
-
|
|
745
|
-
return this.lanServers.has(name) || name === machine || name === `${machine.replace(/\.local$/, "")}.local`;
|
|
792
|
+
return this.lanServers.has(name) || this.isMachineName(name) || this.lanNames.has(name);
|
|
746
793
|
}
|
|
747
794
|
// ---- requests --------------------------------------------------------------------------------------------------
|
|
748
795
|
/** The site whose address `pathname` is under, with the path below it. */
|
|
@@ -1330,6 +1377,12 @@ function commentsViewer(viewer) {
|
|
|
1330
1377
|
var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1331
1378
|
var esc = (text) => text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
1332
1379
|
var script = (value) => JSON.stringify(value).replace(/</g, "\\u003c");
|
|
1380
|
+
function hostName(host) {
|
|
1381
|
+
const value = host.trim().toLowerCase();
|
|
1382
|
+
if (value.startsWith("[")) return value.slice(1, value.indexOf("]") > 0 ? value.indexOf("]") : void 0);
|
|
1383
|
+
const colons = value.split(":").length - 1;
|
|
1384
|
+
return colons === 1 ? value.slice(0, value.indexOf(":")) : value;
|
|
1385
|
+
}
|
|
1333
1386
|
function localOwner() {
|
|
1334
1387
|
let name = "You";
|
|
1335
1388
|
try {
|
|
@@ -1351,6 +1404,8 @@ var ShareHost = class {
|
|
|
1351
1404
|
page;
|
|
1352
1405
|
/** Open to the local network now (the hub's listeners are up, for this artifact too). */
|
|
1353
1406
|
on = false;
|
|
1407
|
+
/** Open to its owner on another device (`open --remote`): the listeners are up, guests stay out unless `on`. */
|
|
1408
|
+
remote = false;
|
|
1354
1409
|
live = /* @__PURE__ */ new Set();
|
|
1355
1410
|
constructor(options) {
|
|
1356
1411
|
this.options = options;
|
|
@@ -1376,6 +1431,10 @@ var ShareHost = class {
|
|
|
1376
1431
|
get shared() {
|
|
1377
1432
|
return this.on;
|
|
1378
1433
|
}
|
|
1434
|
+
/** Someone may reach it over the network now (guests, or its owner on another device): the hub keeps listening. */
|
|
1435
|
+
get listening() {
|
|
1436
|
+
return this.on || this.remote;
|
|
1437
|
+
}
|
|
1379
1438
|
/** The artifact's name, for the join page and the list of what is open at the server's root. */
|
|
1380
1439
|
title() {
|
|
1381
1440
|
return this.options.title();
|
|
@@ -1398,30 +1457,61 @@ var ShareHost = class {
|
|
|
1398
1457
|
this.options.log(`shared on the local network: ${addresses.map((a) => `http://${a}:${this.port}${this.page}`).join(", ") || "(no LAN address)"}`);
|
|
1399
1458
|
return addresses;
|
|
1400
1459
|
}
|
|
1460
|
+
/**
|
|
1461
|
+
* Opens the artifact to its owner on another device, or closes that, and returns the owner's link, at `host` when
|
|
1462
|
+
* that is this computer's (the address the owner's browser already reaches it by), else at its first network address.
|
|
1463
|
+
* Guests are not let in by it: that is `setLan`.
|
|
1464
|
+
*/
|
|
1465
|
+
async setRemote(on, host) {
|
|
1466
|
+
if (!on) {
|
|
1467
|
+
this.remote = false;
|
|
1468
|
+
for (const live of [...this.live]) if (live.viewer.remote) live.close("unshared");
|
|
1469
|
+
this.network.release();
|
|
1470
|
+
return null;
|
|
1471
|
+
}
|
|
1472
|
+
const addresses = await this.network.open();
|
|
1473
|
+
if (!addresses.length) {
|
|
1474
|
+
this.network.release();
|
|
1475
|
+
throw new ShareError("This computer has no network address to open it at.");
|
|
1476
|
+
}
|
|
1477
|
+
this.remote = true;
|
|
1478
|
+
const name = hostName(host ?? "");
|
|
1479
|
+
const sorted = [...addresses].sort((a, b) => Number(isTailnet(a)) - Number(isTailnet(b)));
|
|
1480
|
+
const address = name && (addresses.includes(name) || await this.network.allowName?.(name)) ? name : sorted[0];
|
|
1481
|
+
const url = `http://${address.includes(":") ? `[${address}]` : address}:${this.port}${this.page}o/${this.sharing.ownerToken()}`;
|
|
1482
|
+
this.options.log(`open to its owner on another device at http://${address}:${this.port}${this.page}`);
|
|
1483
|
+
return { url, address };
|
|
1484
|
+
}
|
|
1401
1485
|
/** The artifact left the hub: it stays shared in .sharing.json, for the next time it opens. */
|
|
1402
1486
|
close() {
|
|
1403
1487
|
this.on = false;
|
|
1488
|
+
this.remote = false;
|
|
1404
1489
|
this.network.release();
|
|
1405
1490
|
}
|
|
1406
1491
|
// ---- who is asking ---------------------------------------------------------------------------------------------
|
|
1407
|
-
cookie(request) {
|
|
1492
|
+
cookie(request, name = this.options.cookieName) {
|
|
1408
1493
|
for (const part of (request.headers.cookie ?? "").split(";")) {
|
|
1409
1494
|
const eq = part.indexOf("=");
|
|
1410
|
-
if (eq > 0 && part.slice(0, eq).trim() ===
|
|
1495
|
+
if (eq > 0 && part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
|
|
1411
1496
|
}
|
|
1412
1497
|
return void 0;
|
|
1413
1498
|
}
|
|
1499
|
+
/** The cookie the owner's link leaves in a browser on another device. */
|
|
1500
|
+
get ownerCookie() {
|
|
1501
|
+
return `${this.options.cookieName}_owner`;
|
|
1502
|
+
}
|
|
1414
1503
|
/**
|
|
1415
|
-
* Who a request is: the owner over loopback,
|
|
1416
|
-
*
|
|
1417
|
-
*
|
|
1418
|
-
* what a role sees).
|
|
1504
|
+
* Who a request is: the owner over loopback, or by the owner's link's cookie while the artifact is open to them on
|
|
1505
|
+
* another device; a guest by their cookie (while their link is on and the artifact is shared), else nobody. Loopback
|
|
1506
|
+
* is always the owner, so trying a link on this computer can't lock the owner out of their own artifact; a guest
|
|
1507
|
+
* comes over a LAN address (this computer's own included: that is how the owner sees what a role sees).
|
|
1419
1508
|
*/
|
|
1420
1509
|
isLocal(request) {
|
|
1421
1510
|
return this.options.local ? this.options.local(request) : isLoopback(request.socket.remoteAddress);
|
|
1422
1511
|
}
|
|
1423
1512
|
viewerOf(request) {
|
|
1424
1513
|
if (this.isLocal(request)) return { role: "owner", user: this.options.owner };
|
|
1514
|
+
if (this.remote && this.sharing.isOwnerToken(this.cookie(request, this.ownerCookie))) return { role: "owner", user: this.options.owner, remote: true };
|
|
1425
1515
|
const guest = this.on ? this.sharing.guest(this.cookie(request)) : null;
|
|
1426
1516
|
if (!guest) return null;
|
|
1427
1517
|
this.sharing.seen(guest);
|
|
@@ -1455,7 +1545,8 @@ var ShareHost = class {
|
|
|
1455
1545
|
const p = new URL(request.url ?? "/", "http://local").pathname;
|
|
1456
1546
|
const method = request.method ?? "GET";
|
|
1457
1547
|
const link = /^\/s\/([A-Za-z0-9_-]{16,64})$/.exec(p);
|
|
1458
|
-
const
|
|
1548
|
+
const ownerLink = /^\/o\/([A-Za-z0-9_-]{16,64})$/.exec(p);
|
|
1549
|
+
const ours = link || ownerLink || p === "/__share" || p.startsWith("/__share/");
|
|
1459
1550
|
if (!ours) return false;
|
|
1460
1551
|
if (method === "POST" && !sameOrigin(request)) {
|
|
1461
1552
|
request.resume();
|
|
@@ -1467,6 +1558,12 @@ var ShareHost = class {
|
|
|
1467
1558
|
else response.writeHead(405, { Allow: "GET, POST" }).end();
|
|
1468
1559
|
return true;
|
|
1469
1560
|
}
|
|
1561
|
+
if (ownerLink) {
|
|
1562
|
+
request.resume();
|
|
1563
|
+
if (method === "GET" || method === "HEAD") this.ownerIn(request, response, ownerLink[1]);
|
|
1564
|
+
else response.writeHead(405, { Allow: "GET" }).end();
|
|
1565
|
+
return true;
|
|
1566
|
+
}
|
|
1470
1567
|
const viewer = this.viewerOf(request);
|
|
1471
1568
|
if (!viewer) {
|
|
1472
1569
|
this.denied(request, response);
|
|
@@ -1550,6 +1647,24 @@ var ShareHost = class {
|
|
|
1550
1647
|
const url = `http://127.0.0.1:${this.port}${this.page}`;
|
|
1551
1648
|
return `<p class="note">${esc(t.ownerHere)} <a href="${esc(url)}">${esc(url)}</a></p>`;
|
|
1552
1649
|
}
|
|
1650
|
+
/**
|
|
1651
|
+
* GET /o/<token>: the owner's link. On another device it leaves the owner's cookie (while the artifact is open to them
|
|
1652
|
+
* there) and goes on to the page, its query kept (`?comment=` opens a thread); over loopback it is the page anyway.
|
|
1653
|
+
*/
|
|
1654
|
+
ownerIn(request, response, token2) {
|
|
1655
|
+
const local = this.isLocal(request);
|
|
1656
|
+
if (!this.sharing.isOwnerToken(token2) || !local && !this.remote) {
|
|
1657
|
+
const t = this.words(request);
|
|
1658
|
+
return this.sendPage(response, 404, t.linkGoneTitle, `<h1>${esc(t.linkGoneTitle)}</h1><p>${esc(t.linkGone)}</p>`);
|
|
1659
|
+
}
|
|
1660
|
+
const search = new URL(request.url ?? "/", "http://local").search;
|
|
1661
|
+
const headers = { Location: `${this.page}${search}`, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" };
|
|
1662
|
+
if (!local) {
|
|
1663
|
+
headers["Set-Cookie"] = `${this.ownerCookie}=${token2}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000`;
|
|
1664
|
+
this.options.log(`share: the owner came in from another device (${request.socket.remoteAddress})`);
|
|
1665
|
+
}
|
|
1666
|
+
response.writeHead(303, headers).end();
|
|
1667
|
+
}
|
|
1553
1668
|
/** GET /s/<token>: the name form (or straight in, for a guest already in through this link); POST: join. */
|
|
1554
1669
|
async join(request, response, method, token2) {
|
|
1555
1670
|
const t = this.words(request);
|
|
@@ -1607,6 +1722,7 @@ var ShareHost = class {
|
|
|
1607
1722
|
return {
|
|
1608
1723
|
title: this.options.title(),
|
|
1609
1724
|
lan: this.on,
|
|
1725
|
+
remote: this.remote,
|
|
1610
1726
|
port: this.port,
|
|
1611
1727
|
page: this.page,
|
|
1612
1728
|
addresses,
|
|
@@ -1618,11 +1734,13 @@ var ShareHost = class {
|
|
|
1618
1734
|
}
|
|
1619
1735
|
/**
|
|
1620
1736
|
* The owner's sharing calls: `lan` {on}, `link` {role, on}, `reset` {role} (a new link: the old one and the guests
|
|
1621
|
-
* who came through it stop working), `role` {guest, role} (a guest's role, while
|
|
1622
|
-
* {guest}
|
|
1737
|
+
* who came through it stop working; role `owner` the owner's own link), `role` {guest, role} (a guest's role, while
|
|
1738
|
+
* that role's link is on), `remove` {guest}, `remote` {on, host?} (open to the owner on another device: the answer
|
|
1739
|
+
* then has `remoteLink` {url, address}, the owner's link). Answers the view after.
|
|
1623
1740
|
*/
|
|
1624
1741
|
async share(action) {
|
|
1625
1742
|
const role = action.role;
|
|
1743
|
+
let remote = null;
|
|
1626
1744
|
switch (action.action) {
|
|
1627
1745
|
case "lan": {
|
|
1628
1746
|
const addresses = await this.setLan(action.on === true);
|
|
@@ -1634,9 +1752,17 @@ var ShareHost = class {
|
|
|
1634
1752
|
this.sharing.setLink(role, action.on === true);
|
|
1635
1753
|
break;
|
|
1636
1754
|
case "reset":
|
|
1637
|
-
if (
|
|
1755
|
+
if (role === "owner") {
|
|
1756
|
+
this.sharing.resetOwner();
|
|
1757
|
+
for (const live of [...this.live]) if (live.viewer.remote) live.close("revoked");
|
|
1758
|
+
break;
|
|
1759
|
+
}
|
|
1760
|
+
if (!isGuestRole(role)) throw new ShareError("role: owner, editor, commenter or viewer");
|
|
1638
1761
|
this.sharing.resetLink(role);
|
|
1639
1762
|
break;
|
|
1763
|
+
case "remote":
|
|
1764
|
+
remote = await this.setRemote(action.on === true, typeof action.host === "string" ? action.host : void 0);
|
|
1765
|
+
break;
|
|
1640
1766
|
case "role": {
|
|
1641
1767
|
if (!isGuestRole(role)) throw new ShareError("role: editor, commenter or viewer");
|
|
1642
1768
|
if (typeof action.guest !== "string" || !this.sharing.state.guests.some((g) => g.id === action.guest)) throw new ShareError("no such guest");
|
|
@@ -1648,11 +1774,11 @@ var ShareHost = class {
|
|
|
1648
1774
|
if (typeof action.guest !== "string" || !this.sharing.removeGuest(action.guest)) throw new ShareError("no such guest");
|
|
1649
1775
|
break;
|
|
1650
1776
|
default:
|
|
1651
|
-
throw new ShareError("action: lan, link, reset, role or
|
|
1777
|
+
throw new ShareError("action: lan, link, reset, role, remove or remote");
|
|
1652
1778
|
}
|
|
1653
1779
|
this.options.log(`share: ${JSON.stringify(action)}`);
|
|
1654
1780
|
this.dropRevoked();
|
|
1655
|
-
return this.shareView();
|
|
1781
|
+
return { ...this.shareView(), ...remote ? { remoteLink: remote } : {} };
|
|
1656
1782
|
}
|
|
1657
1783
|
async shareApi(request, response, method) {
|
|
1658
1784
|
if (method === "GET") return sendJson(response, 200, this.shareView());
|
|
@@ -2906,11 +3032,11 @@ async function shareCall(url, action, fail2) {
|
|
|
2906
3032
|
if (!response.ok) fail2("SHARE_REFUSED", String(body.message ?? `the server answered ${response.status}`));
|
|
2907
3033
|
return body;
|
|
2908
3034
|
}
|
|
2909
|
-
function rolesOf(flags, key, fail2) {
|
|
3035
|
+
function rolesOf(flags, key, fail2, owner = false) {
|
|
2910
3036
|
const given = flags.get(key);
|
|
2911
3037
|
if (given === void 0) return [];
|
|
2912
3038
|
const roles = given === "all" || given === "true" ? [...GUEST_ROLES] : given.split(",").map((r) => r.trim());
|
|
2913
|
-
for (const role of roles) if (!isGuestRole(role)) fail2("INVALID_ROLE", `--${key}: editor, commenter, viewer or all (got ${role}).`);
|
|
3039
|
+
for (const role of roles) if (!isGuestRole(role) && !(owner && role === "owner")) fail2("INVALID_ROLE", `--${key}: ${owner ? "owner, " : ""}editor, commenter, viewer or all (got ${role}).`);
|
|
2914
3040
|
return roles;
|
|
2915
3041
|
}
|
|
2916
3042
|
function guestRoles(flags, fail2) {
|
|
@@ -2925,7 +3051,7 @@ function guestRoles(flags, fail2) {
|
|
|
2925
3051
|
async function shareCommand(o) {
|
|
2926
3052
|
const off = o.flags.has("off");
|
|
2927
3053
|
const changes = [
|
|
2928
|
-
...rolesOf(o.flags, "reset", o.fail).map((role) => ({ action: "reset", role })),
|
|
3054
|
+
...rolesOf(o.flags, "reset", o.fail, true).map((role) => ({ action: "reset", role })),
|
|
2929
3055
|
...rolesOf(o.flags, "enable", o.fail).map((role) => ({ action: "link", role, on: true })),
|
|
2930
3056
|
...rolesOf(o.flags, "disable", o.fail).map((role) => ({ action: "link", role, on: false })),
|
|
2931
3057
|
...guestRoles(o.flags, o.fail)
|
|
@@ -2935,7 +3061,8 @@ async function shareCommand(o) {
|
|
|
2935
3061
|
if (!server) {
|
|
2936
3062
|
const sharing = new Sharing(o.root);
|
|
2937
3063
|
for (const change of changes) {
|
|
2938
|
-
if (change.action === "reset") sharing.
|
|
3064
|
+
if (change.action === "reset" && change.role === "owner") sharing.resetOwner();
|
|
3065
|
+
else if (change.action === "reset") sharing.resetLink(change.role);
|
|
2939
3066
|
else if (change.action === "role") {
|
|
2940
3067
|
if (!sharing.setRole(String(change.guest), change.role)) o.fail("SHARE_REFUSED", `No guest ${String(change.guest)}, or the ${String(change.role)} link is off.`);
|
|
2941
3068
|
} else sharing.setLink(change.role, change.on === true);
|
|
@@ -2981,6 +3108,30 @@ async function shareCommand(o) {
|
|
|
2981
3108
|
}
|
|
2982
3109
|
var NOT_SHARED_HINT = "share <root> with no options shares it.";
|
|
2983
3110
|
var lanUrls = (view) => (Array.isArray(view.addresses) ? view.addresses : []).map((a) => `http://${String(a)}:${String(view.port)}${String(view.page ?? "/")}`);
|
|
3111
|
+
async function remoteOn(url, flags, fail2, stopCommand) {
|
|
3112
|
+
const given = flags.get("remote");
|
|
3113
|
+
if (given === void 0) return {};
|
|
3114
|
+
const call = async (action) => {
|
|
3115
|
+
const response = await fetch(new URL("__share", url), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(action) });
|
|
3116
|
+
const body = await response.json().catch(() => ({}));
|
|
3117
|
+
if (response.ok) return body;
|
|
3118
|
+
if (response.status === 400 && /^action: /.test(String(body.message ?? ""))) {
|
|
3119
|
+
fail2("REMOTE_UNSUPPORTED", `The editor server running for this folder is of an earlier release and cannot open it to another device. Run ${stopCommand}, then open again.`);
|
|
3120
|
+
}
|
|
3121
|
+
return fail2("SHARE_REFUSED", String(body.message ?? `the server answered ${response.status}`));
|
|
3122
|
+
};
|
|
3123
|
+
if (given === "off") {
|
|
3124
|
+
await call({ action: "remote", on: false });
|
|
3125
|
+
return { remote: false };
|
|
3126
|
+
}
|
|
3127
|
+
const view = await call({ action: "remote", on: true, ...given && given !== "true" ? { host: given } : {} });
|
|
3128
|
+
const link = view.remoteLink;
|
|
3129
|
+
if (typeof link?.url !== "string") return fail2("SHARE_REFUSED", "The server did not give the owner's link.");
|
|
3130
|
+
return {
|
|
3131
|
+
remoteUrl: link.url,
|
|
3132
|
+
remoteNote: `remoteUrl opens the editor as its owner from another device that reaches this computer at ${String(link.address)} (an app reached remotely, a phone, a laptop on the same network or tailnet). It is the owner's own key, over plain http: open it only on your own devices and give it to nobody (to let others in, \`share\`). \`--remote=off\` closes it; \`share <root> --reset owner\` replaces it.`
|
|
3133
|
+
};
|
|
3134
|
+
}
|
|
2984
3135
|
async function lanOn(url, flags, fail2) {
|
|
2985
3136
|
if (!flags.has("lan")) return {};
|
|
2986
3137
|
return { lan: lanUrls(await shareCall(url, { action: "lan", on: true }, fail2)) };
|
|
@@ -3902,7 +4053,7 @@ function print(value, compact = false) {
|
|
|
3902
4053
|
process.stdout.write(`${compact ? JSON.stringify(value) : JSON.stringify(value, null, 2)}
|
|
3903
4054
|
`);
|
|
3904
4055
|
}
|
|
3905
|
-
var BARE_FLAGS = ["text", "json", "wait", "all", "resolve", "reopen", "lan", "off", "force"];
|
|
4056
|
+
var BARE_FLAGS = ["text", "json", "wait", "all", "resolve", "reopen", "lan", "remote", "off", "force"];
|
|
3906
4057
|
function parseArgs(argv) {
|
|
3907
4058
|
const positional = [];
|
|
3908
4059
|
const flags = /* @__PURE__ */ new Map();
|
|
@@ -4822,12 +4973,12 @@ var lanOn2 = (url, flags) => lanOn(url, flags, fail);
|
|
|
4822
4973
|
async function open(root, flags) {
|
|
4823
4974
|
const existing = await runningServer2(root);
|
|
4824
4975
|
if (existing) {
|
|
4825
|
-
print({ url: existing.url, root, pid: existing.pid, reused: true, ...await lanOn2(existing.url, flags), log: path9.join(root, LOG_FILE), stop: cmd("stop", root) }, true);
|
|
4976
|
+
print({ url: existing.url, root, pid: existing.pid, reused: true, ...await lanOn2(existing.url, flags), ...await remoteOn(existing.url, flags, fail, cmd("stop", root)), log: path9.join(root, LOG_FILE), stop: cmd("stop", root) }, true);
|
|
4826
4977
|
return;
|
|
4827
4978
|
}
|
|
4828
4979
|
const state = await startBackground(root, flags);
|
|
4829
4980
|
const empty = !fs10.existsSync(docFile(root, ["board", "meta"]));
|
|
4830
|
-
print({ url: state.url, root, pid: state.pid, ...state.lan?.length ? { lan: state.lan } : {}, log: path9.join(root, LOG_FILE), stop: cmd("stop", root), ...empty ? { note: "board/meta is not written yet: the page shows an empty board until it is." } : {} }, true);
|
|
4981
|
+
print({ url: state.url, root, pid: state.pid, ...state.lan?.length ? { lan: state.lan } : {}, ...await remoteOn(state.url, flags, fail, cmd("stop", root)), log: path9.join(root, LOG_FILE), stop: cmd("stop", root), ...empty ? { note: "board/meta is not written yet: the page shows an empty board until it is." } : {} }, true);
|
|
4831
4982
|
}
|
|
4832
4983
|
async function startBackground(root, flags) {
|
|
4833
4984
|
const port = flags.has("port") ? Number(flags.get("port")) : DEFAULT_PORT;
|
|
@@ -5022,6 +5173,8 @@ Usage:
|
|
|
5022
5173
|
node scripts/artifact.mjs serve <root> [--port n] [--lan] the same server, in the foreground
|
|
5023
5174
|
[--agent-hook "<command>"] open/serve: run this for each comment sent to the agent
|
|
5024
5175
|
(the comment as JSON on stdin); --lan: shared too
|
|
5176
|
+
[--remote[=<host>]] open: also open it to you on another device (<host>: how
|
|
5177
|
+
that device reaches this computer); prints remoteUrl
|
|
5025
5178
|
node scripts/artifact.mjs stop <root> stop the background board server
|
|
5026
5179
|
node scripts/artifact.mjs share <root> share on the local network; prints a link per role
|
|
5027
5180
|
[--off] [--reset R] [--enable R] [--disable R]
|