@neuctra/notlix-mcp 1.0.0 → 2.0.1
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 -26
- package/bin/notlix-mcp.js +79 -21
- package/package.json +13 -3
- package/src/authix.js +153 -0
- package/src/cli/prompt.js +27 -0
- package/src/cli/session.js +37 -0
- package/src/content.js +263 -0
- package/src/defaults.js +8 -0
- package/src/documents.js +133 -0
- package/src/http.js +6 -35
- package/src/index.js +7 -3
- package/src/server.js +185 -97
- package/src/shared/aiEdits.js +142 -0
- package/src/shared/contextPacks.js +133 -0
- package/src/shared/diagrams.js +451 -0
- package/src/shared/presentations.js +374 -0
- package/src/shared/records.js +32 -0
- package/src/shared/slideShapes.js +133 -0
- package/src/sharedText.js +61 -0
- package/src/connection.js +0 -15
- package/src/inbox.js +0 -60
- package/src/seal.js +0 -32
package/README.md
CHANGED
|
@@ -1,26 +1,35 @@
|
|
|
1
1
|
# @neuctra/notlix-mcp
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
MCP server for Neuctra Notlix. It lets Claude Code, Claude Desktop, Cursor and other MCP tools work
|
|
4
|
+
with your Notes, Knowledge, Documents, Presentations and Diagrams.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
| Tool | What it does |
|
|
7
|
+
|---|---|
|
|
8
|
+
| `list_notlix_items` | Lists or searches items, newest first, with ids |
|
|
9
|
+
| `read_notlix_item` | Reads one item in full |
|
|
10
|
+
| `create_notlix_item` | Creates a note, Knowledge item, document (from markdown), presentation (markdown, `---` between slides) or diagram (nodes and connectors, laid out for you) |
|
|
11
|
+
| `edit_notlix_item` | Renames, applies find-and-replace edits, appends content, or adds nodes and connectors to a diagram |
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
Nothing can be deleted. The vault (passwords, secure notes, API keys, files) is encrypted and out
|
|
14
|
+
of reach. The AI acts as you, so it can read every item of these five types: only connect tools
|
|
15
|
+
you trust.
|
|
15
16
|
|
|
16
17
|
## Setup
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
Requires Node.js 20 or later.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx -y @neuctra/notlix-mcp login # asks for your Notlix email and password
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
This saves a session (not your password) in `~/.config/notlix/session.json`, readable only by you.
|
|
26
|
+
It's the same file the Notlix CLI uses, so one login covers both. Sessions last a day; when the AI
|
|
27
|
+
says the sign-in has expired, run `login` again. No restart needed.
|
|
19
28
|
|
|
20
29
|
Claude Code:
|
|
21
30
|
|
|
22
31
|
```bash
|
|
23
|
-
claude mcp add notlix
|
|
32
|
+
claude mcp add notlix -- npx -y @neuctra/notlix-mcp
|
|
24
33
|
```
|
|
25
34
|
|
|
26
35
|
Claude Desktop, Cursor and others (`mcp.json`):
|
|
@@ -28,37 +37,53 @@ Claude Desktop, Cursor and others (`mcp.json`):
|
|
|
28
37
|
```json
|
|
29
38
|
{
|
|
30
39
|
"mcpServers": {
|
|
31
|
-
"notlix": {
|
|
32
|
-
"command": "npx",
|
|
33
|
-
"args": ["-y", "@neuctra/notlix-mcp"],
|
|
34
|
-
"env": { "NOTLIX_TOKEN": "<token>" }
|
|
35
|
-
}
|
|
40
|
+
"notlix": { "command": "npx", "args": ["-y", "@neuctra/notlix-mcp"] }
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
43
|
```
|
|
39
44
|
|
|
40
|
-
|
|
45
|
+
Other commands: `npx -y @neuctra/notlix-mcp whoami` and `npx -y @neuctra/notlix-mcp logout`.
|
|
46
|
+
|
|
47
|
+
For Claude.ai and ChatGPT, use the remote server (`neuctra-notlix-mcp-http`) instead.
|
|
48
|
+
|
|
49
|
+
## How it signs in
|
|
50
|
+
|
|
51
|
+
Like the Notlix CLI, it talks to Authix with the website's publishable key (`pk_live_…`, already
|
|
52
|
+
public in the website) and the website's `Origin`. Authix checks Origin to stop other websites
|
|
53
|
+
using a browser's cookie; here you sign in with your own password. If the Notlix app on Authix
|
|
54
|
+
ever gets an IP allowlist, this stops working, along with the CLI and every browser.
|
|
55
|
+
|
|
56
|
+
| Variable | Default |
|
|
41
57
|
|---|---|
|
|
42
|
-
| `
|
|
43
|
-
| `
|
|
58
|
+
| `NOTLIX_AUTHIX_URL`, `NOTLIX_PUBLISHABLE_KEY`, `NOTLIX_APP_ID`, `NOTLIX_ORIGIN` | the website's settings, in `src/defaults.js` |
|
|
59
|
+
| `NOTLIX_CONFIG_DIR` | `~/.config/notlix` |
|
|
60
|
+
| `NOTLIX_EMAIL`, `NOTLIX_PASSWORD` | asked for by `login` when unset |
|
|
44
61
|
|
|
45
|
-
|
|
62
|
+
Plan item limits are checked by the website, not here, so items created through MCP aren't
|
|
63
|
+
counted against them when they're created.
|
|
46
64
|
|
|
47
65
|
## As a library
|
|
48
66
|
|
|
49
|
-
|
|
67
|
+
Works in Node and on Cloudflare Workers; the remote server uses it:
|
|
50
68
|
|
|
51
69
|
```js
|
|
52
|
-
import {
|
|
70
|
+
import { AuthixClient, createNotlixServer, sharedItemText } from "@neuctra/notlix-mcp";
|
|
53
71
|
import { handleMcpRequest } from "@neuctra/notlix-mcp/http"; // Streamable HTTP, web-standard Request/Response
|
|
54
72
|
```
|
|
55
73
|
|
|
74
|
+
`sharedItemText(authix, token)` returns a live-shared item (note, Knowledge, document or AI
|
|
75
|
+
Brief) as plain text, with no sign-in. It powers "Link for AI".
|
|
76
|
+
|
|
56
77
|
## Development
|
|
57
78
|
|
|
58
79
|
```bash
|
|
59
80
|
npm install
|
|
60
|
-
npm
|
|
81
|
+
npm run sync # copy the website's helpers into src/shared, and its public settings into src/defaults.js
|
|
82
|
+
npm test # runs against a fake Authix
|
|
61
83
|
```
|
|
62
84
|
|
|
63
|
-
|
|
64
|
-
the
|
|
85
|
+
Run `npm run sync` after changing `presentations.js`, `slideShapes.js`, `diagrams.js`,
|
|
86
|
+
`aiEdits.js`, `records.js` or `contextPacks.js` in the client; a test fails when the copies drift.
|
|
87
|
+
|
|
88
|
+
To release: bump `version` here and `VERSION` in `src/server.js`, `npm test`, `npm publish`, then
|
|
89
|
+
update `@neuctra/notlix-mcp` in `neuctra-notlix-mcp-http` and redeploy it.
|
package/bin/notlix-mcp.js
CHANGED
|
@@ -1,29 +1,87 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// Notlix MCP for local AI tools.
|
|
3
|
+
//
|
|
4
|
+
// npx -y @neuctra/notlix-mcp login sign in (asks for email and password)
|
|
5
|
+
// npx -y @neuctra/notlix-mcp whoami show who is signed in
|
|
6
|
+
// npx -y @neuctra/notlix-mcp logout sign out
|
|
7
|
+
// npx -y @neuctra/notlix-mcp run the MCP server over stdio (what AI tools start)
|
|
8
|
+
//
|
|
9
|
+
// stdout carries the MCP protocol when serving, so messages go to stderr.
|
|
7
10
|
|
|
8
11
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
12
|
|
|
10
|
-
import {
|
|
13
|
+
import { AuthixClient, authixConfig, createNotlixServer } from "../src/index.js";
|
|
14
|
+
import { ask } from "../src/cli/prompt.js";
|
|
15
|
+
import { clearSession, configDir, isExpired, readSession, writeSession } from "../src/cli/session.js";
|
|
11
16
|
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
const LOGIN = "npx -y @neuctra/notlix-mcp login";
|
|
18
|
+
const env = process.env;
|
|
19
|
+
const dir = configDir(env);
|
|
20
|
+
const config = authixConfig({
|
|
21
|
+
authixUrl: env.NOTLIX_AUTHIX_URL,
|
|
22
|
+
publishableKey: env.NOTLIX_PUBLISHABLE_KEY,
|
|
23
|
+
appId: env.NOTLIX_APP_ID,
|
|
24
|
+
origin: env.NOTLIX_ORIGIN,
|
|
25
|
+
});
|
|
26
|
+
const hint = `Ask the user to run this in a terminal, then try again: ${LOGIN}`;
|
|
27
|
+
|
|
28
|
+
/** Reads the session file on every request, so a new login works without restarting the AI tool. */
|
|
29
|
+
class FileSessionClient extends AuthixClient {
|
|
30
|
+
get session() {
|
|
31
|
+
const session = readSession(dir);
|
|
32
|
+
return session && !isExpired(session) ? session : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
set session(_value) {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function flag(name) {
|
|
39
|
+
const index = process.argv.indexOf(`--${name}`);
|
|
40
|
+
return index === -1 ? undefined : process.argv[index + 1];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function login() {
|
|
44
|
+
const email = flag("email") || env.NOTLIX_EMAIL || (await ask("Notlix email: "));
|
|
45
|
+
const password = env.NOTLIX_PASSWORD || (await ask("Password: ", { hidden: true }));
|
|
46
|
+
const client = new AuthixClient({ config });
|
|
47
|
+
const session = await client.login(email.trim(), password);
|
|
48
|
+
writeSession(dir, session);
|
|
49
|
+
console.error(`Signed in as ${session.email}. AI tools can use Notlix until ${new Date(session.expiresAt).toLocaleString()}.`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function whoami() {
|
|
53
|
+
const session = readSession(dir);
|
|
54
|
+
if (!session || isExpired(session)) throw new Error(`Not signed in. Run: ${LOGIN}`);
|
|
55
|
+
const user = await new AuthixClient({ config, session, signInHint: `Run: ${LOGIN}` }).whoami();
|
|
56
|
+
if (!user) throw new Error(`Your session has expired. Run: ${LOGIN}`);
|
|
57
|
+
console.error(`Signed in as ${user.email}, until ${new Date(session.expiresAt).toLocaleString()}.`);
|
|
21
58
|
}
|
|
22
59
|
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
60
|
+
async function logout() {
|
|
61
|
+
const session = readSession(dir);
|
|
62
|
+
if (session) await new AuthixClient({ config, session }).logout();
|
|
63
|
+
clearSession(dir);
|
|
64
|
+
console.error("Signed out.");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function serve() {
|
|
68
|
+
const authix = new FileSessionClient({ config, signInHint: hint });
|
|
69
|
+
const server = createNotlixServer(authix);
|
|
70
|
+
await server.connect(new StdioServerTransport());
|
|
71
|
+
if (!authix.session) console.error(`notlix-mcp: not signed in. Run: ${LOGIN}`);
|
|
72
|
+
}
|
|
27
73
|
|
|
28
|
-
|
|
29
|
-
|
|
74
|
+
const commands = { login, whoami, logout, serve };
|
|
75
|
+
const command = process.argv[2] && !process.argv[2].startsWith("-") ? process.argv[2] : "serve";
|
|
76
|
+
|
|
77
|
+
if (!commands[command]) {
|
|
78
|
+
console.error(`Unknown command "${command}". Use login, whoami, logout, or no command to serve.`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
await commands[command]();
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.error(err.message || String(err));
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neuctra/notlix-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "MCP server for Neuctra Notlix: lets Claude Code, Claude Desktop, Cursor and other
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"description": "MCP server for Neuctra Notlix: lets Claude Code, Claude Desktop, Cursor and other AI tools list, read, create and edit your Notes, Knowledge, Documents, Presentations and Diagrams.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"notlix-mcp": "bin/notlix-mcp.js"
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
19
|
"start": "node bin/notlix-mcp.js",
|
|
20
|
+
"sync": "node scripts/sync.mjs",
|
|
20
21
|
"test": "node --test \"test/*.test.js\""
|
|
21
22
|
},
|
|
22
23
|
"engines": {
|
|
@@ -24,7 +25,16 @@
|
|
|
24
25
|
},
|
|
25
26
|
"dependencies": {
|
|
26
27
|
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
28
|
+
"linkedom": "^0.18.15",
|
|
29
|
+
"marked": "^18.1.3",
|
|
27
30
|
"zod": "^4.6.7"
|
|
28
31
|
},
|
|
29
|
-
"license": "UNLICENSED"
|
|
32
|
+
"license": "UNLICENSED",
|
|
33
|
+
"keywords": [
|
|
34
|
+
"mcp",
|
|
35
|
+
"model-context-protocol",
|
|
36
|
+
"notlix",
|
|
37
|
+
"neuctra",
|
|
38
|
+
"notes"
|
|
39
|
+
]
|
|
30
40
|
}
|
package/src/authix.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import defaults from "./defaults.js";
|
|
2
|
+
import { itemsOf, nextCursorOf } from "./shared/records.js";
|
|
3
|
+
|
|
4
|
+
// A small Authix client that works in Node and on Cloudflare Workers. It keeps
|
|
5
|
+
// the user's session cookie itself and sends the website's Origin, the same way
|
|
6
|
+
// the Notlix CLI does. Authix checks Origin to stop other websites using a
|
|
7
|
+
// signed-in browser's cookie; here the user signed in with their own password.
|
|
8
|
+
//
|
|
9
|
+
// It acts only as the signed-in user, with the website's publishable key.
|
|
10
|
+
|
|
11
|
+
export const SESSION_COOKIE = "authix_user_session";
|
|
12
|
+
|
|
13
|
+
export class AuthixError extends Error {
|
|
14
|
+
constructor(message, { status = 0, code = "" } = {}) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "AuthixError";
|
|
17
|
+
this.status = status;
|
|
18
|
+
this.code = code;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Connection settings: the website's public ones, overridable. */
|
|
23
|
+
export function authixConfig(overrides = {}) {
|
|
24
|
+
const pick = (key) => overrides[key] || defaults[key];
|
|
25
|
+
return {
|
|
26
|
+
authixUrl: String(pick("authixUrl")).replace(/\/+$/, ""),
|
|
27
|
+
publishableKey: pick("publishableKey"),
|
|
28
|
+
appId: pick("appId"),
|
|
29
|
+
origin: pick("origin"),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class AuthixClient {
|
|
34
|
+
/**
|
|
35
|
+
* `session`: { cookie, userId, email, expiresAt } or null.
|
|
36
|
+
* `signInHint`: what to tell the user when the session has expired.
|
|
37
|
+
*/
|
|
38
|
+
constructor({ config = authixConfig(), session = null, fetch: doFetch, signInHint = "Sign in again." } = {}) {
|
|
39
|
+
this.config = config;
|
|
40
|
+
this.session = session;
|
|
41
|
+
// Workers refuse fetch called as a method of another object, so it's wrapped.
|
|
42
|
+
this.fetch = doFetch || ((url, init) => globalThis.fetch(url, init));
|
|
43
|
+
this.signInHint = signInHint;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async request(method, path, body) {
|
|
47
|
+
const headers = {
|
|
48
|
+
"x-api-key": this.config.publishableKey,
|
|
49
|
+
Origin: this.config.origin,
|
|
50
|
+
Accept: "application/json",
|
|
51
|
+
};
|
|
52
|
+
if (this.session?.cookie) headers.Cookie = `${SESSION_COOKIE}=${this.session.cookie}`;
|
|
53
|
+
|
|
54
|
+
let payload;
|
|
55
|
+
if (body !== undefined) {
|
|
56
|
+
headers["Content-Type"] = "application/json";
|
|
57
|
+
payload = JSON.stringify({ ...(this.config.appId ? { appId: this.config.appId } : {}), ...body });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let response;
|
|
61
|
+
try {
|
|
62
|
+
response = await this.fetch(`${this.config.authixUrl}${path}`, { method, headers, body: payload });
|
|
63
|
+
} catch (err) {
|
|
64
|
+
// stderr: Worker logs, or the AI tool's MCP log. Never stdout, which carries MCP over stdio.
|
|
65
|
+
console.error(`notlix-mcp: request to Authix failed: ${err?.message || err}`);
|
|
66
|
+
throw new AuthixError("Can't reach Notlix. Check the internet connection.");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const data = await response.json().catch(() => null);
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
if (response.status === 401 && path !== "/users/login") {
|
|
72
|
+
throw new AuthixError(`The Notlix sign-in has expired. ${this.signInHint}`, { status: 401, code: data?.code });
|
|
73
|
+
}
|
|
74
|
+
throw new AuthixError(data?.message || `Notlix answered ${response.status}.`, {
|
|
75
|
+
status: response.status,
|
|
76
|
+
code: data?.code,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return { data, response };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Signs in and returns { cookie, userId, email, expiresAt }. */
|
|
83
|
+
async login(email, password) {
|
|
84
|
+
const { data, response } = await this.request("POST", "/users/login", { email, password });
|
|
85
|
+
const cookies = response.headers.getSetCookie?.() ?? [response.headers.get("set-cookie") || ""];
|
|
86
|
+
const match = cookies.map((line) => line.match(new RegExp(`^${SESSION_COOKIE}=([^;]+)`))).find(Boolean);
|
|
87
|
+
if (!match || !data?.user?.id) throw new AuthixError("Signed in, but no session came back.");
|
|
88
|
+
|
|
89
|
+
const maxAge = Number(cookies.join(";").match(/Max-Age=(\d+)/i)?.[1]);
|
|
90
|
+
this.session = {
|
|
91
|
+
cookie: match[1],
|
|
92
|
+
userId: data.user.id,
|
|
93
|
+
email: data.user.email,
|
|
94
|
+
expiresAt: new Date(Date.now() + (maxAge ? maxAge * 1000 : 86_400_000)).toISOString(),
|
|
95
|
+
};
|
|
96
|
+
return this.session;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async logout() {
|
|
100
|
+
await this.request("POST", "/users/logout", {}).catch(() => {});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The signed-in user, or null when the session no longer works. */
|
|
104
|
+
async whoami() {
|
|
105
|
+
const { data } = await this.request("GET", "/users/session");
|
|
106
|
+
return data?.authenticated ? data.user : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A record someone shared by link (Authix shareUserData), read with no
|
|
111
|
+
* sign-in: the token is the credential. Null when it isn't shared.
|
|
112
|
+
*/
|
|
113
|
+
async getShared(token) {
|
|
114
|
+
try {
|
|
115
|
+
const { data } = await this.request("GET", `/shared/${encodeURIComponent(token)}`);
|
|
116
|
+
const record = data?.data && typeof data.data === "object" && !Array.isArray(data.data) ? data.data : null;
|
|
117
|
+
return record ? { ...record, dataCategory: String(record.dataCategory || "").toLowerCase() } : null;
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (err instanceof AuthixError && (err.status === 404 || err.code === "SHARE_NOT_FOUND")) return null;
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
#userPath(rest = "") {
|
|
125
|
+
if (!this.session?.userId) throw new AuthixError(`Not signed in to Notlix. ${this.signInHint}`, { status: 401 });
|
|
126
|
+
return `/users/${encodeURIComponent(this.session.userId)}/data${rest}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Every record in a category, following pagination. */
|
|
130
|
+
async list(category) {
|
|
131
|
+
const all = [];
|
|
132
|
+
let cursor;
|
|
133
|
+
do {
|
|
134
|
+
const query = new URLSearchParams({ limit: "100", category });
|
|
135
|
+
if (cursor) query.set("cursor", cursor);
|
|
136
|
+
const { data } = await this.request("GET", this.#userPath(`?${query}`));
|
|
137
|
+
all.push(...itemsOf(data));
|
|
138
|
+
cursor = nextCursorOf(data);
|
|
139
|
+
} while (cursor);
|
|
140
|
+
return all;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async add(category, record) {
|
|
144
|
+
const { data } = await this.request("POST", this.#userPath(), { dataCategory: category, ...record });
|
|
145
|
+
return data?.data && typeof data.data === "object" && !Array.isArray(data.data) ? data.data : data;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Authix merges the fields given into the record. */
|
|
149
|
+
async update(dataId, fields) {
|
|
150
|
+
const { data } = await this.request("PUT", this.#userPath(`/${encodeURIComponent(dataId)}`), fields);
|
|
151
|
+
return data;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { Writable } from "node:stream";
|
|
3
|
+
|
|
4
|
+
/** Asks on the terminal. Hidden input isn't echoed, for passwords. */
|
|
5
|
+
export function ask(question, { hidden = false } = {}) {
|
|
6
|
+
if (!process.stdin.isTTY) {
|
|
7
|
+
return Promise.reject(new Error(`Can't ask "${question.trim()}" without a terminal.`));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let muted = false;
|
|
11
|
+
const output = new Writable({
|
|
12
|
+
write(chunk, encoding, done) {
|
|
13
|
+
if (!muted) process.stderr.write(chunk, encoding);
|
|
14
|
+
done();
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
const rl = createInterface({ input: process.stdin, output, terminal: true });
|
|
18
|
+
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
rl.question(question, (answer) => {
|
|
21
|
+
rl.close();
|
|
22
|
+
if (hidden) process.stderr.write("\n");
|
|
23
|
+
resolve(answer);
|
|
24
|
+
});
|
|
25
|
+
muted = hidden;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// The sign-in session, in the same file the Notlix CLI uses
|
|
6
|
+
// (~/.config/notlix/session.json, readable only by this user), so one login
|
|
7
|
+
// covers both. It holds the Authix session cookie, never the password.
|
|
8
|
+
|
|
9
|
+
export function configDir(env = process.env) {
|
|
10
|
+
return env.NOTLIX_CONFIG_DIR || join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "notlix");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const fileOf = (dir) => join(dir, "session.json");
|
|
14
|
+
|
|
15
|
+
export function readSession(dir) {
|
|
16
|
+
try {
|
|
17
|
+
const session = JSON.parse(readFileSync(fileOf(dir), "utf8"));
|
|
18
|
+
return session?.cookie && session?.userId ? session : null;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function writeSession(dir, session) {
|
|
25
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
26
|
+
const file = fileOf(dir);
|
|
27
|
+
writeFileSync(file, `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600 });
|
|
28
|
+
// The mode only applies when the file is created.
|
|
29
|
+
chmodSync(file, 0o600);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function clearSession(dir) {
|
|
33
|
+
const file = fileOf(dir);
|
|
34
|
+
if (existsSync(file)) rmSync(file);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const isExpired = (session) => Boolean(session?.expiresAt) && Date.parse(session.expiresAt) <= Date.now();
|