granttap-mcp 0.8.34 → 0.8.36
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 +38 -12
- package/apps/bridge/src/account-recovery/enroll.ts +20 -9
- package/apps/bridge/src/config/access/pairing.ts +2 -2
- package/apps/bridge/src/machine-load/index.ts +2 -2
- package/apps/bridge/src/monitor/support/heartbeat.ts +3 -3
- package/apps/bridge/src/monitor/support/runtime.ts +4 -2
- package/apps/bridge/src/pairing/presence.ts +21 -0
- package/apps/bridge/src/reply/process/index.ts +8 -1
- package/apps/mcp/src/connection-center/README.md +6 -5
- package/apps/mcp/src/connection-center/bridge.js +1 -0
- package/apps/mcp/src/connection-center/passkey.ts +27 -0
- package/apps/mcp/src/connection-center/state.ts +5 -2
- package/apps/mcp/src/connection-center/view.js +11 -0
- package/apps/mcp/src/connection-center/widget.html +3 -4
- package/apps/mcp/src/desktop/load/index.ts +2 -2
- package/apps/mcp/src/desktop/pairing/index.ts +4 -3
- package/apps/mcp/src/mcp-tools/connect/index.ts +19 -0
- package/apps/mcp/src/mcp-tools/connect/widget.ts +3 -3
- package/apps/mcp/src/oauth/session/connect-snapshot.ts +3 -3
- package/apps/mcp/src/status/desktop-status.ts +4 -2
- package/apps/mcp/src/status/pairing-status.ts +5 -3
- package/cursor-plugin/README.md +1 -1
- package/cursor-plugin/mcp.json +1 -1
- package/cursor-plugin/stdio-bootstrap.cjs +1 -1
- package/package.json +1 -1
- package/packages/core/computer-name.ts +31 -0
package/README.md
CHANGED
|
@@ -4,16 +4,18 @@
|
|
|
4
4
|
[](https://github.com/sergii-ziborov/granttap-mcp/actions/workflows/ci.yml)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
> **See what your coding agents are doing. Step in when they need you.**
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
GrantTap connects local Claude Code and Codex work to a live control center on
|
|
10
|
+
Mac, iPhone, iPad, and Apple Watch. See the Task and its latest activity, answer
|
|
11
|
+
an approval or question with context, and continue the same goal as sessions or
|
|
12
|
+
computers change. Cursor and Grok Build join the view where their local
|
|
13
|
+
integrations support it.
|
|
10
14
|
|
|
11
|
-
This repository
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
credentials stay on your computer. Native controller traffic is end-to-end
|
|
16
|
-
encrypted.
|
|
15
|
+
This repository supplies the local runtime: CLI, MCP server, provider hooks,
|
|
16
|
+
adapters, and wire contracts. It powers Project Mesh, Governance, and observed
|
|
17
|
+
usage while agents and provider credentials stay on your computer. Controller
|
|
18
|
+
traffic is end-to-end encrypted.
|
|
17
19
|
|
|
18
20
|
The MCP runtime, plugins, and CLI are open source under the MIT License.
|
|
19
21
|
The Apple apps, Engine, relay, and website are separately licensed commercial
|
|
@@ -23,6 +25,28 @@ products or source. Their licenses do not change this repository's MIT grant.
|
|
|
23
25
|
[Security model](SECURITY.md) ·
|
|
24
26
|
[Relay source](https://github.com/sergii-ziborov/granttap-relay)
|
|
25
27
|
|
|
28
|
+
## Account Mesh and devices
|
|
29
|
+
|
|
30
|
+
The passkey Account Mesh is the person's relay space. It exists with zero
|
|
31
|
+
computers and can contain many computers and Project Meshes. Create or join it
|
|
32
|
+
from **Devices** in the iPhone, iPad, or Mac app, then use the same passkey in
|
|
33
|
+
the MCP connection card to attach this computer. A previously linked computer
|
|
34
|
+
reuses its machine identity; reconnecting does not create a duplicate. The
|
|
35
|
+
local helper reports the Mac's user-assigned Computer Name and updates its
|
|
36
|
+
account display name when that name changes. Device names are display labels,
|
|
37
|
+
not routing keys: each Task keeps the computer route selected by its Mesh.
|
|
38
|
+
|
|
39
|
+
Install the local helper separately from the Mac App Store app:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install -g granttap-mcp
|
|
43
|
+
granttap setup
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The Mac app can join the Account Mesh before the helper is present and links it
|
|
47
|
+
when the installed helper becomes available. A one-time QR still provides direct
|
|
48
|
+
device pairing without a passkey account.
|
|
49
|
+
|
|
26
50
|
## Mac, iPhone, iPad, and Apple Watch
|
|
27
51
|
|
|
28
52
|
<p align="center">
|
|
@@ -36,11 +60,13 @@ products or source. Their licenses do not change this repository's MIT grant.
|
|
|
36
60
|
<img src="docs/images/apple-watch-approval.png" width="180" alt="GrantTap approval on Apple Watch">
|
|
37
61
|
</p>
|
|
38
62
|
|
|
63
|
+
<p align="center"><em>GrantTap iPhone and Apple Watch screens with deterministic sample work; no private user tasks are shown.</em></p>
|
|
64
|
+
|
|
39
65
|
## Supported providers
|
|
40
66
|
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
-
|
|
67
|
+
- Claude Code and Codex: primary local control and continuation paths.
|
|
68
|
+
- Cursor: Task visibility and supported local controls, with narrower coverage.
|
|
69
|
+
- Grok Build: observable work where the installed runtime exposes it.
|
|
44
70
|
|
|
45
71
|
GrantTap reports the depth each provider actually exposes. Visibility does not
|
|
46
72
|
imply deterministic remote blocking or full mobile continuation.
|
|
@@ -147,7 +173,7 @@ computers. GrantTap builds a bounded Task Capsule from explicit task/git facts,
|
|
|
147
173
|
requires local phone authorization, creates a separate target branch/worktree,
|
|
148
174
|
starts the target execution, and returns a receipt bound to the exact capsule.
|
|
149
175
|
Cursor uses the same provider-neutral schema and trusted caller attribution.
|
|
150
|
-
Grok Build remains
|
|
176
|
+
Grok Build remains observable where available, but does not
|
|
151
177
|
yet expose a trusted caller hook, so agent-authored scoped Mesh events are not
|
|
152
178
|
offered for it. Unsupported remote-start paths fail closed instead of claiming
|
|
153
179
|
parity.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { computerDisplayName } from "../../../../packages/core/computer-name";
|
|
2
2
|
import { loadAccountLink, saveAccountLink } from "./link";
|
|
3
3
|
|
|
4
4
|
const TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
|
@@ -6,7 +6,8 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-
|
|
|
6
6
|
|
|
7
7
|
/** Native Mac app authorizes this MCP with its account session over protected loopback. */
|
|
8
8
|
export async function enrollMacAccount(accountToken: string, origin: string,
|
|
9
|
-
request: typeof fetch = fetch
|
|
9
|
+
request: typeof fetch = fetch,
|
|
10
|
+
computerName: () => string = computerDisplayName): Promise<{ accountId: string; machineId: string }> {
|
|
10
11
|
if (!TOKEN.test(accountToken) || new URL(origin).protocol !== "https:") {
|
|
11
12
|
throw new Error("Invalid account authorization.");
|
|
12
13
|
}
|
|
@@ -22,19 +23,29 @@ export async function enrollMacAccount(accountToken: string, origin: string,
|
|
|
22
23
|
if (existing.accountId !== me.accountId) {
|
|
23
24
|
throw new Error("This Mac is linked to a different account. Revoke it first.");
|
|
24
25
|
}
|
|
25
|
-
const linked = await request(`${origin}/api/account/
|
|
26
|
-
headers
|
|
26
|
+
const linked = await request(`${origin}/api/account/machine/identity`, {
|
|
27
|
+
headers: { authorization: `Bearer ${existing.machineToken}` },
|
|
28
|
+
signal: AbortSignal.timeout(8_000), redirect: "error",
|
|
27
29
|
});
|
|
28
|
-
if (!linked.ok) throw new Error("Account
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
if (linked.status !== 401 && !linked.ok) throw new Error("Account machine is unavailable.");
|
|
31
|
+
if (linked.ok) {
|
|
32
|
+
const identity = await linked.json() as { accountId?: string; machineId?: string };
|
|
33
|
+
if (identity.accountId !== me.accountId || identity.machineId !== existing.machineId) {
|
|
34
|
+
throw new Error("Account machine identity changed unexpectedly.");
|
|
35
|
+
}
|
|
36
|
+
const renamed = await request(`${origin}/api/account/machine/self`, {
|
|
37
|
+
method: "PATCH", headers: { authorization: `Bearer ${existing.machineToken}`,
|
|
38
|
+
"content-type": "application/json" },
|
|
39
|
+
body: JSON.stringify({ name: computerName() }),
|
|
40
|
+
signal: AbortSignal.timeout(8_000), redirect: "error",
|
|
41
|
+
});
|
|
42
|
+
if (!renamed.ok) throw new Error("Account computer name could not be updated.");
|
|
32
43
|
return { accountId: existing.accountId, machineId: existing.machineId };
|
|
33
44
|
}
|
|
34
45
|
}
|
|
35
46
|
const registration = await request(`${origin}/api/account/machines`, {
|
|
36
47
|
method: "POST", headers: { ...headers, "content-type": "application/json" },
|
|
37
|
-
body: JSON.stringify({ name:
|
|
48
|
+
body: JSON.stringify({ name: computerName() }),
|
|
38
49
|
signal: AbortSignal.timeout(8_000), redirect: "error",
|
|
39
50
|
});
|
|
40
51
|
if (registration.status !== 201 && registration.status !== 200) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import { computerDisplayName } from "../../../../../packages/core/computer-name";
|
|
3
3
|
import { generateKeyPair, randomId } from "../../../../../packages/core/crypto";
|
|
4
4
|
import type { PeerConfig } from "../../../../../packages/core/relay-client";
|
|
5
5
|
import { configDir } from "../runtime/paths";
|
|
@@ -129,7 +129,7 @@ export function createPairing(relayUrl: string): {
|
|
|
129
129
|
relayUrl,
|
|
130
130
|
room,
|
|
131
131
|
role: "machine",
|
|
132
|
-
deviceName:
|
|
132
|
+
deviceName: computerDisplayName(),
|
|
133
133
|
senderId: randomId(4),
|
|
134
134
|
myPublicKey: machine.publicKey,
|
|
135
135
|
mySecretKey: machine.secretKey,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { computerDisplayName } from "../../../../packages/core/computer-name";
|
|
2
2
|
import type {
|
|
3
3
|
MachineLoad,
|
|
4
4
|
SessionsStatus,
|
|
@@ -78,7 +78,7 @@ export function buildMachineLoad(input: {
|
|
|
78
78
|
]);
|
|
79
79
|
return {
|
|
80
80
|
type: "machine.load",
|
|
81
|
-
machine: input.machine ??
|
|
81
|
+
machine: input.machine ?? computerDisplayName(),
|
|
82
82
|
monitorCpuPercent: input.self.cpuPercent,
|
|
83
83
|
monitorMemoryBytes: input.self.memoryBytes,
|
|
84
84
|
agents: [...agents]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { computerDisplayName } from "../../../../../packages/core/computer-name";
|
|
2
2
|
import type { RelayClient } from "../../../../../packages/core/relay-client";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -20,7 +20,7 @@ export const HEARTBEAT_INTERVAL_MS = Number(
|
|
|
20
20
|
*/
|
|
21
21
|
export async function publishHeartbeat(client: RelayClient): Promise<void> {
|
|
22
22
|
await client.send(
|
|
23
|
-
{ type: "machine.heartbeat", machine:
|
|
23
|
+
{ type: "machine.heartbeat", machine: computerDisplayName(), createdAt: Date.now() },
|
|
24
24
|
"phone",
|
|
25
25
|
{ ttlMs: HEARTBEAT_INTERVAL_MS * 3, reliable: false },
|
|
26
26
|
);
|
|
@@ -38,7 +38,7 @@ export const HELD_HEARTBEAT_TTL_MS = 60_000;
|
|
|
38
38
|
|
|
39
39
|
export async function publishHeldHeartbeat(client: RelayClient): Promise<void> {
|
|
40
40
|
await client.send(
|
|
41
|
-
{ type: "machine.heartbeat", machine:
|
|
41
|
+
{ type: "machine.heartbeat", machine: computerDisplayName(), createdAt: Date.now() },
|
|
42
42
|
"phone",
|
|
43
43
|
{ ttlMs: HELD_HEARTBEAT_TTL_MS, reliable: true },
|
|
44
44
|
);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { refreshCodexHooks } from "../../codex-hook-trust";
|
|
2
|
-
import {
|
|
2
|
+
import { computerDisplayName } from "../../../../../packages/core/computer-name";
|
|
3
3
|
import { RelayClient } from "../../../../../packages/core/relay-client";
|
|
4
4
|
import type { Payload, SessionInfo, SessionsStatus } from "../../../../../packages/protocol/schema";
|
|
5
5
|
import { loadRuntimeConfig } from "../../config";
|
|
@@ -18,6 +18,7 @@ import { deriveObservedClaims } from "../../mesh/observed/claims";
|
|
|
18
18
|
import { ingestRuntimeInvocations } from "../../engine/invocation/ingest";
|
|
19
19
|
import { HEARTBEAT_INTERVAL_MS, publishHeartbeat } from "./heartbeat";
|
|
20
20
|
import { confirmPendingController } from "../../pairing/controllers";
|
|
21
|
+
import { recordControllerName } from "../../pairing/presence";
|
|
21
22
|
import { startPublishLoop } from "./publish-loop";
|
|
22
23
|
import { singleFlightPublisher } from "./single-flight";
|
|
23
24
|
import { createMachineLoadPublisher } from "../../machine-load";
|
|
@@ -81,7 +82,7 @@ function monitorSnapshot(input: {
|
|
|
81
82
|
const runtime = loadRuntimeConfig();
|
|
82
83
|
return {
|
|
83
84
|
type: "sessions.status",
|
|
84
|
-
machine:
|
|
85
|
+
machine: computerDisplayName(),
|
|
85
86
|
sessions: decorateSessions(sessions, subscriptions),
|
|
86
87
|
history: includeHistory
|
|
87
88
|
? decorateSessions(boundedCatalogHistory(history(forceHistory), subscriptions), subscriptions)
|
|
@@ -218,6 +219,7 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
218
219
|
const off = client.onMessage(async (payload: Payload, peerPublicKey) => {
|
|
219
220
|
if (payload.type === "hello" && payload.role === "phone") {
|
|
220
221
|
confirmPendingController(client.room, peerPublicKey);
|
|
222
|
+
recordControllerName(peerPublicKey, payload.deviceName);
|
|
221
223
|
}
|
|
222
224
|
return handleMonitorMessage(client, payload, { leadership, subscriptions, publish, peerPublicKey });
|
|
223
225
|
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { configDir } from "../config/runtime/paths";
|
|
4
|
+
import { writePrivateFile } from "../config/access/write-private";
|
|
4
5
|
|
|
5
6
|
type PresenceState = {
|
|
6
7
|
phoneLastSeenAt?: number;
|
|
7
8
|
controllers?: Record<string, number>;
|
|
9
|
+
names?: Record<string, string>;
|
|
8
10
|
};
|
|
9
11
|
|
|
10
12
|
function presencePath(): string {
|
|
@@ -31,6 +33,25 @@ export function readControllerLastSeenAt(peerPublicKey: string): number | null {
|
|
|
31
33
|
return typeof at === "number" && Number.isFinite(at) ? at : null;
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
export function readControllerName(peerPublicKey: string): string | null {
|
|
37
|
+
const name = readPresence().names?.[peerPublicKey];
|
|
38
|
+
return typeof name === "string" && name.length > 0 && name.length <= 80
|
|
39
|
+
&& !/^(phone|machine|iphone|ipad)$/i.test(name) ? name : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A controller chooses its own visible label; never infer it from a pairing role. */
|
|
43
|
+
export function recordControllerName(peerPublicKey: string, value: string): boolean {
|
|
44
|
+
const name = value.trim();
|
|
45
|
+
if (!/^[A-Za-z0-9+/]{43}=$/.test(peerPublicKey)
|
|
46
|
+
|| !name || name.length > 80 || /[\x00-\x1f\x7f]/.test(name)
|
|
47
|
+
|| /^(phone|machine|iphone|ipad)$/i.test(name)) return false;
|
|
48
|
+
const previous = readPresence();
|
|
49
|
+
const names = { ...previous.names, [peerPublicKey]: name };
|
|
50
|
+
const bounded = Object.fromEntries(Object.entries(names).slice(-32));
|
|
51
|
+
writePrivateFile(presencePath(), JSON.stringify({ ...previous, names: bounded }));
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
34
55
|
export function hasControllerPresence(): boolean {
|
|
35
56
|
return Object.keys(readPresence().controllers ?? {}).length > 0;
|
|
36
57
|
}
|
|
@@ -93,7 +93,6 @@ export function runProcess(
|
|
|
93
93
|
return;
|
|
94
94
|
}
|
|
95
95
|
const untrack = key ? track(key, child) : () => {};
|
|
96
|
-
if (stdin != null) child.stdin?.end(stdin);
|
|
97
96
|
let stdout = "";
|
|
98
97
|
let stderr = "";
|
|
99
98
|
let done = false;
|
|
@@ -134,5 +133,13 @@ export function runProcess(
|
|
|
134
133
|
}` });
|
|
135
134
|
}
|
|
136
135
|
});
|
|
136
|
+
if (stdin != null) {
|
|
137
|
+
child.stdin?.on("error", (error: NodeJS.ErrnoException) => {
|
|
138
|
+
// A CLI can finish with a valid result before it reads the whole prompt.
|
|
139
|
+
// Its closed pipe must not become an uncaught exception in the bridge.
|
|
140
|
+
if (error.code !== "EPIPE") finish({ ok: false, error: `${command}: ${error.message}` });
|
|
141
|
+
});
|
|
142
|
+
child.stdin?.end(stdin);
|
|
143
|
+
}
|
|
137
144
|
});
|
|
138
145
|
}
|
|
@@ -23,11 +23,12 @@ checks parent messages, refreshes pending pairing while visible, and keeps
|
|
|
23
23
|
copyable transfer material out of model-visible text and persistent UI storage.
|
|
24
24
|
Non-UI clients can still use the tools and display the returned QR image.
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
Connect with passkey on the card creates a short-lived, authenticated website
|
|
27
|
+
request. A fresh passkey assertion on granttap.com links this Mac to the user's
|
|
28
|
+
account; the site returns a machine token only to the local MCP watcher, which
|
|
29
|
+
saves it privately. The QR pairing room and provider runtime remain unchanged.
|
|
30
|
+
The separate coding-app OAuth page can also use passkey to authorize that MCP
|
|
31
|
+
client. The Codex-owned plugin management page is not this card.
|
|
31
32
|
|
|
32
33
|
Tests in `tests/` exercise isolated keys, a loopback encrypted phone connection,
|
|
33
34
|
expiry, non-mutating status, and actual DOM button/host-message behavior.
|
|
@@ -72,6 +72,7 @@ async function call(name, args = {}) {
|
|
|
72
72
|
: await request("tools/call", { name, arguments: args });
|
|
73
73
|
if (result?.isError) message("Could not complete the request. Refresh status and check the relay before retrying.");
|
|
74
74
|
else { render(result); message("Status updated."); }
|
|
75
|
+
return result;
|
|
75
76
|
} catch {
|
|
76
77
|
message("No successful response. Refresh status before retrying; pairing may already have changed.");
|
|
77
78
|
} finally {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { buildConnectSnapshot } from "../oauth/session/connect-snapshot";
|
|
3
|
+
import { publishConnectRequestRetry, watchConnectDecision, websiteOrigin } from "../oauth/consent/website-session";
|
|
4
|
+
|
|
5
|
+
const REQUEST_LIFETIME_MS = 15 * 60_000;
|
|
6
|
+
let pending: { url: string; expiresAt: number } | null = null;
|
|
7
|
+
|
|
8
|
+
/** Start a browser passkey ceremony for this Mac. No account credential enters MCP output. */
|
|
9
|
+
export async function startPasskeyAccountLink(): Promise<string> {
|
|
10
|
+
if (pending && pending.expiresAt > Date.now()) return pending.url;
|
|
11
|
+
const origin = websiteOrigin();
|
|
12
|
+
if (!origin) throw new Error("GrantTap account service is unavailable.");
|
|
13
|
+
const id = randomUUID();
|
|
14
|
+
const secret = randomBytes(32).toString("base64url");
|
|
15
|
+
const snapshot = () => ({ ...buildConnectSnapshot("GrantTap MCP"), purpose: "account-link" as const });
|
|
16
|
+
if (!await publishConnectRequestRetry(origin, id, snapshot(), 3, secret)) {
|
|
17
|
+
throw new Error("GrantTap account service did not accept the connection request.");
|
|
18
|
+
}
|
|
19
|
+
watchConnectDecision(origin, id, snapshot, (approved, method) => {
|
|
20
|
+
if (!approved || method !== "passkey") throw new Error("Use the account passkey to link this Mac.");
|
|
21
|
+
pending = null;
|
|
22
|
+
return { redirectUrl: `${origin}/account` };
|
|
23
|
+
}, { requestSecret: secret });
|
|
24
|
+
const url = `${origin}/connect#request=${id}`;
|
|
25
|
+
pending = { url, expiresAt: Date.now() + REQUEST_LIFETIME_MS };
|
|
26
|
+
return url;
|
|
27
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { computerDisplayName } from "../../../../packages/core/computer-name";
|
|
3
3
|
import { loadConfig } from "../../../bridge/src/config";
|
|
4
4
|
import { isMachineConfigured, listPairedPhones, readOnlyMachineConfigPath } from "../status/pairing-status";
|
|
5
5
|
import { inspectProviderStatusSnapshot } from "../status/provider-status";
|
|
6
6
|
import { packageVersion } from "../status/package-version";
|
|
7
7
|
import { connectionRuntimeStatus } from "../mcp-tools/connect/relay";
|
|
8
8
|
import { beginEnrollment, cancelEnrollment, enrollmentIsOpen } from "./enrollment";
|
|
9
|
+
import { loadAccountLink } from "../../../bridge/src/account-recovery/link";
|
|
9
10
|
|
|
10
11
|
export const connectionOutput = {
|
|
11
12
|
status: z.enum(["disconnected", "paired", "pairing", "expired", "connected"]),
|
|
@@ -23,6 +24,7 @@ export const connectionOutput = {
|
|
|
23
24
|
expiresInMinutes: z.number().int().positive().nullable(),
|
|
24
25
|
providers: z.array(z.object({ id: z.string(), status: z.string(), detail: z.string() })),
|
|
25
26
|
roomPrefix: z.string(),
|
|
27
|
+
accountLinkSaved: z.boolean(),
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
export type PendingCode = {
|
|
@@ -66,7 +68,7 @@ export class ConnectionState {
|
|
|
66
68
|
return {
|
|
67
69
|
structuredContent: {
|
|
68
70
|
status,
|
|
69
|
-
computer:
|
|
71
|
+
computer: computerDisplayName(),
|
|
70
72
|
version: packageVersion(),
|
|
71
73
|
relay: config ? new URL(config.relayUrl).host : "",
|
|
72
74
|
relayStatus: runtime.relayStatus,
|
|
@@ -76,6 +78,7 @@ export class ConnectionState {
|
|
|
76
78
|
expiresInMinutes: code ? Math.max(1, Math.ceil((code.expiresAt - now) / 60_000)) : null,
|
|
77
79
|
providers: inspectProviderStatusSnapshot().providers.map(({ id, status, detail }) => ({ id, status, detail })),
|
|
78
80
|
roomPrefix: config?.room ? config.room.slice(0, 8) : "",
|
|
81
|
+
accountLinkSaved: loadAccountLink() !== null,
|
|
79
82
|
},
|
|
80
83
|
_meta: { granttap: code ? { pairingUri: code.pairingUri, qrDataUrl: code.qrDataUrl } : {} },
|
|
81
84
|
};
|
|
@@ -21,6 +21,16 @@ function render(value) {
|
|
|
21
21
|
current = state;
|
|
22
22
|
clearCode();
|
|
23
23
|
const meta = result._meta?.granttap ?? {};
|
|
24
|
+
const passkeyUrl = typeof meta.passkeyUrl === "string" && /^https:\/\/granttap\.com\/connect#request=[0-9a-f-]{36}$/i.test(meta.passkeyUrl)
|
|
25
|
+
? meta.passkeyUrl : "";
|
|
26
|
+
$("passkey-link").classList.toggle("hidden", !passkeyUrl || state.accountLinkSaved === true);
|
|
27
|
+
if (passkeyUrl) $("passkey-link").setAttribute("href", passkeyUrl);
|
|
28
|
+
else $("passkey-link").removeAttribute("href");
|
|
29
|
+
$("account-status").textContent = state.accountLinkSaved === true
|
|
30
|
+
? "Saved account link on this Mac. Current server access is checked when it is used."
|
|
31
|
+
: "No account linked on this Mac. A passkey can link it without changing the QR pairing.";
|
|
32
|
+
$("passkey").textContent = state.accountLinkSaved === true
|
|
33
|
+
? "Reauthorize with passkey" : "Connect with passkey";
|
|
24
34
|
const labels = {
|
|
25
35
|
disconnected: ["Not paired", "Add a device", "Tap Add a device. Scan the QR in GrantTap on iPhone, iPad, or Android."],
|
|
26
36
|
pairing: ["Waiting for a device", "Scan this QR", "Keep this card open. The QR is only here — not in chat."],
|
|
@@ -91,6 +101,7 @@ $("connect").addEventListener("click", () => {
|
|
|
91
101
|
call("connect");
|
|
92
102
|
});
|
|
93
103
|
$("refresh").addEventListener("click", () => call("connection_status"));
|
|
104
|
+
$("passkey").addEventListener("click", () => call("connect_with_passkey"));
|
|
94
105
|
$("reconnect").addEventListener("click", () => {
|
|
95
106
|
pendingMode = "reconnect";
|
|
96
107
|
$("confirm-copy").textContent = "Reconnect shows a new QR for the existing controller device in this room.";
|
|
@@ -27,7 +27,7 @@ details{border-top:1px solid #8884;padding-top:14px}summary{cursor:pointer;font-
|
|
|
27
27
|
</svg>
|
|
28
28
|
</div><div><div class="title">GrantTap</div><div class="status"><span class="dot"></span><span id="status">Checking connection…</span></div></div></header>
|
|
29
29
|
<section class="body">
|
|
30
|
-
<div class="copy"><h2 id="heading">Add a device</h2><p id="detail">Scan the QR in GrantTap on iPhone, iPad, or Android. It appears here as soon as pairing starts.</p><ul class="phones" id="phones"></ul><p class="empty" id="phones-empty">No saved pairing on this Mac yet.</p><div class="actions"><button class="primary" id="connect">Add a device</button><button id="reconnect">Reconnect</button><button id="new-qr" class="hidden">Create a new QR</button><button id="refresh">Refresh status</button><button id="copy" class="hidden">Copy pairing link</button></div><div class="message small" id="message" role="status"></div></div>
|
|
30
|
+
<div class="copy"><h2 id="heading">Add a device</h2><p id="detail">Scan the QR in GrantTap on iPhone, iPad, or Android. It appears here as soon as pairing starts.</p><ul class="phones" id="phones"></ul><p class="empty" id="phones-empty">No saved pairing on this Mac yet.</p><div class="actions"><button class="primary" id="connect">Add a device</button><button id="reconnect">Reconnect</button><button id="new-qr" class="hidden">Create a new QR</button><button id="refresh">Refresh status</button><button id="copy" class="hidden">Copy pairing link</button></div><p id="account-status" class="small">Checking account link…</p><div class="actions"><button id="passkey">Connect with passkey</button><a id="passkey-link" class="hidden" target="_blank" rel="noopener noreferrer">Continue on granttap.com</a></div><div class="message small" id="message" role="status"></div></div>
|
|
31
31
|
<img id="qr" class="hidden" alt="GrantTap pairing QR" />
|
|
32
32
|
</section>
|
|
33
33
|
<p id="expiry" class="small" aria-live="polite"></p>
|
|
@@ -36,9 +36,8 @@ details{border-top:1px solid #8884;padding-top:14px}summary{cursor:pointer;font-
|
|
|
36
36
|
<dt>Relay observed by this MCP</dt><dd id="relay">Not checked</dd>
|
|
37
37
|
<dt>Last encrypted message from iPhone</dt><dd id="phone">Not observed</dd></dl>
|
|
38
38
|
<h3>Provider readiness</h3><ul id="providers"></ul>
|
|
39
|
-
<p>
|
|
40
|
-
|
|
41
|
-
<p>Missing hooks or background sync? Run <code>npm install -g granttap-mcp@0.8.23</code> and <code>granttap setup</code> locally.
|
|
39
|
+
<p>QR pairs a phone directly. A passkey links this Mac to your GrantTap account so your signed-in phone can discover its computers. Provider sign-in is separate.</p>
|
|
40
|
+
<p>Missing hooks or background sync? Run <code>npm install -g granttap-mcp@0.8.36</code> and <code>granttap setup</code> locally.
|
|
42
41
|
A saved pairing alone does not prove your phone or the background helper is online.</p>
|
|
43
42
|
<p><a href="https://granttap.com" target="_blank" rel="noopener noreferrer">Website</a> ·
|
|
44
43
|
<a href="https://granttap.com/privacy" target="_blank" rel="noopener noreferrer">Privacy</a> ·
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { computerDisplayName } from "../../../../../packages/core/computer-name";
|
|
2
2
|
import {
|
|
3
3
|
sampleAgentProcesses, type AgentProcessLoad,
|
|
4
4
|
} from "../../../../bridge/src/machine-load/host/process-sampler";
|
|
@@ -12,7 +12,7 @@ export async function desktopMachineLoad(
|
|
|
12
12
|
return {
|
|
13
13
|
operation: "desktop.machine_load" as const,
|
|
14
14
|
source: "process_sample" as const,
|
|
15
|
-
computer:
|
|
15
|
+
computer: computerDisplayName(),
|
|
16
16
|
observed_at: now(),
|
|
17
17
|
agents: Object.entries(processes)
|
|
18
18
|
.filter(([agent]) => agent.length > 0)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { computerDisplayName } from "../../../../../packages/core/computer-name";
|
|
2
2
|
import { loadConfig } from "../../../../bridge/src/config";
|
|
3
3
|
import { reloadPairingHelper } from "../../../../bridge/src/install";
|
|
4
4
|
import { createOneTimePairing, DEFAULT_RELAY, PAIRING_CODE_TTL_MINUTES } from "../../../../bridge/src/pairing";
|
|
@@ -37,7 +37,7 @@ export class DesktopControllerEnrollment {
|
|
|
37
37
|
|
|
38
38
|
snapshot(now = Date.now()): DesktopControllerCode {
|
|
39
39
|
const result: DesktopControllerCode = {
|
|
40
|
-
operation: "desktop.controller_enrollment", status: "idle", computer:
|
|
40
|
+
operation: "desktop.controller_enrollment", status: "idle", computer: computerDisplayName(),
|
|
41
41
|
};
|
|
42
42
|
if (!this.pending) return result;
|
|
43
43
|
if (!isMachineConfigured()) { this.pending = null; return result; }
|
|
@@ -77,7 +77,8 @@ export class DesktopControllerEnrollment {
|
|
|
77
77
|
this.onPairingChanged();
|
|
78
78
|
return this.snapshot();
|
|
79
79
|
} catch {
|
|
80
|
-
return { operation: "desktop.controller_enrollment", status: "unavailable",
|
|
80
|
+
return { operation: "desktop.controller_enrollment", status: "unavailable",
|
|
81
|
+
computer: computerDisplayName() };
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
84
|
}
|
|
@@ -7,6 +7,7 @@ import { isMachineConfigured } from "../../status/pairing-status";
|
|
|
7
7
|
import { ConnectionState, connectionOutput } from "../../connection-center/state";
|
|
8
8
|
import { CONNECTION_WIDGET_URI } from "./widget";
|
|
9
9
|
import { resetRelay, relay } from "./relay";
|
|
10
|
+
import { startPasskeyAccountLink } from "../../connection-center/passkey";
|
|
10
11
|
|
|
11
12
|
const widgetMeta = {
|
|
12
13
|
ui: { resourceUri: CONNECTION_WIDGET_URI, visibility: ["model", "app"] },
|
|
@@ -39,6 +40,24 @@ export function registerConnectTool(server: McpServer): void {
|
|
|
39
40
|
annotations: { ...changes, readOnlyHint: true, idempotentHint: true },
|
|
40
41
|
_meta: widgetMeta,
|
|
41
42
|
}, async () => connectionResult(state));
|
|
43
|
+
server.registerTool("connect_with_passkey", {
|
|
44
|
+
title: "Link this Mac to a GrantTap account",
|
|
45
|
+
description: "Open a short-lived GrantTap account passkey request for this Mac. The passkey is entered only on granttap.com; existing QR pairings remain unchanged.",
|
|
46
|
+
inputSchema: {},
|
|
47
|
+
outputSchema: connectionOutput,
|
|
48
|
+
annotations: changes,
|
|
49
|
+
_meta: widgetMeta,
|
|
50
|
+
}, async () => {
|
|
51
|
+
try {
|
|
52
|
+
const passkeyUrl = await startPasskeyAccountLink();
|
|
53
|
+
const result = connectionResult(state);
|
|
54
|
+
const meta = result._meta?.granttap;
|
|
55
|
+
const granttap = meta && typeof meta === "object" ? meta : {};
|
|
56
|
+
return { ...result, _meta: { granttap: { ...granttap, passkeyUrl } } };
|
|
57
|
+
} catch {
|
|
58
|
+
return { isError: true, content: [{ type: "text", text: "GrantTap could not start passkey linking. Check the account service and try again." }] };
|
|
59
|
+
}
|
|
60
|
+
});
|
|
42
61
|
server.registerTool(
|
|
43
62
|
"connect",
|
|
44
63
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
|
|
3
|
-
export const CONNECTION_WIDGET_URI = "ui://granttap/connection/
|
|
3
|
+
export const CONNECTION_WIDGET_URI = "ui://granttap/connection/v4.html";
|
|
4
4
|
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
6
|
|
|
@@ -13,7 +13,7 @@ export function registerConnectionWidget(server: McpServer): void {
|
|
|
13
13
|
CONNECTION_WIDGET_URI,
|
|
14
14
|
{
|
|
15
15
|
title: "GrantTap connection center",
|
|
16
|
-
description: "GrantTap card:
|
|
16
|
+
description: "GrantTap card: QR device pairing, Mac account passkey linking, and connection status.",
|
|
17
17
|
mimeType: "text/html;profile=mcp-app",
|
|
18
18
|
},
|
|
19
19
|
async (uri) => ({
|
|
@@ -23,7 +23,7 @@ export function registerConnectionWidget(server: McpServer): void {
|
|
|
23
23
|
text: html,
|
|
24
24
|
_meta: {
|
|
25
25
|
ui: { prefersBorder: true, permissions: { clipboardWrite: {} }, csp: { connectDomains: [], resourceDomains: [] } },
|
|
26
|
-
"openai/widgetDescription": "
|
|
26
|
+
"openai/widgetDescription": "Pair a device by QR or link this Mac to your account with passkey.",
|
|
27
27
|
"openai/widgetPrefersBorder": true,
|
|
28
28
|
},
|
|
29
29
|
}],
|
|
@@ -2,7 +2,7 @@ import { codexHooksReady } from "../../../../bridge/src/codex-hook-trust";
|
|
|
2
2
|
/**
|
|
3
3
|
* Public consent snapshot for granttap.com. No pairing keys, tokens, or codes.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { computerDisplayName } from "../../../../../packages/core/computer-name";
|
|
6
6
|
import { inspectAgentIntegrations } from "../../../../bridge/src/install";
|
|
7
7
|
import { loadConfig } from "../../../../bridge/src/config";
|
|
8
8
|
import { computerId } from "../../../../bridge/src/mesh/identity/computer";
|
|
@@ -49,7 +49,7 @@ export function buildConnectSnapshot(clientName?: string): ConnectSnapshot {
|
|
|
49
49
|
const runtime = connectionRuntimeStatus(room || undefined);
|
|
50
50
|
return {
|
|
51
51
|
clientName: publicClientName(clientName),
|
|
52
|
-
computerName:
|
|
52
|
+
computerName: computerDisplayName(),
|
|
53
53
|
paired,
|
|
54
54
|
passkeyCapable: true,
|
|
55
55
|
machineId: loadAccountLink()?.machineId,
|
|
@@ -70,7 +70,7 @@ export function buildConnectSnapshot(clientName?: string): ConnectSnapshot {
|
|
|
70
70
|
|
|
71
71
|
/** Computers and open tasks only. No transcripts, keys, or room ids. */
|
|
72
72
|
export function publicMeshSummary(): ConnectMesh {
|
|
73
|
-
const thisComputer =
|
|
73
|
+
const thisComputer = computerDisplayName();
|
|
74
74
|
try {
|
|
75
75
|
const store = localMeshStore();
|
|
76
76
|
const names = new Set<string>([thisComputer, computerId().slice(0, 80)]);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { computerDisplayName } from "../../../../packages/core/computer-name";
|
|
2
2
|
import { loadConfig } from "../../../bridge/src/config";
|
|
3
3
|
import { connectionRuntimeStatus } from "../mcp-tools/connect/relay";
|
|
4
4
|
import { packageVersion } from "./package-version";
|
|
5
5
|
import { computerId } from "../../../bridge/src/mesh/identity/computer";
|
|
6
|
+
import { loadAccountLink } from "../../../bridge/src/account-recovery/link";
|
|
6
7
|
import {
|
|
7
8
|
isMachineConfigured, listPairedPhones, phoneReachability, readOnlyMachineConfigPath,
|
|
8
9
|
} from "./pairing-status";
|
|
@@ -18,8 +19,9 @@ export function desktopStatusSnapshot(desktopEngineSocket?: string) {
|
|
|
18
19
|
schema: "granttap.desktop-status.v1" as const,
|
|
19
20
|
ok: true,
|
|
20
21
|
service: "granttap-mcp" as const,
|
|
21
|
-
computer:
|
|
22
|
+
computer: computerDisplayName(),
|
|
22
23
|
endpointId: computerId(),
|
|
24
|
+
accountLinkSaved: loadAccountLink() !== null,
|
|
23
25
|
version: packageVersion(),
|
|
24
26
|
paired: configured,
|
|
25
27
|
phoneReachability: phoneReachability(phones),
|
|
@@ -3,7 +3,8 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { loadConfig } from "../../../bridge/src/config";
|
|
6
|
-
import { hasControllerPresence, readControllerLastSeenAt,
|
|
6
|
+
import { hasControllerPresence, readControllerLastSeenAt, readControllerName,
|
|
7
|
+
readPhoneLastSeenAt } from "../../../bridge/src/pairing/presence";
|
|
7
8
|
import { controllerPeerGate } from "../../../bridge/src/pairing/controllers";
|
|
8
9
|
|
|
9
10
|
export type PairedPhone = {
|
|
@@ -59,14 +60,15 @@ export function listPairedPhones(phoneLastSeenAt: number | null = null, now = Da
|
|
|
59
60
|
value != null && (best == null || value > best) ? value : best
|
|
60
61
|
), null);
|
|
61
62
|
const seen = lastSeenAt != null && now - lastSeenAt < 60_000;
|
|
62
|
-
const primary: PairedPhone = { name
|
|
63
|
+
const primary: PairedPhone = { name: readControllerName(machine.peerPublicKey) ?? name,
|
|
64
|
+
status: seen ? "seen" : "paired", lastSeenAt };
|
|
63
65
|
const allowed = controllerPeerGate(machine);
|
|
64
66
|
const additional = (machine.extraPeerPublicKeys ?? [])
|
|
65
67
|
.filter((key) => key && key !== machine.peerPublicKey && allowed(key))
|
|
66
68
|
.map((key, index): PairedPhone => {
|
|
67
69
|
const at = readControllerLastSeenAt(key);
|
|
68
70
|
const suffix = createHash("sha256").update(key).digest("hex").slice(0, 6);
|
|
69
|
-
return { name: `Controller ${index + 2} · ${suffix}`,
|
|
71
|
+
return { name: readControllerName(key) ?? `Controller ${index + 2} · ${suffix}`,
|
|
70
72
|
status: at != null && now - at < 60_000 ? "seen" : "paired", lastSeenAt: at };
|
|
71
73
|
});
|
|
72
74
|
return [primary, ...additional];
|
package/cursor-plugin/README.md
CHANGED
|
@@ -10,7 +10,7 @@ keeps each phone response tied to the exact originating chat and prompt.
|
|
|
10
10
|
Install the supported CLI on this computer, then run:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
npm install -g granttap-mcp@0.8.
|
|
13
|
+
npm install -g granttap-mcp@0.8.36
|
|
14
14
|
granttap setup
|
|
15
15
|
granttap status
|
|
16
16
|
```
|
package/cursor-plugin/mcp.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"command": "node",
|
|
5
5
|
"args": [
|
|
6
6
|
"-e",
|
|
7
|
-
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.
|
|
7
|
+
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.36\";\nconst dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === \"1\";\nconst platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM\n ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;\nconst nodeRoot = dryRun && process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT\n ? process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT : dirname(process.execPath);\nconst pathRoots = (process.env.PATH || \"\").split(delimiter).filter(Boolean);\nconst appData = process.env.APPDATA;\nconst roots = [nodeRoot, ...pathRoots, ...(appData ? [join(appData, \"npm\")] : [])];\nconst first = (paths) => paths.find((path) => existsSync(path));\n\nfunction launch() {\n if (platform !== \"win32\") {\n const installed = first(roots.map((root) => join(root, \"granttap-mcp\")));\n if (installed) return { command: installed, args: [] };\n const npx = first(roots.map((root) => join(root, \"npx\"))) || \"npx\";\n return { command: npx, args: [\"-y\", PACKAGE] };\n }\n const installed = first(roots.map((root) =>\n join(root, \"node_modules\", \"granttap-mcp\", \"bin\", \"granttap-mcp.mjs\")));\n if (installed) return { command: process.execPath, args: [installed] };\n const npx = first(roots.map((root) =>\n join(root, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\")));\n if (npx) return { command: process.execPath, args: [npx, \"-y\", PACKAGE] };\n throw new Error(\"Node.js/npm 20+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
|
|
8
8
|
]
|
|
9
9
|
}
|
|
10
10
|
}
|
|
@@ -7,7 +7,7 @@ const { spawn } = require("node:child_process");
|
|
|
7
7
|
const { existsSync } = require("node:fs");
|
|
8
8
|
const { delimiter, dirname, join } = require("node:path");
|
|
9
9
|
|
|
10
|
-
const PACKAGE = "granttap-mcp@0.8.
|
|
10
|
+
const PACKAGE = "granttap-mcp@0.8.36";
|
|
11
11
|
const dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === "1";
|
|
12
12
|
const platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM
|
|
13
13
|
? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;
|
package/package.json
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
|
|
4
|
+
type NameSources = {
|
|
5
|
+
platform?: NodeJS.Platform;
|
|
6
|
+
host?: string;
|
|
7
|
+
override?: string;
|
|
8
|
+
macName?: () => string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function usableName(value: string | undefined): string | null {
|
|
12
|
+
const name = value?.trim();
|
|
13
|
+
return name && name.length <= 80 && !/[\x00-\x1f\x7f]/.test(name) ? name : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** User-visible computer name; network hostname remains only a fallback. */
|
|
17
|
+
export function computerDisplayName(sources: NameSources = {}): string {
|
|
18
|
+
const override = usableName(sources.override ?? process.env.GRANTTAP_COMPUTER_NAME);
|
|
19
|
+
if (override) return override;
|
|
20
|
+
if ((sources.platform ?? process.platform) === "darwin") {
|
|
21
|
+
try {
|
|
22
|
+
const macName = sources.macName ?? (() => execFileSync(
|
|
23
|
+
"/usr/sbin/scutil", ["--get", "ComputerName"],
|
|
24
|
+
{ encoding: "utf8", timeout: 2_000, maxBuffer: 4_096 },
|
|
25
|
+
));
|
|
26
|
+
const name = usableName(macName());
|
|
27
|
+
if (name) return name;
|
|
28
|
+
} catch { /* Computer Name may be unset; preserve the network fallback. */ }
|
|
29
|
+
}
|
|
30
|
+
return usableName(sources.host ?? hostname()) ?? "Computer";
|
|
31
|
+
}
|