erdraft 0.0.1 → 0.0.2
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 +30 -13
- package/dist/{chunk-SSLBX2OF.js → chunk-IQ5YYSCP.js} +323 -21
- package/dist/cli.js +277 -26
- package/dist/{studio-config-TIVT4MNY.js → studio-config-TCMRAVMR.js} +1 -1
- package/dist/window/assets/index-BCt7ERad.css +2 -0
- package/dist/window/assets/index-v2x3QhC-.js +132 -0
- package/dist/window/index.html +2 -2
- package/package.json +7 -1
- package/dist/window/assets/index-B5sqm8jk.css +0 -2
- package/dist/window/assets/index-Dk6ZQwtE.js +0 -132
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Erdraft
|
|
2
2
|
|
|
3
|
-
A project's data model, drafted with an agent while a person watches. The agent reads the schema and proposes changes through Erdraft's MCP (or its CLI); each proposal is a **draft**, drawn at once in a window beside the schema the code has, its tables and columns marked added, changed or removed. Asked for ideas, the agent proposes two or three **alternatives** in one group, compared a tab each. The person **adopts** one; only then does the agent write it in the code. Erdraft never changes the code nor the database.
|
|
3
|
+
A project's data model, drafted with an agent while a person watches. The agent reads the schema and proposes changes through Erdraft's MCP (or its CLI); each proposal is a **draft**, drawn at once in a window beside the schema the code has, its tables and columns marked added, changed or removed. Asked for ideas, the agent proposes two or three **alternatives** in one group, compared a tab each, an arrow at each end of the tabs going to the one before or after (the tabs scroll where there are more than the card holds). The person **adopts** one; only then does the agent write it in the code. Erdraft never changes the code nor the database.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -14,7 +14,16 @@ With pnpm, allow esbuild's install script when it asks (`pnpm approve-builds`):
|
|
|
14
14
|
|
|
15
15
|
## Set it up
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
From the project's root:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npx erdraft init # .erdraft.json, Erdraft's MCP in .mcp.json, the skill for the project's agents
|
|
21
|
+
npx erdraft studio # the window, at http://localhost:9369 (the schema read from the code as it opens)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`init` finds the schema in the module drizzle-kit's config names (`drizzle.config.ts` and its kin, at the root or in a package of `packages/` or `apps/`), else where schemas usually are (`src/db/schema.ts`, `src/server/db/schema.ts`...), and names the project after its `package.json`, so every checkout of it shares its drafts whatever its folder is called. It reads the schema before it ends, to show it is one Erdraft reads, and says what to do next. Give it the module when it finds none, or when drizzle-kit's config names several or a glob (Erdraft reads one module that exports every table), and another name: `npx erdraft init src/db/index.ts --name Acme`. Run again, it keeps what is there; `--force` writes `.erdraft.json` and the skill over.
|
|
25
|
+
|
|
26
|
+
What it writes is a `.erdraft.json`, which says where the schema is and the project's name, its drafts kept by it:
|
|
18
27
|
|
|
19
28
|
```json
|
|
20
29
|
{
|
|
@@ -23,13 +32,7 @@ A `.erdraft.json` at the project's root says where its schema is, and the projec
|
|
|
23
32
|
}
|
|
24
33
|
```
|
|
25
34
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
```sh
|
|
29
|
-
npx erdraft studio # the window, at http://localhost:9369 (the schema read from the code as it opens)
|
|
30
|
-
npx erdraft install-skill # the skill, in .claude/skills/erdraft, for the project's agents
|
|
31
|
-
npx erdraft --help # every command
|
|
32
|
-
```
|
|
35
|
+
and Erdraft's server beside the others in Claude Code's `.mcp.json`; any agent that speaks MCP over stdio runs `erdraft mcp` the same way:
|
|
33
36
|
|
|
34
37
|
```json
|
|
35
38
|
{
|
|
@@ -39,7 +42,7 @@ npx erdraft --help # every command
|
|
|
39
42
|
}
|
|
40
43
|
```
|
|
41
44
|
|
|
42
|
-
|
|
45
|
+
Keep it in the project (`npm install --save-dev erdraft`, or the project's package manager's), so `npx erdraft` runs the version installed rather than the latest; `npx erdraft --help` says every command.
|
|
43
46
|
|
|
44
47
|
## Staying up to date
|
|
45
48
|
|
|
@@ -53,6 +56,12 @@ Ask in your own words, *propose two alternatives for tagging projects*, from eit
|
|
|
53
56
|
- **The bell**, at the rail's foot over the theme, allows the browser's notifications (the browser asks once): a draft the agent proposes while the window is not in front is told in a notification, its id and title, and the window opens on it when it is clicked. The bell turns them off and on after, kept; refused, it says where the browser's settings allow them again.
|
|
54
57
|
- **Your agent's session**: with the MCP in the project's `.mcp.json`, a Claude Code session started after it was added offers its tools once approved.
|
|
55
58
|
|
|
59
|
+
## Share it
|
|
60
|
+
|
|
61
|
+
**Share**, at the top bar's end before the assistant, gives a link to the window over the internet, for someone on another machine to follow the schema and the drafts as they change: **Create a link**, then **Copy link**, and **Stop sharing** when done (the window's process ending stops it too). The link opens on what the window shows as it is copied (the draft open, the table in focus, the relationships drawn: the address's query). Whoever opens the link only reads: the window says *View only*, with no Share, no assistant, no Adopt or Set aside, no draft's bin, no database's menu (its name alone), no Connect an agent (the MCP runs on this machine alone) and no Read the schema again, and what it is shown through refuses whatever would change something.
|
|
62
|
+
|
|
63
|
+
The link goes through a tunnel provider, ngrok for now (`tunnel` in `.erdraft.json`), through its SDK in Erdraft's process, with no ngrok program to install: add the SDK to the project (`npm install --save-dev @ngrok/ngrok`, an optional peer of Erdraft's). The first time, the panel asks for an ngrok account's authtoken (a free account's, from [its dashboard](https://dashboard.ngrok.com/get-started/your-authtoken), *Get one*), and keeps it in the system's keychain once a link has opened with it (macOS's Keychain, Windows' Credential Manager, Linux's Secret Service; never in a file), so it is not asked again; *Forget it* takes it out. An authtoken ngrok refuses is said and not kept; where the keychain cannot keep one, it is kept until Erdraft stops. `NGROK_AUTHTOKEN`, when set, goes before the one kept, and the panel says so. On ngrok's free plan, whoever opens the link is shown ngrok's warning page once before the window.
|
|
64
|
+
|
|
56
65
|
## Commands
|
|
57
66
|
|
|
58
67
|
```text
|
|
@@ -61,6 +70,9 @@ erdraft: a project's data model, drafted with an agent while a person watches
|
|
|
61
70
|
Usage: erdraft <command> [options] [--database <id>]
|
|
62
71
|
erdraft --version
|
|
63
72
|
|
|
73
|
+
init [<schema>] [--name <name>] [--force]
|
|
74
|
+
Set the project up: .erdraft.json (its Drizzle schema found, or the one given), the MCP
|
|
75
|
+
in .mcp.json, the skill for its agents
|
|
64
76
|
studio Open the window (the diagram, live)
|
|
65
77
|
mcp Serve the MCP on stdin and stdout, for an agent
|
|
66
78
|
read Read the schema again from the code
|
|
@@ -99,6 +111,7 @@ where the databases' schemas and their drafts are.
|
|
|
99
111
|
| `port` | `9369` | The window's port, on 127.0.0.1: none given, 9369, unassigned by IANA and outside every system's ephemeral range (decisions/0006) |
|
|
100
112
|
| `claude` | `claude` | The Claude Code the assistant runs |
|
|
101
113
|
| `model` | `sonnet` | The assistant's model, an alias or a model's full name |
|
|
114
|
+
| `tunnel` | `ngrok` | The provider the window is shared through (Share it); none given, ngrok, the one there is so far |
|
|
102
115
|
|
|
103
116
|
## Where a field keeps its value
|
|
104
117
|
|
|
@@ -142,12 +155,13 @@ A field is known by its label (its `aria-label`, the text it is labelled by, its
|
|
|
142
155
|
|
|
143
156
|
## How it works
|
|
144
157
|
|
|
145
|
-
- **The base** is the schema as the code has it: the Drizzle module read by tsx in a process of its own each time (a table changed since is read as it is now; what the module prints never reaches the MCP), by the paths of the `tsconfig.json` nearest it, an ES module's or a CommonJS project's alike, its tables (one in a schema of its own, `pgSchema('auth')`, named `auth.users`), columns, keys, indexes (a unique constraint of several columns among them), checks and enums written to `base.json` (`read`, `read_schema`, and each time the window opens). A module with no Postgres table is refused, a MySQL or SQLite one among them.
|
|
158
|
+
- **The base** is the schema as the code has it, read again from the top bar's arrows after the page's name (Read the schema again, `POST /api/read`, every window and the shared link drawing it at once, no page loaded again; a module that cannot be read said beside them), never as the module is saved (a file saved half written would break the read): the Drizzle module read by tsx in a process of its own each time (a table changed since is read as it is now; what the module prints never reaches the MCP), by the paths of the `tsconfig.json` nearest it, an ES module's or a CommonJS project's alike, its tables (one in a schema of its own, `pgSchema('auth')`, named `auth.users`), columns, keys, indexes (a unique constraint of several columns among them), checks and enums written to `base.json` (`read`, `read_schema`, and each time the window opens). A module with no Postgres table is refused, a MySQL or SQLite one among them.
|
|
146
159
|
- **A draft** is a list of operations on the base (`add_table`, `drop_table`, `rename_table`, `add_column`, `drop_column`, `alter_column`, `add_foreign_key`, `drop_foreign_key`, `add_index`, `drop_index`, `add_check`, `drop_check`, `add_enum`, `set_enum_values`, `drop_enum`), applied in order. One that cannot be applied is refused, naming its step; a base read again keeps every draft on it, and a draft that no longer applies says why in the window (one set aside says nothing of it), unless the base holds what it comes to (adopted, then written in the code: the draft laid over the code's schema, each table and enum it touches the same there as in the code, its columns and checks by name, its foreign keys by what they join, its indexes by their columns, an enum's values; what its own steps added and took away again gone from both, what the code holds more kept; `src/core/written-in.ts`), which the window, `show_draft` and `erdraft draft` say instead, the diagram the schema as the code has it with what the draft did marked on it, as a draft still proposed is marked (the tables and columns it added, those it changed and named again, the keys, indexes and checks it added; `src/core/mark-written.ts`).
|
|
147
160
|
- **The store** is changed by one process at a time, every other waiting its turn (a `.lock` file in it): the CLI, the MCP and the window read a file, change it and write it whole, so an agent proposing three alternatives at once keeps all three, each its own number.
|
|
148
161
|
- **The window's server** listens on 127.0.0.1 and answers its own page alone: a request to its API from another site's page, or to another host than this machine (a name turned to 127.0.0.1), is refused, so a page the person has open can neither read the schema nor adopt a draft nor start the assistant. The inspector's routes answer any page of this machine, the project's app.
|
|
162
|
+
- **Sharing** (`POST /api/share`, the credential typed in its body; `DELETE` to stop; `DELETE /api/share/credential` to forget the one kept; the link in the state, and where the credential comes from, never the credential): a second server on a port the system gives, on 127.0.0.1 (`src/share/serve-shared.ts`), is what the tunnel reaches. The link's token (24 random bytes, `?share=`) is kept in an HttpOnly cookie and taken out of the address, and every request without it refused; with it, GET and HEAD alone are passed on to the window's own server as a page of this machine asks them (of `localhost`, with no `Origin` and no cookie), of the API only `/api/state` and `/api/events`. A provider is a `TunnelProvider` (`src/share/tunnel-provider.ts`: its name, the credential it opens with, its name, where one is got and its environment variable, and `open(port, credential)` its address and `close`), named in `src/share/tunnels.ts`, its credential kept by the keychain under `erdraft` and the provider's id (`src/share/keychain.ts`, `@napi-rs/keyring`, loaded when a credential is first asked for); it must carry server-sent events, or the shared window never changes (decisions/0008). Run from Erdraft's source, the window is Vite's, and the link reaches what Vite serves.
|
|
149
163
|
- **The diff** sets the draft beside the base, a table or column renamed matched to its old name, a foreign key matched by what it joins.
|
|
150
|
-
- **The window** (Vite, React, React Flow) keeps what it shows in its address (`?draft=d3&table=users&relations=users`), so a reload or a link opens it again (a draft's card closed from its X, the schema shown again where the view stood, never framed whole, the table in focus and the relationships drawn kept), and Back and Forward go from screen to screen (another draft or other relationships a step; the focus alone none). It fetches the state and hears the store's folder change through server-sent events: a draft proposed by the agent is shown within a second, and selected. Its tables keep the base's places
|
|
164
|
+
- **The window** (Vite, React, React Flow) keeps what it shows in its address (`?draft=d3&table=users&relations=users`), so a reload or a link opens it again (a draft's card closed from its X, the schema shown again where the view stood, never framed whole, the table in focus and the relationships drawn kept), and Back and Forward go from screen to screen (another draft or other relationships a step; the focus alone none). It fetches the state and hears the store's folder change through server-sent events: a draft proposed by the agent is shown within a second, and selected. Its tables keep the base's places, a table a draft adds at the nearest free place beside a table it joins; the base laid out as the person arranges it from the controls (Arrange tables, kept in the browser for every view): top to bottom (dagre, the default), left to right (dagre's ranks across), or square (columns as many as make the diagram as wide as it is high, each card under the shortest, in the order the ranks read; `positionsOf`, `lib/graph.ts`), every card going where the arrangement lays it as a step undone like a drag, the cards moved by hand in the view shown too. The diagram opens already framed (on the table in focus, else on what a draft changes, else whole), never zoomed to as it appears; panned away, the controls' target (Center the tables in focus, beside the whole diagram's) brings those tables back to the middle of the view, and the draft card's own, at its bottom left (Center its tables under the pointer), the tables the draft changes (adopted and written in the code, or no longer applying, the tables its operations touch that the code has, `src/core/touched-tables.ts`), the table in focus let go. The lines run under the cards, each in a tone of its own (five hues, its hue halfway to its foreground so each reads on the canvas light or dark), those meeting at a card each another, so a person presenting the diagram can say which they mean (a line a draft adds or takes away in the colour of what it does), each a lane apart from the others sharing a card (`TURN` and `LANE`, `lib/graph.ts`), never one over another, and running across in the gap between its two cards where they stand now, never behind one (`LaneEdge`, `lib/route.ts`); a line clicked lights the two tables it joins and the columns it joins, frames them and fades the rest, let go with a click on the pane or a table. A card the person drags stays where they leave it, kept in the store's `layouts.json` (shared by every workspace) by what the window showed (the schema, a draft, a table's relationships in either), for every window and the next visit; Reset layout, in the controls, sets it back to the diagram's own. Each drag (several cards dragged together one) is undone and done again from the controls' arrows or with ⌘Z and ⇧⌘Z (Ctrl+Z, Ctrl+Y), never while writing in a field.
|
|
151
165
|
- **The assistant** (`POST /api/assistant`) runs `claude -p` from the project's folder, the person's words on its stdin (never among its arguments), with `--strict-mcp-config` (Erdraft's MCP alone), `--tools ""` and `--allowedTools mcp__erdraft`, its stream (`--output-format stream-json --include-partial-messages`) read into the window's events: words as they are written, each tool as a step, the session to `--resume`. Stopping it or closing the panel stops the agent.
|
|
152
166
|
- **The window follows the agent**: asked to see a table, its relationships or a draft, the agent calls `show_in_window` (the CLI, `show`), which writes the workspace's `view.json` in the store; the window, hearing the folder change, opens the draft, focuses the table and, for its relationships, draws only it and the tables it joins by a foreign key either way, laid out on their own, under the card the way back (Show all); the person asks for a draft's tables' from its card, beside Center (the tables joined by commas in the address, `?relations=wiki_pages,wiki_page_stars`), and for a table's own from its card's header (Show its relationships beside its count, Show all tables there while they are drawn), and goes along a foreign key from its column's link, an arrow under the pointer (Go to its table): the table it points to focused, the column it points to lit, every table drawn again when it is out of the relationships drawn. An ask made before the window opened is not applied.
|
|
153
167
|
- **Deleting** is the person's too: a draft's bin in the column, said again in a dialog, or `erdraft delete <id>`; the draft goes for good with its layouts, and its id is never given again (`sequence.json`). The agent only sets a draft aside.
|
|
@@ -182,7 +196,8 @@ skill/SKILL.md The skill install-skill writes into a project (src/core/
|
|
|
182
196
|
src/core/ The schema read from Drizzle (in a process of its own, read-schema-child.ts), the operations, the diff, the store and its lock, the config, the text the CLI and the MCP answer with
|
|
183
197
|
src/cli/run.ts The CLI's commands
|
|
184
198
|
src/mcp/create-server.ts The MCP server and its tools
|
|
185
|
-
src/server/ The window's API (state, events, adopt, discard, the assistant, the inspector's) and what it refuses (refusal-of.ts), Claude Code's stream read, the built window served (serve-window.ts), its Vite config
|
|
199
|
+
src/server/ The window's API (state, events, adopt, discard, the schema read again, the share, the assistant, the inspector's) and what it refuses (refusal-of.ts), Claude Code's stream read, the built window served (serve-window.ts), its Vite config
|
|
200
|
+
src/share/ The window shared over the internet (decisions/0008): the providers (tunnel-provider.ts, tunnels.ts, providers/ngrok.ts), their credentials in the system's keychain (keychain.ts), the read-only server the tunnel reaches (serve-shared.ts), one link at a time (create-share.ts)
|
|
186
201
|
src/inspect/ The inspector: the Drizzle logger and the rows it reads from a statement, the value matched, and the overlay a project's app draws (its fields, their changes, the browser's storage)
|
|
187
202
|
src/ui/ The window: app.tsx, its hooks, its components (the assistant's panel and composer among them), its contexts (contexts/relations.ts, what a table's card reaches of the diagram), the diagram's layout (lib/graph.ts), the conversation's messages (lib/assistant-message.ts)
|
|
188
203
|
src/test-utils/ A small Drizzle schema and the fakes the tests use
|
|
@@ -197,6 +212,8 @@ pnpm bundle # dist/: the CLI and the inspector (tsup), the window (V
|
|
|
197
212
|
|
|
198
213
|
- **A window open keeps the core it started with.** Vite brings a change to `src/ui/` into it at once, but its server applies the drafts with the `src/core/` it loaded: a change to the core (a column's fields, the diff, how a draft is read) reaches it only once `studio` is started again, and an agent's MCP only once it is connected again. The CLI runs the source afresh each time.
|
|
199
214
|
- **A change is looked at in a window of its own,** never by stopping the one a person is using: `studio` started from a folder whose `.erdraft.json` gives another `port` and the same `schema` and `store` (absolute paths), so it shows the same drafts; photographed in a headless browser, then stopped (2026-11-03, a json column's shape on port 4101).
|
|
215
|
+
- **A control added to the window says what a shared window does with it.** Whoever opens a shared link only reads: nothing that changes something (adopt, set aside, delete, the database switched or added, the assistant, Share, Read the schema again) nor that belongs to the developer's machine (Connect an agent, its MCP on stdio) is drawn there, `viewOnly()` (`src/ui/lib/view-only.ts`) deciding, and the share's server refusing it whatever the page shows (decisions/0008; 2026-11-04, the owner's correction: Add a database in a shared window).
|
|
216
|
+
- **A shared window is looked at without a tunnel:** `serveShared(<the test window's port>, <a token>)` (`src/share/serve-shared.ts`) in a script, and Chromium launched with `--host-resolver-rules=MAP shared.test 127.0.0.1`, the share's cookie (`erdraft_share`, the token) added to its context before the page is opened, as a `Secure` cookie is not kept over http; a host that is not this machine's is what turns the window to View only (2026-11-04, port 9398).
|
|
200
217
|
|
|
201
218
|
The source runs as TypeScript through tsx; Node does not strip the types of a file inside `node_modules`, so the package ships it built (`prepack`): `dist/cli.js`, the inspector's two entries with their types, and the window Vite built, which the CLI serves with its API beside it, where from the source Vite serves it. It is published with pnpm (`pnpm publish`), which writes `publishConfig`'s `bin` and `exports`, the built files', over the source's; `npm publish` is refused (`prepublishOnly`), as it would publish a package whose command is not in it.
|
|
202
219
|
|
|
@@ -750,7 +750,7 @@ function store(folder, workspace = "default") {
|
|
|
750
750
|
}
|
|
751
751
|
|
|
752
752
|
// package.json
|
|
753
|
-
var version = "0.0.
|
|
753
|
+
var version = "0.0.2";
|
|
754
754
|
|
|
755
755
|
// src/core/version.ts
|
|
756
756
|
var VERSION = version;
|
|
@@ -1703,7 +1703,7 @@ ${describeDiff(diffDraft(schema, draft.operations))}`;
|
|
|
1703
1703
|
}
|
|
1704
1704
|
|
|
1705
1705
|
// src/server/create-api.ts
|
|
1706
|
-
import { z as
|
|
1706
|
+
import { z as z10 } from "zod";
|
|
1707
1707
|
import { mkdirSync, watch } from "fs";
|
|
1708
1708
|
import { text as text4 } from "stream/consumers";
|
|
1709
1709
|
|
|
@@ -1796,7 +1796,7 @@ function promptOf(config, context) {
|
|
|
1796
1796
|
...seen.filter(Boolean)
|
|
1797
1797
|
].join("\n\n");
|
|
1798
1798
|
}
|
|
1799
|
-
function argumentsOf(config,
|
|
1799
|
+
function argumentsOf(config, ask2) {
|
|
1800
1800
|
return [
|
|
1801
1801
|
"-p",
|
|
1802
1802
|
"--output-format",
|
|
@@ -1815,8 +1815,8 @@ function argumentsOf(config, ask) {
|
|
|
1815
1815
|
"--model",
|
|
1816
1816
|
config.model,
|
|
1817
1817
|
"--append-system-prompt",
|
|
1818
|
-
promptOf(config,
|
|
1819
|
-
...
|
|
1818
|
+
promptOf(config, ask2.context),
|
|
1819
|
+
...ask2.session ? ["--resume", ask2.session] : []
|
|
1820
1820
|
];
|
|
1821
1821
|
}
|
|
1822
1822
|
function send(response, event) {
|
|
@@ -1825,7 +1825,7 @@ function send(response, event) {
|
|
|
1825
1825
|
`);
|
|
1826
1826
|
}
|
|
1827
1827
|
function createAssistant(config, spawn = spawnProcess) {
|
|
1828
|
-
return async function
|
|
1828
|
+
return async function ask2(request, response) {
|
|
1829
1829
|
const asked = askSchema.safeParse(JSON.parse(await text2(request) || "{}"));
|
|
1830
1830
|
if (!asked.success) {
|
|
1831
1831
|
response.writeHead(400, { "content-type": "application/json" });
|
|
@@ -1872,8 +1872,260 @@ function createAssistant(config, spawn = spawnProcess) {
|
|
|
1872
1872
|
};
|
|
1873
1873
|
}
|
|
1874
1874
|
|
|
1875
|
-
// src/
|
|
1875
|
+
// src/share/create-share.ts
|
|
1876
|
+
import { randomBytes } from "crypto";
|
|
1877
|
+
|
|
1878
|
+
// src/share/keychain.ts
|
|
1879
|
+
var SERVICE = "erdraft";
|
|
1880
|
+
async function entryOf(account) {
|
|
1881
|
+
const { Entry } = await import("@napi-rs/keyring");
|
|
1882
|
+
return new Entry(SERVICE, account);
|
|
1883
|
+
}
|
|
1884
|
+
var keychain = {
|
|
1885
|
+
async read(account) {
|
|
1886
|
+
try {
|
|
1887
|
+
return (await entryOf(account)).getPassword();
|
|
1888
|
+
} catch {
|
|
1889
|
+
return null;
|
|
1890
|
+
}
|
|
1891
|
+
},
|
|
1892
|
+
async save(account, secret) {
|
|
1893
|
+
const entry = await entryOf(account);
|
|
1894
|
+
entry.setPassword(secret);
|
|
1895
|
+
},
|
|
1896
|
+
async forget(account) {
|
|
1897
|
+
const entry = await entryOf(account);
|
|
1898
|
+
entry.deletePassword();
|
|
1899
|
+
}
|
|
1900
|
+
};
|
|
1901
|
+
var keychain_default = keychain;
|
|
1902
|
+
|
|
1903
|
+
// src/share/providers/ngrok.ts
|
|
1904
|
+
var ngrok = {
|
|
1905
|
+
name: "ngrok",
|
|
1906
|
+
credential: {
|
|
1907
|
+
label: "ngrok authtoken",
|
|
1908
|
+
help: "https://dashboard.ngrok.com/get-started/your-authtoken",
|
|
1909
|
+
env: "NGROK_AUTHTOKEN"
|
|
1910
|
+
},
|
|
1911
|
+
async open(port, authtoken) {
|
|
1912
|
+
const sdk = await import("@ngrok/ngrok").catch(() => void 0);
|
|
1913
|
+
if (!sdk) {
|
|
1914
|
+
throw new Error(
|
|
1915
|
+
"Sharing through ngrok needs its package in the project: npm install --save-dev @ngrok/ngrok"
|
|
1916
|
+
);
|
|
1917
|
+
}
|
|
1918
|
+
const listener = await sdk.forward({ addr: `127.0.0.1:${port}`, authtoken }).catch((error) => {
|
|
1919
|
+
throw String(error).includes("ERR_NGROK_105") ? new Error(
|
|
1920
|
+
"ngrok does not take this authtoken: copy it again from its dashboard"
|
|
1921
|
+
) : error;
|
|
1922
|
+
});
|
|
1923
|
+
const url = listener.url();
|
|
1924
|
+
if (url === null) {
|
|
1925
|
+
await listener.close();
|
|
1926
|
+
throw new Error("ngrok opened the tunnel without an address");
|
|
1927
|
+
}
|
|
1928
|
+
return { url, close: () => listener.close() };
|
|
1929
|
+
}
|
|
1930
|
+
};
|
|
1931
|
+
var ngrok_default = ngrok;
|
|
1932
|
+
|
|
1933
|
+
// src/share/tunnels.ts
|
|
1934
|
+
var TUNNEL_IDS = ["ngrok"];
|
|
1935
|
+
var TUNNELS = { ngrok: ngrok_default };
|
|
1936
|
+
var tunnels_default = TUNNELS;
|
|
1937
|
+
|
|
1938
|
+
// src/share/serve-shared.ts
|
|
1939
|
+
import {
|
|
1940
|
+
request as ask,
|
|
1941
|
+
createServer as createServer2
|
|
1942
|
+
} from "http";
|
|
1943
|
+
import { timingSafeEqual } from "crypto";
|
|
1944
|
+
import { once } from "events";
|
|
1876
1945
|
import { z as z8 } from "zod";
|
|
1946
|
+
var COOKIE = "erdraft_share";
|
|
1947
|
+
var READS = /* @__PURE__ */ new Set(["/api/state", "/api/events"]);
|
|
1948
|
+
var PASSED = [
|
|
1949
|
+
"accept",
|
|
1950
|
+
"accept-encoding",
|
|
1951
|
+
"accept-language",
|
|
1952
|
+
"cache-control",
|
|
1953
|
+
"if-modified-since",
|
|
1954
|
+
"if-none-match",
|
|
1955
|
+
"last-event-id",
|
|
1956
|
+
"range"
|
|
1957
|
+
];
|
|
1958
|
+
function refuse(response, status, message) {
|
|
1959
|
+
response.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
|
|
1960
|
+
response.end(message);
|
|
1961
|
+
}
|
|
1962
|
+
function sameToken(given, token) {
|
|
1963
|
+
const a = Buffer.from(given ?? "");
|
|
1964
|
+
const b = Buffer.from(token);
|
|
1965
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
1966
|
+
}
|
|
1967
|
+
function cookieOf(request) {
|
|
1968
|
+
const pair = (request.headers.cookie ?? "").split(";").map((each) => each.trim()).find((each) => each.startsWith(`${COOKIE}=`));
|
|
1969
|
+
return pair?.slice(COOKIE.length + 1);
|
|
1970
|
+
}
|
|
1971
|
+
function forward(request, response, port, path) {
|
|
1972
|
+
const headers = {
|
|
1973
|
+
host: `localhost:${port}`
|
|
1974
|
+
};
|
|
1975
|
+
for (const name of PASSED) {
|
|
1976
|
+
const value = request.headers[name];
|
|
1977
|
+
if (value !== void 0) {
|
|
1978
|
+
headers[name] = value;
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
const upstream = ask(
|
|
1982
|
+
{ host: "127.0.0.1", port, method: request.method, path, headers },
|
|
1983
|
+
(answer2) => {
|
|
1984
|
+
response.writeHead(Number(answer2.statusCode), answer2.headers);
|
|
1985
|
+
answer2.on("error", () => response.destroy());
|
|
1986
|
+
answer2.pipe(response);
|
|
1987
|
+
}
|
|
1988
|
+
);
|
|
1989
|
+
upstream.on("error", () => {
|
|
1990
|
+
if (!response.destroyed) {
|
|
1991
|
+
refuse(response, 502, "The window is not answering");
|
|
1992
|
+
}
|
|
1993
|
+
});
|
|
1994
|
+
response.on("close", () => upstream.destroy());
|
|
1995
|
+
upstream.end();
|
|
1996
|
+
}
|
|
1997
|
+
async function serveShared(port, token) {
|
|
1998
|
+
const server = createServer2((request, response) => {
|
|
1999
|
+
const url = new URL(String(request.url), "http://localhost");
|
|
2000
|
+
const given = url.searchParams.get("share");
|
|
2001
|
+
if (given !== null) {
|
|
2002
|
+
if (!sameToken(given, token)) {
|
|
2003
|
+
refuse(response, 403, "This link is not shared, or no longer");
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
url.searchParams.delete("share");
|
|
2007
|
+
response.writeHead(303, {
|
|
2008
|
+
location: `${url.pathname}${url.search}`,
|
|
2009
|
+
"set-cookie": `${COOKIE}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax`
|
|
2010
|
+
});
|
|
2011
|
+
response.end();
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
if (!sameToken(cookieOf(request), token)) {
|
|
2015
|
+
refuse(response, 403, "This link is not shared, or no longer");
|
|
2016
|
+
return;
|
|
2017
|
+
}
|
|
2018
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
2019
|
+
refuse(response, 405, "A shared window only reads");
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
if (url.pathname.startsWith("/api/") && !READS.has(url.pathname)) {
|
|
2023
|
+
refuse(response, 403, "A shared window only reads");
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
forward(request, response, port, `${url.pathname}${url.search}`);
|
|
2027
|
+
});
|
|
2028
|
+
server.listen(0, "127.0.0.1");
|
|
2029
|
+
await once(server, "listening");
|
|
2030
|
+
return {
|
|
2031
|
+
server,
|
|
2032
|
+
port: z8.object({ port: z8.number() }).parse(server.address()).port
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
// src/share/create-share.ts
|
|
2037
|
+
function createShare(config, {
|
|
2038
|
+
provider = tunnels_default[config.tunnel],
|
|
2039
|
+
token = () => randomBytes(24).toString("base64url"),
|
|
2040
|
+
keychain: kept = keychain_default,
|
|
2041
|
+
env = process.env
|
|
2042
|
+
} = {}) {
|
|
2043
|
+
const account = config.tunnel;
|
|
2044
|
+
let open;
|
|
2045
|
+
let starting;
|
|
2046
|
+
let remembered;
|
|
2047
|
+
async function credentialOf() {
|
|
2048
|
+
const given = env[provider.credential.env];
|
|
2049
|
+
if (given) {
|
|
2050
|
+
return { secret: given, from: "environment" };
|
|
2051
|
+
}
|
|
2052
|
+
remembered ??= kept.read(account).then(
|
|
2053
|
+
(secret) => ({
|
|
2054
|
+
secret,
|
|
2055
|
+
from: secret === null ? null : "keychain"
|
|
2056
|
+
})
|
|
2057
|
+
);
|
|
2058
|
+
return remembered;
|
|
2059
|
+
}
|
|
2060
|
+
async function begin(given) {
|
|
2061
|
+
const credential = given || (await credentialOf()).secret;
|
|
2062
|
+
if (!credential) {
|
|
2063
|
+
throw new Error(
|
|
2064
|
+
`Sharing through ${provider.name} needs its ${provider.credential.label}`
|
|
2065
|
+
);
|
|
2066
|
+
}
|
|
2067
|
+
const secret = token();
|
|
2068
|
+
const { server, port } = await serveShared(config.port, secret);
|
|
2069
|
+
const closeServer = () => {
|
|
2070
|
+
server.closeAllConnections();
|
|
2071
|
+
server.close();
|
|
2072
|
+
};
|
|
2073
|
+
const tunnel = await provider.open(port, credential).catch((error) => {
|
|
2074
|
+
closeServer();
|
|
2075
|
+
throw error;
|
|
2076
|
+
});
|
|
2077
|
+
if (given) {
|
|
2078
|
+
const from = await kept.save(account, given).then(
|
|
2079
|
+
() => "keychain",
|
|
2080
|
+
() => "memory"
|
|
2081
|
+
);
|
|
2082
|
+
remembered = Promise.resolve({ secret: given, from });
|
|
2083
|
+
}
|
|
2084
|
+
open = {
|
|
2085
|
+
url: `${tunnel.url}/?share=${secret}`,
|
|
2086
|
+
// Its server closed whatever the provider answers: a tunnel that fails to close reaches nothing behind it
|
|
2087
|
+
close: async () => {
|
|
2088
|
+
closeServer();
|
|
2089
|
+
await tunnel.close().catch(() => void 0);
|
|
2090
|
+
}
|
|
2091
|
+
};
|
|
2092
|
+
return { url: open.url, provider: provider.name };
|
|
2093
|
+
}
|
|
2094
|
+
return {
|
|
2095
|
+
async state() {
|
|
2096
|
+
const { from } = await credentialOf();
|
|
2097
|
+
return {
|
|
2098
|
+
provider: provider.name,
|
|
2099
|
+
url: open?.url ?? null,
|
|
2100
|
+
credential: { ...provider.credential, from }
|
|
2101
|
+
};
|
|
2102
|
+
},
|
|
2103
|
+
// Shared, with the credential given or the one there is, or the link it is shared by already; two asked at
|
|
2104
|
+
// once, one link
|
|
2105
|
+
start(given) {
|
|
2106
|
+
if (open) {
|
|
2107
|
+
return Promise.resolve({ url: open.url, provider: provider.name });
|
|
2108
|
+
}
|
|
2109
|
+
starting ??= begin(given).finally(() => {
|
|
2110
|
+
starting = void 0;
|
|
2111
|
+
});
|
|
2112
|
+
return starting;
|
|
2113
|
+
},
|
|
2114
|
+
async stop() {
|
|
2115
|
+
const was = open;
|
|
2116
|
+
open = void 0;
|
|
2117
|
+
await was?.close();
|
|
2118
|
+
},
|
|
2119
|
+
// The credential kept forgotten, in the keychain and in this run: the window asks for it again
|
|
2120
|
+
async forget() {
|
|
2121
|
+
await kept.forget(account);
|
|
2122
|
+
remembered = Promise.resolve({ secret: null, from: null });
|
|
2123
|
+
}
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
// src/server/create-inspect-api.ts
|
|
2128
|
+
import { z as z9 } from "zod";
|
|
1877
2129
|
import { text as text3 } from "stream/consumers";
|
|
1878
2130
|
|
|
1879
2131
|
// src/inspect/holds-value.ts
|
|
@@ -1896,9 +2148,9 @@ function holdsValue(kept, typed) {
|
|
|
1896
2148
|
// src/server/create-inspect-api.ts
|
|
1897
2149
|
var KEPT_WRITES = 500;
|
|
1898
2150
|
var KEPT_FOR = 15 * 6e4;
|
|
1899
|
-
var writesSchema =
|
|
1900
|
-
writes:
|
|
1901
|
-
|
|
2151
|
+
var writesSchema = z9.object({
|
|
2152
|
+
writes: z9.array(
|
|
2153
|
+
z9.object({ table: z9.string(), values: z9.record(z9.string(), z9.unknown()) })
|
|
1902
2154
|
)
|
|
1903
2155
|
});
|
|
1904
2156
|
var tracedSchema = fieldSchema.omit({ tracedAt: true });
|
|
@@ -1931,7 +2183,7 @@ function createInspectApi(now = Date.now) {
|
|
|
1931
2183
|
if (request.method === "POST" && url.pathname === "/api/inspect/writes") {
|
|
1932
2184
|
const sent = writesSchema.safeParse(await body(request));
|
|
1933
2185
|
if (!sent.success) {
|
|
1934
|
-
json(response, 400, { error:
|
|
2186
|
+
json(response, 400, { error: z9.prettifyError(sent.error) }, headers);
|
|
1935
2187
|
return true;
|
|
1936
2188
|
}
|
|
1937
2189
|
const at = now();
|
|
@@ -1968,7 +2220,7 @@ function createInspectApi(now = Date.now) {
|
|
|
1968
2220
|
if (request.method === "POST" && url.pathname === "/api/inspect/fields") {
|
|
1969
2221
|
const field = tracedSchema.safeParse(await body(request));
|
|
1970
2222
|
if (!field.success) {
|
|
1971
|
-
json(response, 400, { error:
|
|
2223
|
+
json(response, 400, { error: z9.prettifyError(field.error) }, headers);
|
|
1972
2224
|
return true;
|
|
1973
2225
|
}
|
|
1974
2226
|
const kept = await drafts.saveField(field.data);
|
|
@@ -2014,25 +2266,29 @@ function refusalOf(request, asker) {
|
|
|
2014
2266
|
var SETTLE = 40;
|
|
2015
2267
|
var DRAFT_ACTION = /^\/api\/drafts\/([^/]+)\/(adopt|discard)$/;
|
|
2016
2268
|
var DRAFT_DELETE = /^\/api\/drafts\/([^/]+)$/;
|
|
2017
|
-
var layoutSchema =
|
|
2269
|
+
var layoutSchema = z10.object({ key: z10.string().min(1), places: placesSchema });
|
|
2270
|
+
function messageOf(error) {
|
|
2271
|
+
return String(error).replace(/^Error: /, "");
|
|
2272
|
+
}
|
|
2018
2273
|
function json2(response, status, value) {
|
|
2019
2274
|
response.writeHead(status, { "content-type": "application/json" });
|
|
2020
2275
|
response.end(JSON.stringify(value));
|
|
2021
2276
|
}
|
|
2022
|
-
function createApi(config, { spawn } = {}) {
|
|
2023
|
-
const
|
|
2277
|
+
function createApi(config, { spawn, share = createShare(config) } = {}) {
|
|
2278
|
+
const ask2 = createAssistant(config, spawn);
|
|
2024
2279
|
const inspect = createInspectApi();
|
|
2025
2280
|
const clients = /* @__PURE__ */ new Set();
|
|
2026
2281
|
let timer;
|
|
2027
|
-
|
|
2028
|
-
const watcher = watch(config.store, { recursive: true }, () => {
|
|
2282
|
+
function tell() {
|
|
2029
2283
|
clearTimeout(timer);
|
|
2030
2284
|
timer = setTimeout(() => {
|
|
2031
2285
|
for (const client of clients) {
|
|
2032
2286
|
client.write("event: change\ndata: {}\n\n");
|
|
2033
2287
|
}
|
|
2034
2288
|
}, SETTLE);
|
|
2035
|
-
}
|
|
2289
|
+
}
|
|
2290
|
+
mkdirSync(config.store, { recursive: true });
|
|
2291
|
+
const watcher = watch(config.store, { recursive: true }, tell);
|
|
2036
2292
|
async function handle(request, response) {
|
|
2037
2293
|
const { pathname } = new URL(String(request.url), "http://localhost");
|
|
2038
2294
|
if (!pathname.startsWith("/api/")) {
|
|
@@ -2057,6 +2313,7 @@ function createApi(config, { spawn } = {}) {
|
|
|
2057
2313
|
port: config.port,
|
|
2058
2314
|
databases: config.databases.map(({ id, name }) => ({ id, name })),
|
|
2059
2315
|
database: { id: database.id, name: database.name },
|
|
2316
|
+
share: await share.state(),
|
|
2060
2317
|
...await drafts.read()
|
|
2061
2318
|
});
|
|
2062
2319
|
return true;
|
|
@@ -2073,11 +2330,54 @@ function createApi(config, { spawn } = {}) {
|
|
|
2073
2330
|
return true;
|
|
2074
2331
|
}
|
|
2075
2332
|
if (request.method === "POST" && pathname === "/api/assistant") {
|
|
2076
|
-
await
|
|
2333
|
+
await ask2(request, response);
|
|
2334
|
+
return true;
|
|
2335
|
+
}
|
|
2336
|
+
if (request.method === "POST" && pathname === "/api/read") {
|
|
2337
|
+
try {
|
|
2338
|
+
const base = await readSchema(config, database, drafts);
|
|
2339
|
+
json2(response, 200, {
|
|
2340
|
+
source: base.source,
|
|
2341
|
+
tables: base.schema.tables.length,
|
|
2342
|
+
enums: base.schema.enums.length
|
|
2343
|
+
});
|
|
2344
|
+
} catch (error) {
|
|
2345
|
+
json2(response, 422, { error: messageOf(error) });
|
|
2346
|
+
}
|
|
2347
|
+
return true;
|
|
2348
|
+
}
|
|
2349
|
+
if (request.method === "POST" && pathname === "/api/share") {
|
|
2350
|
+
const given = z10.object({ credential: z10.string().trim().min(1).optional() }).safeParse(JSON.parse(await text4(request) || "{}"));
|
|
2351
|
+
try {
|
|
2352
|
+
json2(
|
|
2353
|
+
response,
|
|
2354
|
+
200,
|
|
2355
|
+
await share.start(given.success ? given.data.credential : void 0)
|
|
2356
|
+
);
|
|
2357
|
+
} catch (error) {
|
|
2358
|
+
json2(response, 502, { error: messageOf(error) });
|
|
2359
|
+
}
|
|
2360
|
+
tell();
|
|
2361
|
+
return true;
|
|
2362
|
+
}
|
|
2363
|
+
if (request.method === "DELETE" && pathname === "/api/share/credential") {
|
|
2364
|
+
try {
|
|
2365
|
+
await share.forget();
|
|
2366
|
+
json2(response, 200, { ok: true });
|
|
2367
|
+
} catch (error) {
|
|
2368
|
+
json2(response, 502, { error: messageOf(error) });
|
|
2369
|
+
}
|
|
2370
|
+
tell();
|
|
2371
|
+
return true;
|
|
2372
|
+
}
|
|
2373
|
+
if (request.method === "DELETE" && pathname === "/api/share") {
|
|
2374
|
+
await share.stop();
|
|
2375
|
+
json2(response, 200, { ok: true });
|
|
2376
|
+
tell();
|
|
2077
2377
|
return true;
|
|
2078
2378
|
}
|
|
2079
2379
|
if (request.method === "POST" && pathname === "/api/database") {
|
|
2080
|
-
const chosen =
|
|
2380
|
+
const chosen = z10.object({ id: z10.string() }).safeParse(JSON.parse(await text4(request) || "{}"));
|
|
2081
2381
|
try {
|
|
2082
2382
|
const id = chosen.success ? chosen.data.id : "";
|
|
2083
2383
|
json2(response, 200, await chooseDatabase(config, id));
|
|
@@ -2091,7 +2391,7 @@ function createApi(config, { spawn } = {}) {
|
|
|
2091
2391
|
JSON.parse(await text4(request) || "{}")
|
|
2092
2392
|
);
|
|
2093
2393
|
if (!layout.success) {
|
|
2094
|
-
json2(response, 400, { error:
|
|
2394
|
+
json2(response, 400, { error: z10.prettifyError(layout.error) });
|
|
2095
2395
|
return true;
|
|
2096
2396
|
}
|
|
2097
2397
|
await drafts.saveLayout(layout.data.key, layout.data.places);
|
|
@@ -2138,6 +2438,7 @@ function createApi(config, { spawn } = {}) {
|
|
|
2138
2438
|
for (const client of clients) {
|
|
2139
2439
|
client.end();
|
|
2140
2440
|
}
|
|
2441
|
+
share.stop();
|
|
2141
2442
|
}
|
|
2142
2443
|
};
|
|
2143
2444
|
}
|
|
@@ -2158,5 +2459,6 @@ export {
|
|
|
2158
2459
|
describeDraft,
|
|
2159
2460
|
describeDecision,
|
|
2160
2461
|
createServer,
|
|
2462
|
+
TUNNEL_IDS,
|
|
2161
2463
|
createApi
|
|
2162
2464
|
};
|