larkit 0.0.0-stage → 0.1.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/AGENT_SKILL.md +12 -0
- package/README.md +765 -2
- package/bin/larkit-mcp.js +8 -0
- package/bin/larkit.js +532 -0
- package/larkit.config.example.json +12 -0
- package/package.json +73 -4
- package/scripts/build-index.mjs +66 -0
- package/scripts/check-syntax.mjs +28 -0
- package/scripts/login-worker.mjs +50 -0
- package/scripts/publish-pack.mjs +277 -0
- package/scripts/verify-bootstrap.mjs +238 -0
- package/scripts/verify-console.mjs +60 -0
- package/scripts/verify-index.mjs +59 -0
- package/scripts/verify-mcp.mjs +215 -0
- package/skills/larkit/SKILL.md +120 -0
- package/skills/larkit/references/console.md +86 -0
- package/skills/larkit/references/playbooks.md +177 -0
- package/skills/larkit/references/tools.md +115 -0
- package/skills/larkit/references/truth-model.md +117 -0
- package/src/auth/login.js +171 -0
- package/src/auth/service.js +213 -0
- package/src/auth/store.js +213 -0
- package/src/bootstrap/channel.js +201 -0
- package/src/bootstrap/cli-install.js +184 -0
- package/src/bootstrap/download.js +182 -0
- package/src/bootstrap/init.js +672 -0
- package/src/bootstrap/pack-cli.js +97 -0
- package/src/bootstrap/pack.js +541 -0
- package/src/bootstrap/report.js +62 -0
- package/src/bootstrap/skills.js +223 -0
- package/src/bootstrap/state.js +66 -0
- package/src/bootstrap/tar.js +417 -0
- package/src/console/browser.js +338 -0
- package/src/console/client.js +827 -0
- package/src/console/dispatch.js +101 -0
- package/src/docs/client.js +284 -0
- package/src/indexer/anchors.js +366 -0
- package/src/indexer/db.js +449 -0
- package/src/indexer/extract-assets.js +285 -0
- package/src/indexer/extract-deb.js +56 -0
- package/src/indexer/format-pool.js +64 -0
- package/src/indexer/format-worker.js +132 -0
- package/src/indexer/format.js +239 -0
- package/src/indexer/protocol.js +123 -0
- package/src/mcp/server.js +433 -0
- package/src/probe/client.js +225 -0
- package/src/probe/openapi.js +194 -0
- package/src/protocol/catalog.js +146 -0
- package/src/protocol/data/README.md +15 -0
- package/src/protocol/data/bundle.json +1 -0
- package/src/protocol/data/commands.json +12896 -0
- package/src/protocol/data/constants.json +8 -0
- package/src/protocol/data/reference/PROTOCOL.md +66 -0
- package/src/protocol/data/reference/endpoints.json +46 -0
- package/src/protocol/data/schema.json.gz +0 -0
- package/src/protocol/descriptor.js +358 -0
- package/src/protocol/schema.js +235 -0
- package/src/protocol/transport.js +387 -0
- package/src/shared/browser.js +70 -0
- package/src/shared/config.js +90 -0
- package/src/shared/paths.js +151 -0
- package/src/shared/quiet.js +12 -0
- package/src/shared/util.js +124 -0
- package/src/truth/fts.js +24 -0
- package/src/truth/search.js +554 -0
- package/src/truth/verified.js +107 -0
- package/verified_truths/README.md +28 -0
- package/verified_truths/card-json-2.0-official.md +55 -0
- package/verified_truths/client-ws-gateway-boundaries.md +33 -0
- package/verified_truths/feishu-developer-console-apis.md +104 -0
- package/workers/truth-api/src/index.js +180 -0
- package/workers/truth-api/wrangler.toml +31 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Tool & CLI reference
|
|
2
|
+
|
|
3
|
+
All MCP tools are also available as CLI commands returning the same JSON. Prefer MCP when it is
|
|
4
|
+
mounted; use the CLI for scripts, CI, or when MCP is not configured.
|
|
5
|
+
|
|
6
|
+
## MCP tools
|
|
7
|
+
|
|
8
|
+
### `lark_doc` — official documentation (zero config)
|
|
9
|
+
|
|
10
|
+
| Param | Notes |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| `action` | `search` \| `read` \| `categories` |
|
|
13
|
+
| `query` | title/path keyword for `search` |
|
|
14
|
+
| `category` | taxonomy key, e.g. `服务端 API_即时通讯` |
|
|
15
|
+
| `path` | document path returned by `search` |
|
|
16
|
+
| `offset` / `limit` | line window for large docs (`limit <= 400`) |
|
|
17
|
+
|
|
18
|
+
CLI: `larkit doc search 卡片`, `larkit doc read "<path>" --limit 80`, `larkit doc categories`.
|
|
19
|
+
|
|
20
|
+
### `lark_truth_search` — local truth engine
|
|
21
|
+
|
|
22
|
+
| Param | Notes |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| `query` | keyword, API path, card field, error code, IPC channel, command name |
|
|
25
|
+
| `scope` | `all` \| `verified` \| `protocol` \| `web` \| `electron` |
|
|
26
|
+
| `limit` | 1–50, default 10 |
|
|
27
|
+
|
|
28
|
+
Returns compact hits with `kind`, coordinates (`file_path`, `line_number`, `line_start`), a
|
|
29
|
+
320-char `context`/`snippet`, and protocol schema summaries. Always start with `scope=verified`.
|
|
30
|
+
|
|
31
|
+
CLI: `larkit search card_link --scope electron --limit 5`.
|
|
32
|
+
|
|
33
|
+
### `lark_truth_inspect` — read code / persist knowledge
|
|
34
|
+
|
|
35
|
+
| Param | Notes |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `action` | `read_slice` \| `save_verified` |
|
|
38
|
+
| `file_path` | path from search results (`formatted`-relative or `verified_truths/...`) |
|
|
39
|
+
| `line_number` | target line |
|
|
40
|
+
| `mode` | `context` \| `function` (function prefers the enclosing body) |
|
|
41
|
+
| `max_lines` | default 120, hard cap 200 |
|
|
42
|
+
| `topic`, `content`, `evidence`, `source`, `confidence`, `tags` | for `save_verified` |
|
|
43
|
+
|
|
44
|
+
CLI: `larkit inspect web/network/foo.js 120 --mode=function --max-lines=80`,
|
|
45
|
+
`larkit save "topic" --content "..." --evidence "..." --source probe`.
|
|
46
|
+
|
|
47
|
+
### `lark_auth` — local Feishu identity
|
|
48
|
+
|
|
49
|
+
Actions: `whoami`, `check`, `list`, `use`, `cookie`, `login`, `login_status`, `import`, `logout`.
|
|
50
|
+
|
|
51
|
+
- `login` is detached: it returns a status file; `login_status` returns `qr_path` to show the user.
|
|
52
|
+
- `import` accepts a cookie string or a JSON file.
|
|
53
|
+
- Profiles live in `~/.larkit/credentials.json`; the active profile in `~/.larkit/config.json`.
|
|
54
|
+
|
|
55
|
+
CLI: `larkit auth check --profile geren`, `larkit auth login geren`, `larkit auth login_status geren`.
|
|
56
|
+
|
|
57
|
+
### `lark_console` — developer console control
|
|
58
|
+
|
|
59
|
+
| Action | Purpose |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| `list_apps`, `get_app` | inventory |
|
|
62
|
+
| `create_app`, `update_app_info` | app lifecycle (`dry_run` supported) |
|
|
63
|
+
| `get_credentials` | app_secret (never persist in knowledge files) |
|
|
64
|
+
| `list_scopes`, `applied_scopes`, `grant_scopes` | permissions |
|
|
65
|
+
| `enable_bot_ws` | robot + event WS + card callback WS |
|
|
66
|
+
| `list_versions`, `publish` | versioning/release |
|
|
67
|
+
| `browser_fallback` | Chrome + CDP fallback, captures internal APIs |
|
|
68
|
+
|
|
69
|
+
Always `dry_run:true` first for writes. Full endpoint table: `console.md`.
|
|
70
|
+
|
|
71
|
+
CLI: `larkit console enable_bot_ws --app cli_xxx --profile geren --dry-run`.
|
|
72
|
+
|
|
73
|
+
### `lark_probe` — real traffic
|
|
74
|
+
|
|
75
|
+
`mode="openapi"`:
|
|
76
|
+
`app_id`, `app_secret` (or env), `method`, `path`, `query`, `body`, `timeout`.
|
|
77
|
+
Automatically obtains/caches `tenant_access_token` (60s early expiry).
|
|
78
|
+
|
|
79
|
+
`mode="client"`:
|
|
80
|
+
`command` (e.g. `PUT_MESSAGE`), `payload`, `transport` (`ws` | `gateway`), `profile`,
|
|
81
|
+
`wait_push`, `timeout`. Uses the bundled protobuf schema so it works without the deb.
|
|
82
|
+
|
|
83
|
+
CLI: `larkit probe openapi --path=/im/v1/chats --app-id=... --app-secret=...`,
|
|
84
|
+
`larkit probe client --command=PUSH_MESSAGES --wait-push --timeout=8000`.
|
|
85
|
+
|
|
86
|
+
## CLI bootstrap commands
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
npx larkit # init on first run; status afterwards
|
|
90
|
+
larkit init # idempotent bootstrap
|
|
91
|
+
larkit status --json
|
|
92
|
+
larkit update # refresh outdated truth/skill/CLI
|
|
93
|
+
larkit skills list|install|remove [--target=agents,claude,project] [--project]
|
|
94
|
+
larkit pack build|verify|embed|path|stats
|
|
95
|
+
larkit doctor
|
|
96
|
+
larkit mcp # stdio MCP server
|
|
97
|
+
larkit mcp config # print the MCP client JSON
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Environment
|
|
101
|
+
|
|
102
|
+
| Variable | Purpose |
|
|
103
|
+
| --- | --- |
|
|
104
|
+
| `LARKIT_HOME` | override `~/.larkit` (state, CLI, skill records, downloads) |
|
|
105
|
+
| `LARKIT_DATA_DIR` | truth.db + formatted corpus location |
|
|
106
|
+
| `LARKIT_VERIFIED_DIR` | writable `verified_truths` directory |
|
|
107
|
+
| `LARKIT_PACK_MANIFEST` | default truth pack manifest URL/path |
|
|
108
|
+
| `LARKIT_DEB` / `LARKIT_WEB_DUMP` | inputs for `init --from-assets` |
|
|
109
|
+
| `LARKIT_BIN_DIR` | where the `larkit` shim is written (default `~/.local/bin`) |
|
|
110
|
+
| `FEISHU_APP_ID` / `FEISHU_APP_SECRET` | default probe credentials |
|
|
111
|
+
| `CHROME_PATH` | Chrome for `browser_fallback` |
|
|
112
|
+
| `LARKIT_PROFILE` | default auth profile |
|
|
113
|
+
| `LARKIT_GATEWAY_HEADERS` | extra client gateway headers (JSON) |
|
|
114
|
+
|
|
115
|
+
Config layers: `<data>/config.json` < `~/.larkit/config.json` < `./larkit.config.json`.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Truth model
|
|
2
|
+
|
|
3
|
+
## What the index contains
|
|
4
|
+
|
|
5
|
+
| Layer | Scope | Content |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| `verified` | `verified_truths` | conclusions you wrote with `save_verified` (section-level FTS) |
|
|
8
|
+
| `protocol` | `protocol_catalog`, `protocol_docs` | 2020 client commands (cmd/name/request/response/kind) + `PROTOCOL.md`/endpoints notes |
|
|
9
|
+
| `web` | `code_anchors` | 531 deduplicated Web client resources (formatted, line-accurate) |
|
|
10
|
+
| `electron` | `code_anchors` | 8500+ desktop renderer/preload/JSSDK/worker files from `webcontent/*.asar` |
|
|
11
|
+
|
|
12
|
+
Index scale depends on the client version used to build the pack (current reference:
|
|
13
|
+
2020 commands, 237,729 anchors, 12 verified sections, 17 protocol doc sections).
|
|
14
|
+
|
|
15
|
+
`code_anchors` cover: OpenAPI paths, URLs, IPC channels, `tt.*`/`h5sdk.*`/`LarkAPI.*`, card schema
|
|
16
|
+
keys, error codes, i18n keys, protocol constants, command names.
|
|
17
|
+
|
|
18
|
+
## Coordinates and reading
|
|
19
|
+
|
|
20
|
+
- Search returns `file_path` **relative to the formatted corpus** (e.g.
|
|
21
|
+
`electron/asar/messenger/35983/f7adb73684.js`) plus `line_number`.
|
|
22
|
+
- Pass that path verbatim to `read_slice`; do not prepend anything.
|
|
23
|
+
- `verified_truths/<slug>.md` paths are also readable; sections carry `line_start`/`line_end`.
|
|
24
|
+
- `read_slice` is bounded (default 12 lines of context, `max_lines <= 120`). Reading a whole
|
|
25
|
+
formatted file is forbidden — the corpus is ~14M lines.
|
|
26
|
+
|
|
27
|
+
## Writing knowledge (`save_verified`)
|
|
28
|
+
|
|
29
|
+
File format (one topic per file):
|
|
30
|
+
|
|
31
|
+
```md
|
|
32
|
+
---
|
|
33
|
+
topic: ...
|
|
34
|
+
verified_at: ...
|
|
35
|
+
source: probe | reverse | doc | console
|
|
36
|
+
confidence: high | medium | low
|
|
37
|
+
tags: [...]
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 结论
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
## 证据
|
|
44
|
+
- probe log_id / URL / file:line
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Rules:
|
|
48
|
+
|
|
49
|
+
- Only verified facts. Guesses stay in the conversation.
|
|
50
|
+
- Include a minimal reproduction (request/response/JSON snippet) so the conclusion is actionable.
|
|
51
|
+
- Never write credentials, tokens, or full auth headers.
|
|
52
|
+
- After writing, `lark_truth_search(scope="verified")` must hit immediately (SQLite incremental
|
|
53
|
+
sync). If sync fails, the Markdown is still saved and will be indexed on the next rebuild.
|
|
54
|
+
|
|
55
|
+
## Storage layout
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
~/.larkit/
|
|
59
|
+
├── data/
|
|
60
|
+
│ ├── truth.db # SQLite FTS5 index
|
|
61
|
+
│ ├── truth.meta.json # build stats
|
|
62
|
+
│ ├── formatted/ # optional code corpus (search works without it)
|
|
63
|
+
│ ├── cache/ # docs, tokens, screenshots, login logs
|
|
64
|
+
│ └── console-api-recipes.json
|
|
65
|
+
├── verified_truths/ # user-owned Markdown knowledge
|
|
66
|
+
├── cli/ # durable CLI install (npm prefix)
|
|
67
|
+
├── downloads/ # cached truth/code pack archives
|
|
68
|
+
├── install.json # bootstrap state + seed hashes
|
|
69
|
+
├── mcp.json # MCP client config
|
|
70
|
+
├── config.json # user-level config (profiles/apps)
|
|
71
|
+
└── credentials.json # auth profiles
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Engineers can override `LARKIT_HOME`, `LARKIT_DATA_DIR`, `LARKIT_VERIFIED_DIR`. A git checkout
|
|
75
|
+
continues to use `<repo>/.larkit-data` and `<repo>/verified_truths` for `pnpm index` / `pnpm verify`.
|
|
76
|
+
|
|
77
|
+
## Installing / updating
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npx larkit # truth pack (truth.db + code corpus) + skill + CLI + MCP config
|
|
81
|
+
npx larkit update # refresh only outdated pieces (truth check runs on every command)
|
|
82
|
+
npx larkit status --json
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Truth pack sources, in priority order:
|
|
86
|
+
|
|
87
|
+
1. `--pack=<manifest.json|dir|.tar.gz|https://...>`
|
|
88
|
+
2. `--from-assets --deb=... --web-dump=...` (local build; Linux + `dpkg-deb`)
|
|
89
|
+
3. `--channel[=<url>]` — the official channel, also the default
|
|
90
|
+
4. `LARKIT_PACK_MANIFEST`
|
|
91
|
+
|
|
92
|
+
There is exactly one archive (`tiers.full`, ~161 MB): `truth.db` + verified seeds + `formatted/`.
|
|
93
|
+
Nothing is optional — installing always brings the code corpus with it.
|
|
94
|
+
|
|
95
|
+
Seed `verified_truths` files are content-hashed. On update they are replaced only if unmodified;
|
|
96
|
+
user edits are preserved.
|
|
97
|
+
|
|
98
|
+
## Rebuilding (maintainers)
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pnpm index # full pipeline into .larkit-data
|
|
102
|
+
node scripts/build-index.mjs --only=protocol,db
|
|
103
|
+
node scripts/build-index.mjs --only=anchors,protocol,db
|
|
104
|
+
npx larkit pack build --out=dist/truth-pack --tag=2026.11.15 --base-url=https://host/path
|
|
105
|
+
node scripts/publish-pack.mjs --pack-dir=dist/truth-pack --base-url=https://host/path
|
|
106
|
+
npx larkit pack verify dist/truth-pack/manifest.json
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Boundaries
|
|
110
|
+
|
|
111
|
+
- Desktop main process is native (`feishu`/`libiron`); there is no `app.asar`. The corpus covers
|
|
112
|
+
renderer/preload/JSSDK/worker/protocol assets only.
|
|
113
|
+
- i18n bundles are excluded from formatting; query specific keys if needed.
|
|
114
|
+
- Official docs endpoints and console APIs are public but may rate-limit or change; caches and
|
|
115
|
+
`browser_fallback` recordings are the fallback.
|
|
116
|
+
- Private protocol routes and console internals drift with client versions. Record the version you
|
|
117
|
+
verified against.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import QRCode from "qrcode";
|
|
4
|
+
import { CACHE_DIR } from "../shared/paths.js";
|
|
5
|
+
import { ensureDir } from "../shared/util.js";
|
|
6
|
+
import { launchBrowser } from "../shared/browser.js";
|
|
7
|
+
import { saveProfile } from "./store.js";
|
|
8
|
+
|
|
9
|
+
const LOGIN_URL =
|
|
10
|
+
"https://accounts.feishu.cn/accounts/page/login?app_id=11&force_login=1&no_trap=1&redirect_uri=https%3A%2F%2Fwww.feishu.cn%2F";
|
|
11
|
+
const NAV_TIMEOUT = 45_000;
|
|
12
|
+
const TOKEN_TIMEOUT = 30_000;
|
|
13
|
+
|
|
14
|
+
function safeName(name) {
|
|
15
|
+
return String(name || "default").replace(/[^\p{L}\p{N}_-]+/gu, "_");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function writeStatus(file, data) {
|
|
19
|
+
if (!file) return;
|
|
20
|
+
ensureDir(path.dirname(file));
|
|
21
|
+
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function loginWithQr({
|
|
25
|
+
name = "default",
|
|
26
|
+
timeoutMs = 120_000,
|
|
27
|
+
headless = true,
|
|
28
|
+
statusFile,
|
|
29
|
+
} = {}) {
|
|
30
|
+
ensureDir(path.join(CACHE_DIR, "login"));
|
|
31
|
+
const base = `${safeName(name)}-${Date.now()}`;
|
|
32
|
+
const qrPath = path.join(CACHE_DIR, "login", `${base}.png`);
|
|
33
|
+
const screenshotPath = path.join(CACHE_DIR, "login", `${base}-page.png`);
|
|
34
|
+
const status = (phase, extra = {}) =>
|
|
35
|
+
writeStatus(statusFile, {
|
|
36
|
+
phase,
|
|
37
|
+
profile: name,
|
|
38
|
+
updated_at: new Date().toISOString(),
|
|
39
|
+
...extra,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
status("starting");
|
|
43
|
+
let browser;
|
|
44
|
+
let page;
|
|
45
|
+
try {
|
|
46
|
+
browser = await launchBrowser({ headless });
|
|
47
|
+
page = await browser.newPage();
|
|
48
|
+
status("loading", { login_url: LOGIN_URL });
|
|
49
|
+
|
|
50
|
+
const tokenPromise = new Promise((resolve, reject) => {
|
|
51
|
+
const timer = setTimeout(
|
|
52
|
+
() => reject(new Error("waiting for qrlogin/init timed out")),
|
|
53
|
+
TOKEN_TIMEOUT,
|
|
54
|
+
);
|
|
55
|
+
page.on("response", async (response) => {
|
|
56
|
+
if (!response.url().includes("/accounts/qrlogin/init")) return;
|
|
57
|
+
try {
|
|
58
|
+
const body = await response.json();
|
|
59
|
+
const token = body?.data?.step_info?.token;
|
|
60
|
+
if (token) {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
resolve(token);
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// ignore non-JSON responses
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await page.goto(LOGIN_URL, {
|
|
72
|
+
waitUntil: "networkidle2",
|
|
73
|
+
timeout: NAV_TIMEOUT,
|
|
74
|
+
});
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (!page.url().includes("accounts.feishu.cn")) throw error;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const token = await tokenPromise;
|
|
80
|
+
const qrData = JSON.stringify({ qrlogin: { token } });
|
|
81
|
+
await QRCode.toFile(qrPath, qrData, {
|
|
82
|
+
width: 640,
|
|
83
|
+
margin: 2,
|
|
84
|
+
errorCorrectionLevel: "M",
|
|
85
|
+
});
|
|
86
|
+
status("qr_ready", { qr_path: qrPath, final_url: page.url() });
|
|
87
|
+
|
|
88
|
+
const deadline = Date.now() + Math.max(5_000, Number(timeoutMs) || 120_000);
|
|
89
|
+
let lastCookies = [];
|
|
90
|
+
while (Date.now() < deadline) {
|
|
91
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
92
|
+
try {
|
|
93
|
+
lastCookies = await browser.cookies();
|
|
94
|
+
} catch {
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
const session = lastCookies.find((cookie) => cookie.name === "session");
|
|
98
|
+
const device = lastCookies.find(
|
|
99
|
+
(cookie) => cookie.name === "passport_web_did",
|
|
100
|
+
);
|
|
101
|
+
if (session?.value && device?.value) {
|
|
102
|
+
const cookie = lastCookies
|
|
103
|
+
.filter((item) => item.domain.includes("feishu.cn"))
|
|
104
|
+
.map((item) => `${item.name}=${item.value}`)
|
|
105
|
+
.join("; ");
|
|
106
|
+
const saved = saveProfile({ name, cookie, active: true });
|
|
107
|
+
status("success", {
|
|
108
|
+
qr_path: qrPath,
|
|
109
|
+
final_url: page.url(),
|
|
110
|
+
cookies: saved.cookies,
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
ok: true,
|
|
114
|
+
profile: name,
|
|
115
|
+
cookies: saved.cookies,
|
|
116
|
+
qr_path: qrPath,
|
|
117
|
+
final_url: page.url(),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
await page.screenshot({ path: screenshotPath, fullPage: false });
|
|
124
|
+
} catch {
|
|
125
|
+
// ignore
|
|
126
|
+
}
|
|
127
|
+
status("timeout", {
|
|
128
|
+
qr_path: qrPath,
|
|
129
|
+
screenshot_path: screenshotPath,
|
|
130
|
+
final_url: page.url(),
|
|
131
|
+
cookie_names: lastCookies.map((cookie) => cookie.name),
|
|
132
|
+
});
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
profile: name,
|
|
136
|
+
error: "QR login timed out",
|
|
137
|
+
qr_path: qrPath,
|
|
138
|
+
screenshot_path: screenshotPath,
|
|
139
|
+
final_url: page.url(),
|
|
140
|
+
hint: "Open qr_path and scan with Feishu, then retry. Or use lark_auth action=import with a cookie string/file.",
|
|
141
|
+
};
|
|
142
|
+
} catch (error) {
|
|
143
|
+
try {
|
|
144
|
+
if (page) {
|
|
145
|
+
await page.screenshot({ path: screenshotPath, fullPage: false });
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// ignore
|
|
149
|
+
}
|
|
150
|
+
status("error", {
|
|
151
|
+
error: error instanceof Error ? error.message : String(error),
|
|
152
|
+
qr_path: fs.existsSync(qrPath) ? qrPath : null,
|
|
153
|
+
screenshot_path: fs.existsSync(screenshotPath) ? screenshotPath : null,
|
|
154
|
+
final_url: page?.url?.(),
|
|
155
|
+
});
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
profile: name,
|
|
159
|
+
error: error instanceof Error ? error.message : String(error),
|
|
160
|
+
qr_path: fs.existsSync(qrPath) ? qrPath : null,
|
|
161
|
+
screenshot_path: fs.existsSync(screenshotPath) ? screenshotPath : null,
|
|
162
|
+
final_url: page?.url?.(),
|
|
163
|
+
};
|
|
164
|
+
} finally {
|
|
165
|
+
try {
|
|
166
|
+
await browser?.close();
|
|
167
|
+
} catch {
|
|
168
|
+
// ignore
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import {
|
|
5
|
+
CACHE_DIR,
|
|
6
|
+
LOCAL_CONFIG_FILE,
|
|
7
|
+
ROOT,
|
|
8
|
+
} from "../shared/paths.js";
|
|
9
|
+
import { ensureDir, readJson, writeJson } from "../shared/util.js";
|
|
10
|
+
import { resolveConsoleSession } from "../console/client.js";
|
|
11
|
+
import {
|
|
12
|
+
authPaths,
|
|
13
|
+
getProfileCookies,
|
|
14
|
+
importProfiles,
|
|
15
|
+
listProfiles,
|
|
16
|
+
removeProfile,
|
|
17
|
+
resolveProfileName,
|
|
18
|
+
setActiveProfile,
|
|
19
|
+
} from "./store.js";
|
|
20
|
+
import { loginWithQr } from "./login.js";
|
|
21
|
+
|
|
22
|
+
function statusFileFor(name) {
|
|
23
|
+
const safe = String(name || "default").replace(/[^\p{L}\p{N}_-]+/gu, "_");
|
|
24
|
+
return path.join(CACHE_DIR, "login", `${safe}.status.json`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function authList() {
|
|
28
|
+
return {
|
|
29
|
+
...listProfiles(),
|
|
30
|
+
home: authPaths().home,
|
|
31
|
+
credentials_file: authPaths().credentials,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function whoami({ profile } = {}) {
|
|
36
|
+
const name = resolveProfileName(profile);
|
|
37
|
+
if (!name) {
|
|
38
|
+
return { ok: false, error: "no larkit profiles; run lark_auth action=login or import" };
|
|
39
|
+
}
|
|
40
|
+
getProfileCookies(name);
|
|
41
|
+
try {
|
|
42
|
+
const session = await resolveConsoleSession(name, { force: true });
|
|
43
|
+
return {
|
|
44
|
+
ok: session.valid,
|
|
45
|
+
profile: name,
|
|
46
|
+
user: session.user,
|
|
47
|
+
console_logged_in: session.valid,
|
|
48
|
+
hint: session.valid
|
|
49
|
+
? undefined
|
|
50
|
+
: "cookie expired; run lark_auth action=login or import a fresh cookie",
|
|
51
|
+
};
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
profile: name,
|
|
56
|
+
error: error instanceof Error ? error.message : String(error),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function checkAuth({ profile } = {}) {
|
|
62
|
+
const name = resolveProfileName(profile);
|
|
63
|
+
if (!name) {
|
|
64
|
+
return { ok: false, error: "no larkit profiles; run lark_auth action=login or import" };
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const session = await resolveConsoleSession(name, { force: true });
|
|
68
|
+
return {
|
|
69
|
+
ok: session.valid,
|
|
70
|
+
profile: name,
|
|
71
|
+
user: session.user,
|
|
72
|
+
console_logged_in: session.valid,
|
|
73
|
+
page_status: session.page_status,
|
|
74
|
+
csrf_present: Boolean(session.csrf),
|
|
75
|
+
hint: session.valid
|
|
76
|
+
? undefined
|
|
77
|
+
: "cookie expired; run lark_auth action=login or import a fresh cookie",
|
|
78
|
+
};
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
profile: name,
|
|
83
|
+
error: error instanceof Error ? error.message : String(error),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function useProfile(name) {
|
|
89
|
+
if (!name) throw new Error("profile name is required");
|
|
90
|
+
const result = setActiveProfile(name);
|
|
91
|
+
const local = readJson(LOCAL_CONFIG_FILE, {});
|
|
92
|
+
writeJson(LOCAL_CONFIG_FILE, { ...local, defaultProfile: name });
|
|
93
|
+
return { ...result, default_profile: name };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function cookieFor(profile) {
|
|
97
|
+
const { name, cookies } = getProfileCookies(profile);
|
|
98
|
+
return { ok: true, profile: name, cookie: cookies, length: cookies.length };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function startLogin(
|
|
102
|
+
name = "default",
|
|
103
|
+
{ timeoutMs = 120_000, headless = true, wait = false } = {},
|
|
104
|
+
) {
|
|
105
|
+
ensureDir(path.join(CACHE_DIR, "login"));
|
|
106
|
+
const statusFile = statusFileFor(name);
|
|
107
|
+
if (wait) {
|
|
108
|
+
const result = loginWithQr({ name, timeoutMs, headless, statusFile });
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
const logPath = path.join(CACHE_DIR, "login", `${name.replace(/[^\w.-]+/g, "_")}.log`);
|
|
112
|
+
const fd = fs.openSync(logPath, "w");
|
|
113
|
+
const child = spawn(
|
|
114
|
+
process.execPath,
|
|
115
|
+
[
|
|
116
|
+
path.join(ROOT, "scripts", "login-worker.mjs"),
|
|
117
|
+
"--name",
|
|
118
|
+
name,
|
|
119
|
+
"--timeout",
|
|
120
|
+
String(timeoutMs),
|
|
121
|
+
"--status",
|
|
122
|
+
statusFile,
|
|
123
|
+
headless ? "--headless" : "--headful",
|
|
124
|
+
],
|
|
125
|
+
{
|
|
126
|
+
detached: true,
|
|
127
|
+
stdio: ["ignore", fd, fd],
|
|
128
|
+
cwd: ROOT,
|
|
129
|
+
env: { ...process.env },
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
child.unref();
|
|
133
|
+
fs.closeSync(fd);
|
|
134
|
+
return {
|
|
135
|
+
ok: true,
|
|
136
|
+
started: true,
|
|
137
|
+
profile: name,
|
|
138
|
+
pid: child.pid,
|
|
139
|
+
status_file: statusFile,
|
|
140
|
+
log_path: logPath,
|
|
141
|
+
qr_path: path.join(CACHE_DIR, "login", "*.png"),
|
|
142
|
+
hint:
|
|
143
|
+
"Login worker started. Wait a few seconds, read status_file for qr_path, scan the PNG with Feishu, then call lark_auth action=check.",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function loginStatus(name = "default") {
|
|
148
|
+
const statusFile = statusFileFor(name);
|
|
149
|
+
if (!fs.existsSync(statusFile)) {
|
|
150
|
+
return { ok: false, profile: name, status_file: statusFile, error: "no login status yet" };
|
|
151
|
+
}
|
|
152
|
+
return { ok: true, profile: name, status_file: statusFile, ...readJson(statusFile, {}) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function logout(name) {
|
|
156
|
+
if (!name) throw new Error("profile name is required");
|
|
157
|
+
return removeProfile(name);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function importAuth({ file, cookie, name = "default", active = true } = {}) {
|
|
161
|
+
let input = cookie;
|
|
162
|
+
if (file) {
|
|
163
|
+
const text = fs.readFileSync(path.resolve(file), "utf8");
|
|
164
|
+
try {
|
|
165
|
+
input = JSON.parse(text);
|
|
166
|
+
} catch {
|
|
167
|
+
input = text;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!input) throw new Error("cookie or file is required");
|
|
171
|
+
return importProfiles(input, { active });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function authDispatch({
|
|
175
|
+
action,
|
|
176
|
+
profile,
|
|
177
|
+
name,
|
|
178
|
+
browser,
|
|
179
|
+
file,
|
|
180
|
+
cookie,
|
|
181
|
+
wait,
|
|
182
|
+
timeout_ms: timeoutMs,
|
|
183
|
+
headless,
|
|
184
|
+
} = {}) {
|
|
185
|
+
switch (action) {
|
|
186
|
+
case "list":
|
|
187
|
+
return authList();
|
|
188
|
+
case "whoami":
|
|
189
|
+
return whoami({ profile });
|
|
190
|
+
case "check":
|
|
191
|
+
return checkAuth({ profile });
|
|
192
|
+
case "use":
|
|
193
|
+
return useProfile(name || profile);
|
|
194
|
+
case "cookie":
|
|
195
|
+
return cookieFor(profile);
|
|
196
|
+
case "login":
|
|
197
|
+
return startLogin(name || profile || "default", {
|
|
198
|
+
timeoutMs,
|
|
199
|
+
wait,
|
|
200
|
+
headless: headless !== false && browser !== "headful",
|
|
201
|
+
});
|
|
202
|
+
case "login_status":
|
|
203
|
+
return loginStatus(name || profile || "default");
|
|
204
|
+
case "import":
|
|
205
|
+
return importAuth({ file, cookie, name: name || profile || "default" });
|
|
206
|
+
case "logout":
|
|
207
|
+
return logout(name || profile);
|
|
208
|
+
default:
|
|
209
|
+
throw new Error(
|
|
210
|
+
`unknown auth action: ${action}; expected list|whoami|check|use|cookie|login|login_status|import|logout`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|