latchkey 0.1.0 → 0.1.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/package.json +8 -8
- package/.nvmrc +0 -1
- package/.pre-commit-config.yaml +0 -22
- package/.prettierignore +0 -4
- package/.prettierrc +0 -7
- package/CLAUDE.md +0 -13
- package/docs/development.md +0 -94
- package/eslint.config.js +0 -30
- package/integrations/SKILL.md +0 -62
- package/scripts/cryptFile.ts +0 -123
- package/scripts/recordBrowserSession.ts +0 -280
- package/scripts/tsconfig.json +0 -10
- package/src/apiCredentialStore.ts +0 -87
- package/src/apiCredentials.ts +0 -180
- package/src/cli.ts +0 -32
- package/src/cliCommands.ts +0 -321
- package/src/config.ts +0 -115
- package/src/curl.ts +0 -78
- package/src/encryptedStorage.ts +0 -161
- package/src/encryption.ts +0 -106
- package/src/index.ts +0 -65
- package/src/keychain.ts +0 -105
- package/src/playwrightUtils.ts +0 -143
- package/src/registry.ts +0 -35
- package/src/services/base.ts +0 -234
- package/src/services/discord.ts +0 -73
- package/src/services/dropbox.ts +0 -173
- package/src/services/github.ts +0 -139
- package/src/services/index.ts +0 -13
- package/src/services/linear.ts +0 -134
- package/src/services/slack.ts +0 -85
- package/tests/apiCredentialStore.test.ts +0 -162
- package/tests/apiCredentials.test.ts +0 -195
- package/tests/cli.test.ts +0 -798
- package/tests/encryptedStorage.test.ts +0 -173
- package/tests/encryption.test.ts +0 -169
- package/tests/lint.test.ts +0 -19
- package/tests/registry.test.ts +0 -103
- package/tests/servicesAgainstRecordings.test.ts +0 -230
- package/tests/typecheck.test.ts +0 -19
- package/tsconfig.json +0 -24
- package/vitest.config.ts +0 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "latchkey",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "A CLI tool that injects API credentials into curl requests for known third-party services",
|
|
5
5
|
"author": "Imbue <hynek@imbue.com>",
|
|
6
6
|
"repository": {
|
|
@@ -12,19 +12,19 @@
|
|
|
12
12
|
"url": "https://github.com/imbue-ai/latchkey/issues"
|
|
13
13
|
},
|
|
14
14
|
"type": "module",
|
|
15
|
-
"main": "dist/index.js",
|
|
16
|
-
"types": "dist/index.d.ts",
|
|
15
|
+
"main": "dist/src/index.js",
|
|
16
|
+
"types": "dist/src/index.d.ts",
|
|
17
17
|
"bin": {
|
|
18
18
|
"latchkey": "./dist/src/cli.js"
|
|
19
19
|
},
|
|
20
|
-
"
|
|
21
|
-
"postinstall": "npx playwright install chromium",
|
|
22
|
-
"prepublishOnly": "npm run build",
|
|
23
|
-
"files": [
|
|
20
|
+
"files": [
|
|
24
21
|
"dist",
|
|
25
22
|
"README.md",
|
|
26
23
|
"LICENSE"
|
|
27
|
-
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"postinstall": "npx playwright install chromium",
|
|
27
|
+
"prepublishOnly": "npm run build",
|
|
28
28
|
"build": "tsc",
|
|
29
29
|
"dev": "tsc --watch",
|
|
30
30
|
"lint": "eslint src tests scripts",
|
package/.nvmrc
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
24.13.0
|
package/.pre-commit-config.yaml
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
default_install_hook_types: [pre-commit]
|
|
2
|
-
|
|
3
|
-
repos:
|
|
4
|
-
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
5
|
-
rev: v6.0.0
|
|
6
|
-
hooks:
|
|
7
|
-
- id: trailing-whitespace
|
|
8
|
-
- id: end-of-file-fixer
|
|
9
|
-
- id: check-added-large-files
|
|
10
|
-
exclude: 'uv.lock'
|
|
11
|
-
- id: check-yaml
|
|
12
|
-
- repo: https://github.com/astral-sh/uv-pre-commit
|
|
13
|
-
rev: 0.7.22
|
|
14
|
-
hooks:
|
|
15
|
-
- id: uv-lock
|
|
16
|
-
- repo: local
|
|
17
|
-
hooks:
|
|
18
|
-
- id: ruff
|
|
19
|
-
name: "Python formatter + import sorter (ruff)"
|
|
20
|
-
entry: bash -c 'uv run ruff check --select UP006,UP007,I,F401 --fix --force-exclude --config pyproject.toml "$@" && uv run ruff format --force-exclude --config pyproject.toml "$@"' --
|
|
21
|
-
language: system
|
|
22
|
-
types: [python]
|
package/.prettierignore
DELETED
package/.prettierrc
DELETED
package/CLAUDE.md
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
For information about the high-level goal and motivations, see README.md.
|
|
2
|
-
|
|
3
|
-
You are a highly intelligent and experienced software creator that codes in a straightforward way, prefers simplicity and avoids overengineering.
|
|
4
|
-
|
|
5
|
-
Typescript style guide:
|
|
6
|
-
|
|
7
|
-
- Use modern Typescript code.
|
|
8
|
-
- Prefer functional, stateless logic as much as possible.
|
|
9
|
-
- Use immutable data structures as much as possible.
|
|
10
|
-
- Do not use abbreviations in variable (class, function, ...) names. It's fine for names to be somewhat verbose.
|
|
11
|
-
- Omit docstrings if they don't add any value beyond what can be obviously inferred from the function signature / class name.
|
|
12
|
-
- Do not throw builtin errors; always replace them with dedicated error subclasses.
|
|
13
|
-
- When done, validate your changes by running `npm lint` and `npm test`.
|
package/docs/development.md
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
# Developing Latchkey
|
|
2
|
-
|
|
3
|
-
Thank you for considering contributing to Latchkey!
|
|
4
|
-
|
|
5
|
-
## Setting up your environment
|
|
6
|
-
|
|
7
|
-
Make sure you're using [nvm](https://github.com/nvm-sh/nvm) so that your node version
|
|
8
|
-
corresponds to the one listed in `.nvmrc`.
|
|
9
|
-
|
|
10
|
-
After that, the easiest way to set up your system so that you can run
|
|
11
|
-
Latchkey while working on it is to clone this repository and
|
|
12
|
-
then run:
|
|
13
|
-
|
|
14
|
-
```
|
|
15
|
-
npm install && npm run build && npm link
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
After that, every time you make a change to the code, run
|
|
19
|
-
`npm run rebuild`. Invoking `latchkey` in your terminal will
|
|
20
|
-
then use the version you just built.
|
|
21
|
-
|
|
22
|
-
## Before you submit a PR
|
|
23
|
-
|
|
24
|
-
- Run `npm lint` and `npm test` to validate your changes.
|
|
25
|
-
- Run `npm format` to apply autoformatting.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
## Adding a new service
|
|
29
|
-
|
|
30
|
-
Each third-party service needs to be approached slightly
|
|
31
|
-
differently. When adding support for a new service, you need to
|
|
32
|
-
start by asking yourself the following question:
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
_Can an API token be extracted from the network traffic that flows between the browser and the service's website during or after login?_
|
|
36
|
-
|
|
37
|
-
If the answer is yes, see how the [Discord](../src/services/discord.ts) service is implemented and try to do it similarly.
|
|
38
|
-
|
|
39
|
-
Otherwise, ask yourself the following question:
|
|
40
|
-
|
|
41
|
-
_Can an API token be created in the user's account (e.g. in Developer settings)?_
|
|
42
|
-
|
|
43
|
-
If so, see how the [Linear](../src/services/linear.ts) service is implemented and try to do it similarly.
|
|
44
|
-
|
|
45
|
-
When possible, the first option (extracting the token from the
|
|
46
|
-
network traffic) is always preferable because it's simpler, more
|
|
47
|
-
robust, and less invasive. If the answer is no in both cases,
|
|
48
|
-
it's a special case and you're on your own!
|
|
49
|
-
|
|
50
|
-
Above, when we say "API", we always mean a public API. Do
|
|
51
|
-
not expose undocumented private APIs through Latchkey - agents
|
|
52
|
-
should be able to determine usage by consulting the documentation.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
### Potentially useful helpers
|
|
56
|
-
|
|
57
|
-
#### Request / response recorder
|
|
58
|
-
|
|
59
|
-
Use this to record the request/response pairs of your browser
|
|
60
|
-
login sequence as plaintext JSON files. The resulting recording
|
|
61
|
-
can be inspected, either manually or with the help of AI, to see
|
|
62
|
-
if you can extract an API token or something similar from there.
|
|
63
|
-
|
|
64
|
-
```
|
|
65
|
-
npx tsx scripts/recordBrowserSession.ts <service_name>
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
If you have `jq` installed on your system, you can then
|
|
69
|
-
start exploring, for instance like this:
|
|
70
|
-
|
|
71
|
-
```
|
|
72
|
-
cat path/to/recording/login_session.json | jq -C | less -R
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
#### File encryptor / decryptor
|
|
76
|
-
|
|
77
|
-
During development, it may sometimes be necessary to inspect the
|
|
78
|
-
credentials stored in `~/.latchkey/credentials.json.enc`.
|
|
79
|
-
|
|
80
|
-
To do that, you can use `scripts/cryptFile.ts`. For example:
|
|
81
|
-
|
|
82
|
-
```
|
|
83
|
-
npx tsx scripts/cryptFile.ts decrypt ~/.latchkey/credentials.json.enc
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
#### Browser automation recorder
|
|
88
|
-
|
|
89
|
-
When automating the browser login follow-up, you can sometimes
|
|
90
|
-
use Playwright's codegen functionality, for example:
|
|
91
|
-
|
|
92
|
-
```
|
|
93
|
-
npx playwright codegen --target=javascript https://login-page.example.com/
|
|
94
|
-
```
|
package/eslint.config.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import eslint from "@eslint/js";
|
|
2
|
-
import tseslint from "typescript-eslint";
|
|
3
|
-
|
|
4
|
-
export default tseslint.config(
|
|
5
|
-
eslint.configs.recommended,
|
|
6
|
-
...tseslint.configs.strictTypeChecked,
|
|
7
|
-
...tseslint.configs.stylisticTypeChecked,
|
|
8
|
-
{
|
|
9
|
-
languageOptions: {
|
|
10
|
-
parserOptions: {
|
|
11
|
-
projectService: true,
|
|
12
|
-
tsconfigRootDir: import.meta.dirname,
|
|
13
|
-
},
|
|
14
|
-
},
|
|
15
|
-
},
|
|
16
|
-
{
|
|
17
|
-
ignores: ["dist/", "node_modules/", "eslint.config.js"],
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
rules: {
|
|
21
|
-
// Allow unused variables prefixed with underscore
|
|
22
|
-
"@typescript-eslint/no-unused-vars": [
|
|
23
|
-
"error",
|
|
24
|
-
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
|
25
|
-
],
|
|
26
|
-
// Allow non-null assertions when needed
|
|
27
|
-
"@typescript-eslint/no-non-null-assertion": "off",
|
|
28
|
-
},
|
|
29
|
-
}
|
|
30
|
-
);
|
package/integrations/SKILL.md
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: latchkey
|
|
3
|
-
description: Interact with third-party services (Slack, Discord, Dropbox, GitHub, Linear...) on user's behalf using their public APIs.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Latchkey
|
|
7
|
-
|
|
8
|
-
## Instructions
|
|
9
|
-
|
|
10
|
-
Latchkey is a CLI tool that automatically injects credentials into curl commands for supported public APIs. Instead of manually managing API tokens, latchkey opens a browser for login, extracts credentials from the session, and injects them into your curl requests.
|
|
11
|
-
|
|
12
|
-
Use this skill when the user asks you to work with third-party services like Slack, Discord, Dropbox, Github, Linear and others on their behalf.
|
|
13
|
-
|
|
14
|
-
Usage:
|
|
15
|
-
|
|
16
|
-
1. **Use `latchkey curl`** instead of regular `curl` for supported services.
|
|
17
|
-
2. **Look for the newest documentation of the desired public API online.**
|
|
18
|
-
3. **Pass through all regular curl arguments** - latchkey is a transparent wrapper.
|
|
19
|
-
4. **Use `latchkey status <service_name>`** when you notice potentially expired credentials.
|
|
20
|
-
5. When the status is `invalid`, **force a new login by calling `latchkey clear <service_name>`**, then retry the curl command.
|
|
21
|
-
6. **Do not force a new login if the status is `valid`** - the user might just not have the necessary permissions.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
## Examples
|
|
25
|
-
|
|
26
|
-
### Make an authenticated curl request
|
|
27
|
-
```bash
|
|
28
|
-
latchkey curl [curl arguments]
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
### Creating a Slack channel
|
|
32
|
-
```bash
|
|
33
|
-
latchkey curl -X POST 'https://slack.com/api/conversations.create' \
|
|
34
|
-
-H 'Content-Type: application/json' \
|
|
35
|
-
-d '{"name":"my-channel"}'
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
(Notice that `-H 'Authorization: Bearer` is not present in the invocation.)
|
|
39
|
-
|
|
40
|
-
### Getting Discord user info
|
|
41
|
-
```bash
|
|
42
|
-
latchkey curl 'https://discord.com/api/v10/users/@me'
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
### Clear expired credentials and force a new login to Discord
|
|
46
|
-
```bash
|
|
47
|
-
latchkey status discord # Returns "invalid"
|
|
48
|
-
latchkey clear discord
|
|
49
|
-
latchkey curl 'https://discord.com/api/v10/users/@me'
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
Only do this when you notice that your previous call ended up not being authenticated (HTTP 401 or 403). The next `latchkey curl` call will trigger a new login flow.
|
|
53
|
-
|
|
54
|
-
### List supported services
|
|
55
|
-
```bash
|
|
56
|
-
latchkey services
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
## Notes
|
|
60
|
-
|
|
61
|
-
- All curl arguments are passed through unchanged
|
|
62
|
-
- Return codes, stdin, and stdout are passed back from curl
|
package/scripts/cryptFile.ts
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env npx tsx
|
|
2
|
-
/**
|
|
3
|
-
* CLI tool for encrypting and decrypting latchkey files.
|
|
4
|
-
*
|
|
5
|
-
* This is a developer utility for inspecting and modifying encrypted
|
|
6
|
-
* credential and browser state files.
|
|
7
|
-
*
|
|
8
|
-
* Usage:
|
|
9
|
-
* npx tsx scripts/cryptFile.ts decrypt <file> # Decrypt file in place
|
|
10
|
-
* npx tsx scripts/cryptFile.ts encrypt <file> # Encrypt file in place
|
|
11
|
-
*
|
|
12
|
-
* The encryption key is sourced from:
|
|
13
|
-
* 1. LATCHKEY_ENCRYPTION_KEY environment variable
|
|
14
|
-
* 2. System keychain
|
|
15
|
-
*
|
|
16
|
-
* Examples:
|
|
17
|
-
* npx tsx scripts/cryptFile.ts decrypt ~/.latchkey/credentials.json
|
|
18
|
-
* npx tsx scripts/cryptFile.ts encrypt ~/.latchkey/credentials.json
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { program } from 'commander';
|
|
22
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
|
-
import { CONFIG } from '../src/config.js';
|
|
24
|
-
import { EncryptedStorage } from '../src/encryptedStorage.js';
|
|
25
|
-
import { encrypt, generateKey } from '../src/encryption.js';
|
|
26
|
-
import { isKeychainAvailable, retrieveFromKeychain } from '../src/keychain.js';
|
|
27
|
-
|
|
28
|
-
const ENCRYPTED_FILE_PREFIX = 'LATCHKEY_ENCRYPTED:';
|
|
29
|
-
|
|
30
|
-
function getEncryptionKey(): string {
|
|
31
|
-
// 1. Check environment variable via Config
|
|
32
|
-
if (CONFIG.encryptionKeyOverride) {
|
|
33
|
-
return CONFIG.encryptionKeyOverride;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// 2. Check keychain
|
|
37
|
-
if (isKeychainAvailable(CONFIG.serviceName, CONFIG.accountName)) {
|
|
38
|
-
const keychainKey = retrieveFromKeychain(CONFIG.serviceName, CONFIG.accountName);
|
|
39
|
-
if (keychainKey) {
|
|
40
|
-
return keychainKey;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
console.error(`\
|
|
45
|
-
Error: No encryption key available.
|
|
46
|
-
Set LATCHKEY_ENCRYPTION_KEY or ensure the system keychain has a stored key.
|
|
47
|
-
|
|
48
|
-
To generate a new key:
|
|
49
|
-
export LATCHKEY_ENCRYPTION_KEY="${generateKey()}"`);
|
|
50
|
-
process.exit(1);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function decryptCommand(filePath: string): void {
|
|
54
|
-
if (!existsSync(filePath)) {
|
|
55
|
-
console.error(`Error: File not found: ${filePath}`);
|
|
56
|
-
process.exit(1);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const rawContent = readFileSync(filePath, 'utf-8');
|
|
60
|
-
if (!rawContent.startsWith(ENCRYPTED_FILE_PREFIX)) {
|
|
61
|
-
console.error(`Error: File is not encrypted: ${filePath}`);
|
|
62
|
-
process.exit(1);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const storage = new EncryptedStorage({
|
|
66
|
-
serviceName: CONFIG.serviceName,
|
|
67
|
-
accountName: CONFIG.accountName,
|
|
68
|
-
});
|
|
69
|
-
const content = storage.readFile(filePath);
|
|
70
|
-
|
|
71
|
-
if (content === null) {
|
|
72
|
-
console.error(`Error: Could not read file: ${filePath}`);
|
|
73
|
-
process.exit(1);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
writeFileSync(filePath, content, { encoding: 'utf-8', mode: 0o600 });
|
|
77
|
-
console.error(`Decrypted: ${filePath}`);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function encryptCommand(filePath: string): void {
|
|
81
|
-
if (!existsSync(filePath)) {
|
|
82
|
-
console.error(`Error: File not found: ${filePath}`);
|
|
83
|
-
process.exit(1);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const content = readFileSync(filePath, 'utf-8');
|
|
87
|
-
if (content.startsWith(ENCRYPTED_FILE_PREFIX)) {
|
|
88
|
-
console.error(`Error: File is already encrypted: ${filePath}`);
|
|
89
|
-
process.exit(1);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const key = getEncryptionKey();
|
|
93
|
-
const encryptedData = encrypt(content, key);
|
|
94
|
-
const dataToWrite = ENCRYPTED_FILE_PREFIX + encryptedData;
|
|
95
|
-
|
|
96
|
-
writeFileSync(filePath, dataToWrite, { encoding: 'utf-8', mode: 0o600 });
|
|
97
|
-
console.error(`Encrypted: ${filePath}`);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
program.name('cryptFile').description(`\
|
|
101
|
-
CLI tool for encrypting and decrypting latchkey files.
|
|
102
|
-
|
|
103
|
-
The encryption key is sourced from:
|
|
104
|
-
1. LATCHKEY_ENCRYPTION_KEY environment variable
|
|
105
|
-
2. System keychain`);
|
|
106
|
-
|
|
107
|
-
program
|
|
108
|
-
.command('decrypt')
|
|
109
|
-
.description('Decrypt file in place')
|
|
110
|
-
.argument('<file>', 'Path to the encrypted file')
|
|
111
|
-
.action((filePath: string) => {
|
|
112
|
-
decryptCommand(filePath);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
program
|
|
116
|
-
.command('encrypt')
|
|
117
|
-
.description('Encrypt an unencrypted file in place')
|
|
118
|
-
.argument('<file>', 'Path to the file to encrypt')
|
|
119
|
-
.action((filePath: string) => {
|
|
120
|
-
encryptCommand(filePath);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
program.parse();
|
|
@@ -1,280 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env npx tsx
|
|
2
|
-
/**
|
|
3
|
-
* Record browser requests and responses during a login session.
|
|
4
|
-
*
|
|
5
|
-
* This script opens a browser at a service's login URL and records all HTTP
|
|
6
|
-
* requests and responses (including their headers and timing). When you close the
|
|
7
|
-
* browser, the recording is saved. This is useful for recording login flows that
|
|
8
|
-
* can be replayed later for testing credentials extraction.
|
|
9
|
-
*
|
|
10
|
-
* Usage:
|
|
11
|
-
* npx tsx scripts/recordBrowserSession.ts <service_name> [recording_name]
|
|
12
|
-
*
|
|
13
|
-
* Examples:
|
|
14
|
-
* npx tsx scripts/recordBrowserSession.ts slack
|
|
15
|
-
* npx tsx scripts/recordBrowserSession.ts discord custom_session.json
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
19
|
-
import { dirname, join, resolve } from 'node:path';
|
|
20
|
-
import { fileURLToPath } from 'node:url';
|
|
21
|
-
import type { Response } from 'playwright';
|
|
22
|
-
import { CONFIG } from '../src/config.js';
|
|
23
|
-
import { EncryptedStorage } from '../src/encryptedStorage.js';
|
|
24
|
-
import { withTempBrowserContext } from '../src/playwrightUtils.js';
|
|
25
|
-
import { REGISTRY } from '../src/registry.js';
|
|
26
|
-
|
|
27
|
-
// Get the directory of this file
|
|
28
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
29
|
-
const __dirname = dirname(__filename);
|
|
30
|
-
|
|
31
|
-
// Recordings directory relative to this script
|
|
32
|
-
const RECORDINGS_DIRECTORY = resolve(__dirname, 'recordings');
|
|
33
|
-
|
|
34
|
-
// Default recording filename
|
|
35
|
-
const DEFAULT_RECORDING_NAME = 'login_session.json';
|
|
36
|
-
|
|
37
|
-
class UnknownServiceError extends Error {
|
|
38
|
-
constructor(serviceName: string) {
|
|
39
|
-
super(`Unknown service: ${serviceName}`);
|
|
40
|
-
this.name = 'UnknownServiceError';
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Resource types to skip (CSS, images, fonts, multimedia)
|
|
45
|
-
const SKIPPED_RESOURCE_TYPES = new Set(['stylesheet', 'image', 'media', 'font']);
|
|
46
|
-
|
|
47
|
-
// Common multi-part TLDs
|
|
48
|
-
const MULTI_PART_TLDS = new Set(['co.uk', 'com.au', 'co.nz', 'co.jp', 'com.br', 'co.in']);
|
|
49
|
-
|
|
50
|
-
interface RequestData {
|
|
51
|
-
timestamp_ms: number;
|
|
52
|
-
method: string;
|
|
53
|
-
url: string;
|
|
54
|
-
headers: Record<string, string>;
|
|
55
|
-
resource_type: string;
|
|
56
|
-
post_data?: string;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
interface ResponseData {
|
|
60
|
-
status: number;
|
|
61
|
-
status_text: string;
|
|
62
|
-
headers: Record<string, string>;
|
|
63
|
-
body?: string;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
interface RecordedEntry {
|
|
67
|
-
request: RequestData;
|
|
68
|
-
response: ResponseData;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Extract the base domain from a URL.
|
|
73
|
-
*
|
|
74
|
-
* For example:
|
|
75
|
-
* https://discord.com/login -> discord.com
|
|
76
|
-
* https://api.discord.com/v9/users -> discord.com
|
|
77
|
-
* https://www.example.co.uk/page -> example.co.uk
|
|
78
|
-
*/
|
|
79
|
-
function extractBaseDomain(url: string): string {
|
|
80
|
-
let hostname: string;
|
|
81
|
-
try {
|
|
82
|
-
hostname = new URL(url).hostname;
|
|
83
|
-
} catch {
|
|
84
|
-
return '';
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Split the hostname into parts
|
|
88
|
-
const parts = hostname.split('.');
|
|
89
|
-
|
|
90
|
-
// Handle common multi-part TLDs (e.g., co.uk, com.au)
|
|
91
|
-
if (parts.length >= 3) {
|
|
92
|
-
const potentialTld = parts.slice(-2).join('.');
|
|
93
|
-
if (MULTI_PART_TLDS.has(potentialTld)) {
|
|
94
|
-
return parts.slice(-3).join('.');
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (parts.length >= 2) {
|
|
99
|
-
return parts.slice(-2).join('.');
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return hostname;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Check if a request URL belongs to the same base domain.
|
|
107
|
-
*/
|
|
108
|
-
function isSameBaseDomain(requestUrl: string, baseDomain: string): boolean {
|
|
109
|
-
let requestHostname: string;
|
|
110
|
-
try {
|
|
111
|
-
requestHostname = new URL(requestUrl).hostname;
|
|
112
|
-
} catch {
|
|
113
|
-
return false;
|
|
114
|
-
}
|
|
115
|
-
return requestHostname === baseDomain || requestHostname.endsWith('.' + baseDomain);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Handle a response and record both request and response details.
|
|
120
|
-
*/
|
|
121
|
-
async function handleResponse(
|
|
122
|
-
response: Response,
|
|
123
|
-
recordedEntries: RecordedEntry[],
|
|
124
|
-
startTime: { value: number },
|
|
125
|
-
baseDomain: string
|
|
126
|
-
): Promise<void> {
|
|
127
|
-
const request = response.request();
|
|
128
|
-
|
|
129
|
-
// Skip CSS, images, fonts, and multimedia
|
|
130
|
-
if (SKIPPED_RESOURCE_TYPES.has(request.resourceType())) {
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Skip requests to external domains
|
|
135
|
-
if (!isSameBaseDomain(request.url(), baseDomain)) {
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if (startTime.value === 0) {
|
|
140
|
-
startTime.value = Date.now();
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const timestampMs = Date.now() - startTime.value;
|
|
144
|
-
|
|
145
|
-
const requestData: RequestData = {
|
|
146
|
-
timestamp_ms: timestampMs,
|
|
147
|
-
method: request.method(),
|
|
148
|
-
url: request.url(),
|
|
149
|
-
headers: await request.allHeaders(),
|
|
150
|
-
resource_type: request.resourceType(),
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
// Include POST data if present
|
|
154
|
-
try {
|
|
155
|
-
const postData = request.postData();
|
|
156
|
-
if (postData !== null) {
|
|
157
|
-
requestData.post_data = postData;
|
|
158
|
-
}
|
|
159
|
-
} catch {
|
|
160
|
-
// Post data not available or not decodable
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const responseData: ResponseData = {
|
|
164
|
-
status: response.status(),
|
|
165
|
-
status_text: response.statusText(),
|
|
166
|
-
headers: await response.allHeaders(),
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
// Try to get response body as text (skip binary content)
|
|
170
|
-
try {
|
|
171
|
-
const body = await response.text();
|
|
172
|
-
responseData.body = body;
|
|
173
|
-
} catch {
|
|
174
|
-
// Binary content or other error - skip body
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
recordedEntries.push({
|
|
178
|
-
request: requestData,
|
|
179
|
-
response: responseData,
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Record browser requests and responses during a login session.
|
|
185
|
-
*/
|
|
186
|
-
async function record(
|
|
187
|
-
serviceName: string,
|
|
188
|
-
recordingName: string = DEFAULT_RECORDING_NAME
|
|
189
|
-
): Promise<void> {
|
|
190
|
-
const service = REGISTRY.getByName(serviceName);
|
|
191
|
-
if (service === null) {
|
|
192
|
-
throw new UnknownServiceError(serviceName);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const outputDirectory = join(RECORDINGS_DIRECTORY, serviceName);
|
|
196
|
-
mkdirSync(outputDirectory, { recursive: true });
|
|
197
|
-
const requestsPath = join(outputDirectory, recordingName);
|
|
198
|
-
|
|
199
|
-
const browserStatePath = CONFIG.browserStatePath;
|
|
200
|
-
|
|
201
|
-
const baseDomain = extractBaseDomain(service.loginUrl);
|
|
202
|
-
|
|
203
|
-
console.log(`Recording login for service: ${service.name}`);
|
|
204
|
-
console.log(`Login URL: ${service.loginUrl}`);
|
|
205
|
-
console.log(`Recording requests to: ${baseDomain} (and subdomains)`);
|
|
206
|
-
console.log(`Output directory: ${outputDirectory}`);
|
|
207
|
-
console.log(`Browser state: ${browserStatePath}`);
|
|
208
|
-
console.log("\nClose the browser window when you're done to save the recording.");
|
|
209
|
-
|
|
210
|
-
const recordedEntries: RecordedEntry[] = [];
|
|
211
|
-
const startTime = { value: 0 };
|
|
212
|
-
|
|
213
|
-
const encryptedStorage = new EncryptedStorage({
|
|
214
|
-
encryptionKeyOverride: CONFIG.encryptionKeyOverride,
|
|
215
|
-
serviceName: CONFIG.serviceName,
|
|
216
|
-
accountName: CONFIG.accountName,
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
await withTempBrowserContext(encryptedStorage, browserStatePath, async ({ context }) => {
|
|
220
|
-
const page = await context.newPage();
|
|
221
|
-
|
|
222
|
-
// Register response handler to capture all requests and responses
|
|
223
|
-
page.on('response', (response) => {
|
|
224
|
-
handleResponse(response, recordedEntries, startTime, baseDomain).catch(() => {
|
|
225
|
-
// Ignore errors in response handling
|
|
226
|
-
});
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
await page.goto(service.loginUrl);
|
|
230
|
-
|
|
231
|
-
// Wait for user to close the browser
|
|
232
|
-
await page.waitForEvent('close', { timeout: 0 });
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
// Save recorded entries
|
|
236
|
-
writeFileSync(requestsPath, JSON.stringify(recordedEntries, null, 2));
|
|
237
|
-
|
|
238
|
-
console.log('\nRecording saved successfully!');
|
|
239
|
-
console.log(` Requests file: ${requestsPath}`);
|
|
240
|
-
console.log(` Recorded ${String(recordedEntries.length)} request/response pairs`);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// Main entry point
|
|
244
|
-
async function main(): Promise<void> {
|
|
245
|
-
const args = process.argv.slice(2);
|
|
246
|
-
|
|
247
|
-
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
248
|
-
console.log('Usage: npx tsx scripts/recordBrowserSession.ts <service_name> [recording_name]');
|
|
249
|
-
console.log('');
|
|
250
|
-
console.log('Arguments:');
|
|
251
|
-
console.log(
|
|
252
|
-
" service_name Name of the service to record login for (e.g., 'slack', 'discord')"
|
|
253
|
-
);
|
|
254
|
-
console.log(' recording_name Name of the recording file (default: login_session.json)');
|
|
255
|
-
console.log('');
|
|
256
|
-
console.log('Examples:');
|
|
257
|
-
console.log(' npx tsx scripts/recordBrowserSession.ts slack');
|
|
258
|
-
console.log(' npx tsx scripts/recordBrowserSession.ts discord custom_session.json');
|
|
259
|
-
process.exit(0);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const serviceName = args[0]!;
|
|
263
|
-
const recordingName = args[1] ?? DEFAULT_RECORDING_NAME;
|
|
264
|
-
|
|
265
|
-
try {
|
|
266
|
-
await record(serviceName, recordingName);
|
|
267
|
-
} catch (error) {
|
|
268
|
-
if (error instanceof UnknownServiceError) {
|
|
269
|
-
console.error(`Error: ${error.message}`);
|
|
270
|
-
console.error('Available services:');
|
|
271
|
-
for (const service of REGISTRY.services) {
|
|
272
|
-
console.error(` - ${service.name}`);
|
|
273
|
-
}
|
|
274
|
-
process.exit(1);
|
|
275
|
-
}
|
|
276
|
-
throw error;
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
void main();
|