itfin-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -0
- package/dist/clock.js +1 -0
- package/dist/config.js +14 -0
- package/dist/errors.js +10 -0
- package/dist/index.js +22 -0
- package/dist/itfinClient.js +79 -0
- package/dist/jwt.js +10 -0
- package/dist/leave.js +56 -0
- package/dist/login.js +122 -0
- package/dist/reminders.js +61 -0
- package/dist/server.js +557 -0
- package/dist/tokenStore.js +40 -0
- package/dist/workingTime.js +28 -0
- package/package.json +51 -0
- package/scripts/install.sh +188 -0
- package/scripts/pick-clients.mjs +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# itfin-mcp
|
|
2
|
+
|
|
3
|
+
Report time and request leave in ITFin by chatting with Claude Code, Codex or Claude Desktop.
|
|
4
|
+
|
|
5
|
+
## Install (about 2 minutes)
|
|
6
|
+
|
|
7
|
+
You need macOS, Node.js 22+ and Chrome, Edge, Brave, Arc or Chromium.
|
|
8
|
+
|
|
9
|
+
1. Run:
|
|
10
|
+
```bash
|
|
11
|
+
npx itfin-mcp@latest install
|
|
12
|
+
```
|
|
13
|
+
2. Pick the apps: Claude Code, Codex, Claude Desktop. Space ticks or unticks one, Enter confirms.
|
|
14
|
+
3. Type your company name: `acme` for `https://acme.itfin.io`.
|
|
15
|
+
4. Claude Desktop only: restart it (Cmd+Q, then open it).
|
|
16
|
+
5. Start a new session and say **"log in to ITFin"**.
|
|
17
|
+
|
|
18
|
+
## Update
|
|
19
|
+
|
|
20
|
+
Run the same command again. It installs the latest version from npm, keeps your apps and settings and asks nothing.
|
|
21
|
+
|
|
22
|
+
Installs from before the npm package (a git clone in `~/.itfin-mcp`) are replaced on the first update.
|
|
23
|
+
|
|
24
|
+
If the update changed anything, it stops the ITFin servers that are still running the old version. Then start a new session (in Claude Code, `/mcp` → reconnect itfin also works) and restart Claude Desktop (Cmd+Q, then open it).
|
|
25
|
+
|
|
26
|
+
## Log in (once a week)
|
|
27
|
+
|
|
28
|
+
1. Say **"log in to ITFin"**.
|
|
29
|
+
2. Pick your Google account in the window that opens.
|
|
30
|
+
|
|
31
|
+
ITFin tokens last 7 days and can't be extended. A macOS notification reminds you at the start of your working day before yours expires.
|
|
32
|
+
|
|
33
|
+
## Settings
|
|
34
|
+
|
|
35
|
+
Add flags to the install command to change a setting:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npx itfin-mcp@latest install --browser brave --work-start 09:00
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
| Flag | Default | Sets |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `--clients claude,codex,desktop` | asked | Apps to install for |
|
|
44
|
+
| `--company acme` | asked | Workspace `https://acme.itfin.io` |
|
|
45
|
+
| `--url <address>` | | Workspace not on `itfin.io` |
|
|
46
|
+
| `--browser` | `chrome` | `chrome`, `edge`, `brave`, `arc`, `chromium` or a path to the browser |
|
|
47
|
+
| `--work-start` | `10:00` | When relogin reminders fire |
|
|
48
|
+
| `--timezone` | system | IANA timezone, e.g. `Europe/Kyiv` |
|
|
49
|
+
| `--dir` | `~/.itfin-mcp` | Install folder |
|
|
50
|
+
|
|
51
|
+
## Tools
|
|
52
|
+
|
|
53
|
+
| Tool | Does |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `itfin_login` / `itfin_auth_status` | Log in; check when the token expires |
|
|
56
|
+
| `itfin_list_projects` | Projects and tasks you can report to |
|
|
57
|
+
| `itfin_get_entries` | Your time entries, and which days are open, closed or in the future |
|
|
58
|
+
| `itfin_create_entry` / `itfin_update_entry` / `itfin_delete_entry` | Change time entries |
|
|
59
|
+
| `itfin_get_workspace_settings` | Minimum comment length; whether reopen requests are on |
|
|
60
|
+
| `itfin_request_reopen` / `itfin_list_reopen_requests` | Ask a manager to reopen closed days; see your requests |
|
|
61
|
+
| `itfin_list_leave_types` | Leave types and reasons you can use |
|
|
62
|
+
| `itfin_request_leave` | Request full days, or some hours of one day |
|
|
63
|
+
| `itfin_list_leave_requests` / `itfin_cancel_leave_request` | See or cancel your leave requests |
|
|
64
|
+
|
|
65
|
+
Reopen and leave requests are sent as soon as you ask for them. Your manager still approves them.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm test
|
|
71
|
+
npm run typecheck
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
To try a local build with the installer, pack it and point the installer at the tarball:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npm pack
|
|
78
|
+
ITFIN_MCP_PACKAGE=$PWD/itfin-mcp-0.1.0.tgz bash scripts/install.sh
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Releases
|
|
82
|
+
|
|
83
|
+
Every merge into `main` publishes to npm ([workflow](.github/workflows/publish.yml)) as the next patch version and tags it `v<version>`. For a minor or major release, raise `version` in `package.json` in the PR.
|
package/dist/clock.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const systemClock = { now: () => new Date() };
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function loadConfig(env = process.env) {
|
|
2
|
+
const workspaceUrl = env.ITFIN_URL?.trim().replace(/\/+$/, "");
|
|
3
|
+
if (!workspaceUrl)
|
|
4
|
+
throw new Error("ITFIN_URL is required, e.g. https://acme.itfin.io");
|
|
5
|
+
const workStart = env.ITFIN_WORK_START?.trim() || "10:00";
|
|
6
|
+
if (!/^\d{1,2}:\d{2}$/.test(workStart))
|
|
7
|
+
throw new Error("ITFIN_WORK_START must be HH:mm, e.g. 10:00");
|
|
8
|
+
return {
|
|
9
|
+
workspaceUrl,
|
|
10
|
+
browser: env.ITFIN_BROWSER?.trim() || "chrome",
|
|
11
|
+
workStart,
|
|
12
|
+
timezone: env.ITFIN_TIMEZONE?.trim() || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
13
|
+
};
|
|
14
|
+
}
|
package/dist/errors.js
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { systemClock } from "./clock.js";
|
|
6
|
+
import { loadConfig } from "./config.js";
|
|
7
|
+
import { createItfinServer } from "./server.js";
|
|
8
|
+
import { KeychainTokenStore } from "./tokenStore.js";
|
|
9
|
+
// `npx itfin-mcp install [flags]` runs the installer shipped in the package. Without arguments this
|
|
10
|
+
// is the MCP server itself.
|
|
11
|
+
if (process.argv[2] === "install") {
|
|
12
|
+
const script = fileURLToPath(new URL("../scripts/install.sh", import.meta.url));
|
|
13
|
+
const result = spawnSync("bash", [script, ...process.argv.slice(3)], { stdio: "inherit" });
|
|
14
|
+
process.exit(result.status ?? 1);
|
|
15
|
+
}
|
|
16
|
+
const config = loadConfig();
|
|
17
|
+
const server = createItfinServer({
|
|
18
|
+
config,
|
|
19
|
+
clock: systemClock,
|
|
20
|
+
tokenStore: new KeychainTokenStore("itfin-mcp", config.workspaceUrl),
|
|
21
|
+
});
|
|
22
|
+
await server.connect(new StdioServerTransport());
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ToolError, TRACKING_NOT_ALLOWED } from "./errors.js";
|
|
2
|
+
const MAX_ATTEMPTS = 3;
|
|
3
|
+
/** Only these are retried; a retried POST could create a second time entry or reopen request. */
|
|
4
|
+
const RETRYABLE_METHODS = new Set(["GET", "PUT", "DELETE"]);
|
|
5
|
+
/** Thin HTTP client for `<workspace>/api`, mapping ITFin failures onto tool errors. */
|
|
6
|
+
export class ItfinClient {
|
|
7
|
+
workspaceUrl;
|
|
8
|
+
getToken;
|
|
9
|
+
retryDelayMs;
|
|
10
|
+
onUnauthorized;
|
|
11
|
+
constructor(workspaceUrl, getToken, retryDelayMs,
|
|
12
|
+
/** Called with the token ITFin rejected with a 401. */
|
|
13
|
+
onUnauthorized) {
|
|
14
|
+
this.workspaceUrl = workspaceUrl;
|
|
15
|
+
this.getToken = getToken;
|
|
16
|
+
this.retryDelayMs = retryDelayMs;
|
|
17
|
+
this.onUnauthorized = onUnauthorized;
|
|
18
|
+
}
|
|
19
|
+
get(path, query) {
|
|
20
|
+
return this.request("GET", path, { query });
|
|
21
|
+
}
|
|
22
|
+
async request(method, path, opts = {}) {
|
|
23
|
+
const url = new URL(`${this.workspaceUrl}/api${path}`);
|
|
24
|
+
for (const [k, v] of Object.entries(opts.query ?? {}))
|
|
25
|
+
if (v !== undefined)
|
|
26
|
+
url.searchParams.set(k, String(v));
|
|
27
|
+
const headers = { accept: "application/json" };
|
|
28
|
+
const token = opts.anonymous ? undefined : await this.getToken();
|
|
29
|
+
// The raw token, as the web app sends it: /v1 also accepts "Bearer <token>", but /v3 answers that with 401 "Invalid token".
|
|
30
|
+
if (token)
|
|
31
|
+
headers.authorization = token;
|
|
32
|
+
const maxAttempts = RETRYABLE_METHODS.has(method) ? MAX_ATTEMPTS : 1;
|
|
33
|
+
if (opts.body !== undefined)
|
|
34
|
+
headers["content-type"] = "application/json";
|
|
35
|
+
for (let attempt = 1;; attempt++) {
|
|
36
|
+
let res;
|
|
37
|
+
try {
|
|
38
|
+
res = await fetch(url, { method, headers, body: opts.body === undefined ? undefined : JSON.stringify(opts.body) });
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
if (attempt < maxAttempts) {
|
|
42
|
+
await this.backoff(attempt);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
throw new ToolError("ITFIN_ERROR", `ITFin is unreachable: ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
if (res.status >= 500 && attempt < maxAttempts) {
|
|
48
|
+
await this.backoff(attempt);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const text = await res.text();
|
|
52
|
+
const data = text ? safeJson(text) : undefined;
|
|
53
|
+
if (res.ok)
|
|
54
|
+
return data;
|
|
55
|
+
const message = data?.message ?? (text || res.statusText);
|
|
56
|
+
if (res.status === 401) {
|
|
57
|
+
if (token)
|
|
58
|
+
await this.onUnauthorized(token);
|
|
59
|
+
throw new ToolError("AUTH_REQUIRED", `ITFin rejected the token (${message}). Ask the user to log in.`);
|
|
60
|
+
}
|
|
61
|
+
if (res.status === 400 && message === TRACKING_NOT_ALLOWED)
|
|
62
|
+
throw new ToolError("DAY_CLOSED", message);
|
|
63
|
+
if (res.status === 404)
|
|
64
|
+
throw new ToolError("NOT_FOUND", message, { status: 404 });
|
|
65
|
+
throw new ToolError("ITFIN_ERROR", message, { status: res.status });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
backoff(attempt) {
|
|
69
|
+
return new Promise((r) => setTimeout(r, this.retryDelayMs * 2 ** (attempt - 1)));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function safeJson(text) {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(text);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return { message: text };
|
|
78
|
+
}
|
|
79
|
+
}
|
package/dist/jwt.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Decodes the payload of an ITFin token without verifying it. */
|
|
2
|
+
export function decodeToken(token) {
|
|
3
|
+
const payload = token.split(".")[1];
|
|
4
|
+
if (!payload)
|
|
5
|
+
throw new Error("Malformed ITFin token");
|
|
6
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
7
|
+
if (typeof claims.exp !== "number")
|
|
8
|
+
throw new Error("ITFin token has no exp claim");
|
|
9
|
+
return claims;
|
|
10
|
+
}
|
package/dist/leave.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Leave requests (vacation, sick leave, paid and unpaid leave), mirroring ITFin's "Request time off" form.
|
|
2
|
+
/** Leave type ids compared as strings, so "12" and 12 are the same type. */
|
|
3
|
+
export function sameLeaveTypeId(a, b) {
|
|
4
|
+
return String(a) === String(b);
|
|
5
|
+
}
|
|
6
|
+
/** Request types the web app shows as leave (`loadMyVacationsForPeriod`). */
|
|
7
|
+
export const LEAVE_REQUEST_TYPES = ["Vacation", "Sickness", "Unpaid", "PaidLeave", "CarryOver"];
|
|
8
|
+
/** Statuses after which a request can no longer be cancelled. */
|
|
9
|
+
export const CLOSED_REQUEST_STATUSES = ["Canceled", "Rejected"];
|
|
10
|
+
/** The web app's built-in reasons per request type. */
|
|
11
|
+
const BUILT_IN_REASONS = {
|
|
12
|
+
Sickness: ["Airborne disease", "Trauma", "Mental health", "Poisoning, Gastrointestinal disorder", "Surgery, hospital stay", "Other"],
|
|
13
|
+
Vacation: [
|
|
14
|
+
"Rest and relax",
|
|
15
|
+
"Improve physical health",
|
|
16
|
+
"Increase mental power",
|
|
17
|
+
"Improve well-being",
|
|
18
|
+
"Decreased burnout",
|
|
19
|
+
"Maternity leave",
|
|
20
|
+
"Child rearing leave",
|
|
21
|
+
"Other",
|
|
22
|
+
],
|
|
23
|
+
PaidLeave: ["Birthday", "Wedding", "Death of relative", "Force majeure", "Personal circumstances", "Other"],
|
|
24
|
+
};
|
|
25
|
+
/** Workspace settings holding extra reasons, one per line; a leading "-" line replaces the built-in ones. */
|
|
26
|
+
export const ADDITIONAL_REASONS_SETTING = {
|
|
27
|
+
Vacation: "AdditionalVacationLeaveReasons",
|
|
28
|
+
Sickness: "AdditionalSicknessReasons",
|
|
29
|
+
PaidLeave: "AdditionalPaidLeaveReasons",
|
|
30
|
+
};
|
|
31
|
+
/** The reasons the web app offers for a leave type; an empty list means the form has no reason field. */
|
|
32
|
+
export function leaveReasons(requestType, additional) {
|
|
33
|
+
const builtIn = BUILT_IN_REASONS[requestType ?? ""] ?? [];
|
|
34
|
+
const extra = typeof additional === "string" ? additional.split("\n").map((r) => r.trim()).filter(Boolean) : [];
|
|
35
|
+
if (extra[0] === "-")
|
|
36
|
+
return extra.filter((r) => r !== "-");
|
|
37
|
+
return [...builtIn, ...extra];
|
|
38
|
+
}
|
|
39
|
+
/** Minutes as the web app's part-day "FormattedHours" field, e.g. 270 → "04h 30m", 30 → "0h 30m". */
|
|
40
|
+
export function formatHours(minutes) {
|
|
41
|
+
const h = Math.floor(minutes / 60);
|
|
42
|
+
const m = String(minutes % 60).padStart(2, "0");
|
|
43
|
+
return h ? `${String(h).padStart(2, "0")}h ${m}m` : `0h ${m}m`;
|
|
44
|
+
}
|
|
45
|
+
export function toLeaveRequest(r) {
|
|
46
|
+
return {
|
|
47
|
+
id: r.Id,
|
|
48
|
+
type: r.RequestType,
|
|
49
|
+
from: r.DateFrom.slice(0, 10),
|
|
50
|
+
to: r.DateTo.slice(0, 10),
|
|
51
|
+
status: r.Status,
|
|
52
|
+
reason: r.Reason ?? undefined,
|
|
53
|
+
comment: r.Comment ?? undefined,
|
|
54
|
+
createdAt: r.CreatedAt,
|
|
55
|
+
};
|
|
56
|
+
}
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { createServer } from "node:net";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
const BROWSERS = {
|
|
7
|
+
chrome: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
8
|
+
edge: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
9
|
+
brave: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
10
|
+
arc: "/Applications/Arc.app/Contents/MacOS/Arc",
|
|
11
|
+
chromium: "/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
12
|
+
};
|
|
13
|
+
const TOKEN_COOKIE = "itfin-jwt";
|
|
14
|
+
/**
|
|
15
|
+
* Opens the workspace in a Chromium-based browser with a dedicated profile and watches, over the
|
|
16
|
+
* DevTools protocol, for the ITFin token cookie that ITFin sets after the user signs in.
|
|
17
|
+
* Nothing is clicked or typed: the user signs in themselves.
|
|
18
|
+
*/
|
|
19
|
+
export async function startBrowserLogin(opts) {
|
|
20
|
+
const executable = BROWSERS[opts.browser.toLowerCase()] ?? opts.browser;
|
|
21
|
+
if (!existsSync(executable))
|
|
22
|
+
throw new Error(`Browser not found: ${executable}. Set ITFIN_BROWSER to chrome, edge, brave, arc, chromium or a path.`);
|
|
23
|
+
const profileDir = opts.profileDir ?? join(homedir(), "Library", "Application Support", "itfin-mcp", "browser-profile");
|
|
24
|
+
mkdirSync(profileDir, { recursive: true });
|
|
25
|
+
const port = await freePort();
|
|
26
|
+
const child = spawn(executable, [`--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, "--no-first-run", "--no-default-browser-check", "about:blank"], { stdio: "ignore", detached: false });
|
|
27
|
+
const token = (async () => {
|
|
28
|
+
const page = await connectToPage(port, opts.maxWaitMs);
|
|
29
|
+
try {
|
|
30
|
+
await page.send("Network.enable");
|
|
31
|
+
// Drop a token left in the profile so the user really signs in and ITFin issues a fresh one.
|
|
32
|
+
await page.send("Network.deleteCookies", { name: TOKEN_COOKIE, url: opts.workspaceUrl });
|
|
33
|
+
await page.send("Page.navigate", { url: opts.workspaceUrl });
|
|
34
|
+
const deadline = Date.now() + opts.maxWaitMs;
|
|
35
|
+
while (Date.now() < deadline) {
|
|
36
|
+
const { cookies } = (await page.send("Network.getCookies", { urls: [opts.workspaceUrl] }));
|
|
37
|
+
const cookie = cookies.find((c) => c.name === TOKEN_COOKIE);
|
|
38
|
+
if (cookie?.value)
|
|
39
|
+
return decodeURIComponent(cookie.value).replace(/^Bearer\s+/i, "");
|
|
40
|
+
await sleep(1000);
|
|
41
|
+
}
|
|
42
|
+
throw new Error("Timed out waiting for the ITFin login.");
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
page.close();
|
|
46
|
+
closeBrowser(child, port);
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
return { token };
|
|
50
|
+
}
|
|
51
|
+
async function connectToPage(port, maxWaitMs) {
|
|
52
|
+
const deadline = Date.now() + Math.min(maxWaitMs, 30_000);
|
|
53
|
+
let wsUrl;
|
|
54
|
+
while (!wsUrl) {
|
|
55
|
+
try {
|
|
56
|
+
const targets = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json());
|
|
57
|
+
wsUrl = targets.find((t) => t.type === "page")?.webSocketDebuggerUrl;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Browser still starting.
|
|
61
|
+
}
|
|
62
|
+
if (!wsUrl) {
|
|
63
|
+
if (Date.now() > deadline)
|
|
64
|
+
throw new Error("The login browser did not start.");
|
|
65
|
+
await sleep(250);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const ws = new WebSocket(wsUrl);
|
|
69
|
+
await new Promise((resolve, reject) => {
|
|
70
|
+
ws.addEventListener("open", () => resolve(), { once: true });
|
|
71
|
+
ws.addEventListener("error", () => reject(new Error("Could not connect to the login browser.")), { once: true });
|
|
72
|
+
});
|
|
73
|
+
let nextId = 1;
|
|
74
|
+
const pending = new Map();
|
|
75
|
+
ws.addEventListener("message", (ev) => {
|
|
76
|
+
const msg = JSON.parse(String(ev.data));
|
|
77
|
+
if (msg.id === undefined)
|
|
78
|
+
return;
|
|
79
|
+
const p = pending.get(msg.id);
|
|
80
|
+
pending.delete(msg.id);
|
|
81
|
+
if (msg.error)
|
|
82
|
+
p?.reject(new Error(msg.error.message));
|
|
83
|
+
else
|
|
84
|
+
p?.resolve(msg.result);
|
|
85
|
+
});
|
|
86
|
+
ws.addEventListener("close", () => {
|
|
87
|
+
for (const p of pending.values())
|
|
88
|
+
p.reject(new Error("The login browser was closed."));
|
|
89
|
+
pending.clear();
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
send(method, params = {}) {
|
|
93
|
+
const id = nextId++;
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
pending.set(id, { resolve, reject });
|
|
96
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
close: () => ws.close(),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function closeBrowser(child, port) {
|
|
103
|
+
fetch(`http://127.0.0.1:${port}/json/version`)
|
|
104
|
+
.then((r) => r.json())
|
|
105
|
+
.then(({ webSocketDebuggerUrl }) => {
|
|
106
|
+
const ws = new WebSocket(webSocketDebuggerUrl);
|
|
107
|
+
ws.addEventListener("open", () => ws.send(JSON.stringify({ id: 1, method: "Browser.close" })));
|
|
108
|
+
})
|
|
109
|
+
.catch(() => child.kill());
|
|
110
|
+
}
|
|
111
|
+
function freePort() {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const srv = createServer();
|
|
114
|
+
srv.unref();
|
|
115
|
+
srv.on("error", reject);
|
|
116
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
117
|
+
const { port } = srv.address();
|
|
118
|
+
srv.close(() => resolve(port));
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir, userInfo } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
const run = promisify(execFile);
|
|
8
|
+
/** One-shot relogin reminders as per-user launchd jobs that remove themselves after firing. */
|
|
9
|
+
export class LaunchdReminderScheduler {
|
|
10
|
+
async schedule(at, workspaceUrl) {
|
|
11
|
+
const slug = createHash("sha1").update(workspaceUrl).digest("hex").slice(0, 10);
|
|
12
|
+
const label = `com.itfin-mcp.relogin.${slug}`;
|
|
13
|
+
const dir = join(homedir(), "Library", "LaunchAgents");
|
|
14
|
+
const plist = join(dir, `${label}.plist`);
|
|
15
|
+
const domain = `gui/${userInfo().uid}`;
|
|
16
|
+
await run("launchctl", ["bootout", `${domain}/${label}`]).catch(() => undefined);
|
|
17
|
+
rmSync(plist, { force: true });
|
|
18
|
+
if (at.getTime() - Date.now() < 60_000) {
|
|
19
|
+
await this.notify("ITFin", reminderText(workspaceUrl));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
// launchd calendar intervals use the Mac's local time.
|
|
23
|
+
const script = [
|
|
24
|
+
notificationCommand("ITFin", reminderText(workspaceUrl)),
|
|
25
|
+
`launchctl bootout ${domain}/${label}`,
|
|
26
|
+
`rm -f '${plist}'`,
|
|
27
|
+
].join("; ");
|
|
28
|
+
mkdirSync(dir, { recursive: true });
|
|
29
|
+
writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
|
|
30
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
31
|
+
<plist version="1.0">
|
|
32
|
+
<dict>
|
|
33
|
+
<key>Label</key><string>${label}</string>
|
|
34
|
+
<key>ProgramArguments</key>
|
|
35
|
+
<array><string>/bin/sh</string><string>-c</string><string>${escapeXml(script)}</string></array>
|
|
36
|
+
<key>StartCalendarInterval</key>
|
|
37
|
+
<dict>
|
|
38
|
+
<key>Month</key><integer>${at.getMonth() + 1}</integer>
|
|
39
|
+
<key>Day</key><integer>${at.getDate()}</integer>
|
|
40
|
+
<key>Hour</key><integer>${at.getHours()}</integer>
|
|
41
|
+
<key>Minute</key><integer>${at.getMinutes()}</integer>
|
|
42
|
+
</dict>
|
|
43
|
+
</dict>
|
|
44
|
+
</plist>
|
|
45
|
+
`);
|
|
46
|
+
await run("launchctl", ["bootstrap", domain, plist]);
|
|
47
|
+
}
|
|
48
|
+
async notify(title, message) {
|
|
49
|
+
await run("/bin/sh", ["-c", notificationCommand(title, message)]).catch(() => undefined);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function reminderText(workspaceUrl) {
|
|
53
|
+
return `Your ITFin token for ${new URL(workspaceUrl).host} expires soon. Ask Claude to log in to ITFin.`;
|
|
54
|
+
}
|
|
55
|
+
function notificationCommand(title, message) {
|
|
56
|
+
const q = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/'/g, "");
|
|
57
|
+
return `osascript -e 'display notification "${q(message)}" with title "${q(title)}"'`;
|
|
58
|
+
}
|
|
59
|
+
function escapeXml(s) {
|
|
60
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
61
|
+
}
|