tippa 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/LICENSE +21 -0
- package/README.md +124 -0
- package/dist/channel/bin.d.mts +1 -0
- package/dist/channel/bin.mjs +353 -0
- package/dist/client/index.d.ts +21 -0
- package/dist/client/index.js +8239 -0
- package/dist/execute-context-menu-action-Bbh74u99-DE1LGcxh.js +3474 -0
- package/dist/http-BcWR4_RM.mjs +159 -0
- package/dist/index.d.mts +94 -0
- package/dist/index.mjs +457 -0
- package/dist/renderer-Ym7FGQIR--GlE3v8a.js +2876 -0
- package/package.json +64 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { readFileSync, readdirSync, rmSync } from "node:fs";
|
|
5
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
6
|
+
//#region src/channel/discovery.ts
|
|
7
|
+
/** `.tippa/channel.json`: where the plugin finds the helper */
|
|
8
|
+
const discoverySchema = z.object({
|
|
9
|
+
port: z.number().int().positive(),
|
|
10
|
+
secret: z.string().min(1),
|
|
11
|
+
pid: z.number().int().positive()
|
|
12
|
+
});
|
|
13
|
+
/** `<cwd>/.tippa`, the helper's state: discovery file and screenshots */
|
|
14
|
+
function stateDir(cwd) {
|
|
15
|
+
return join(cwd, ".tippa");
|
|
16
|
+
}
|
|
17
|
+
function discoveryPath(cwd) {
|
|
18
|
+
return join(stateDir(cwd), "channel.json");
|
|
19
|
+
}
|
|
20
|
+
/** creates the state dir if it's gone, ignored by the app's git: it holds a live secret */
|
|
21
|
+
async function prepareStateDir(cwd) {
|
|
22
|
+
await mkdir(stateDir(cwd), { recursive: true });
|
|
23
|
+
await writeFile(join(stateDir(cwd), ".gitignore"), "*\n");
|
|
24
|
+
}
|
|
25
|
+
async function writeDiscovery(cwd, discovery) {
|
|
26
|
+
const path = discoveryPath(cwd);
|
|
27
|
+
await prepareStateDir(cwd);
|
|
28
|
+
const tmp = `${path}.${discovery.pid}.tmp`;
|
|
29
|
+
await writeFile(tmp, JSON.stringify(discovery), { mode: 384 });
|
|
30
|
+
await rename(tmp, path);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* removes the file only while it still holds `secret`: a second helper started
|
|
34
|
+
* in the same project overwrites it, and its file must outlive this one.
|
|
35
|
+
*/
|
|
36
|
+
function removeDiscovery(cwd, secret) {
|
|
37
|
+
const path = discoveryPath(cwd);
|
|
38
|
+
try {
|
|
39
|
+
if (JSON.parse(readFileSync(path, "utf8"))?.secret === secret) rmSync(path, { force: true });
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
function isAlive(pid) {
|
|
43
|
+
try {
|
|
44
|
+
process.kill(pid, 0);
|
|
45
|
+
return true;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return error.code === "EPERM";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** `shots-<pid>-<random>`: the pid lets a later helper remove what a killed one left */
|
|
51
|
+
function screenshotDirName(pid, random) {
|
|
52
|
+
return `shots-${pid}-${random}`;
|
|
53
|
+
}
|
|
54
|
+
/** removes screenshot dirs whose helper is gone without cleaning up (SIGKILL, crash) */
|
|
55
|
+
function removeOrphanedScreenshots(cwd) {
|
|
56
|
+
let entries;
|
|
57
|
+
try {
|
|
58
|
+
entries = readdirSync(stateDir(cwd));
|
|
59
|
+
} catch {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const pid = Number(/^shots-(\d+)-/.exec(entry)?.[1]);
|
|
64
|
+
if (pid > 0 && !isAlive(pid)) rmSync(join(stateDir(cwd), entry), {
|
|
65
|
+
recursive: true,
|
|
66
|
+
force: true
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/protocol.ts
|
|
72
|
+
/** the dev server and the channel helper refuse a pick body past this */
|
|
73
|
+
const MAX_BODY_BYTES = 10485760;
|
|
74
|
+
/**
|
|
75
|
+
* base64 chars each element's screenshot may take: a full pick's screenshots together
|
|
76
|
+
* leave room in the body for the html and the json around them
|
|
77
|
+
*/
|
|
78
|
+
const MAX_SCREENSHOT_CHARS = Math.floor(8388608 / 5);
|
|
79
|
+
/** base64 of the 8-byte png signature and the ihdr length's leading zero bits */
|
|
80
|
+
const PNG_BASE64_PREFIX = "iVBORw0KGgo";
|
|
81
|
+
const REPLY_EVENT = "tippa:reply";
|
|
82
|
+
const STATUS_EVENT = "tippa:status";
|
|
83
|
+
/** sent by the loader right after `start` returns; answered with `tippa:status` to that client only */
|
|
84
|
+
const STATUS_REQUEST_EVENT = "tippa:status-request";
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/schema.ts
|
|
87
|
+
/** the dev server keeps this many leading chars of a pick's html */
|
|
88
|
+
const MAX_HTML_CHARS = 4e3;
|
|
89
|
+
const MAX_MODULE_URL_CHARS = 2048;
|
|
90
|
+
const pickIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/);
|
|
91
|
+
/** base64 png; checked by prefix, so it holds in the browser as well as node */
|
|
92
|
+
const pngBase64Schema = z.base64().max(MAX_SCREENSHOT_CHARS).refine((value) => value.startsWith(PNG_BASE64_PREFIX), "not a png");
|
|
93
|
+
/** one picked element of a pick */
|
|
94
|
+
const pickElementSchema = z.object({
|
|
95
|
+
component: z.string(),
|
|
96
|
+
/**
|
|
97
|
+
* as the client posts it, react-grab's source: the sourcemap's `sources` entry, relative to
|
|
98
|
+
* {@link moduleUrl}'s directory; as an agent receives it, an absolute path
|
|
99
|
+
*/
|
|
100
|
+
file: z.string(),
|
|
101
|
+
line: z.number().int().positive(),
|
|
102
|
+
column: z.number().int().positive().optional(),
|
|
103
|
+
/**
|
|
104
|
+
* the served module the component came from, as the browser loaded it: an absolute
|
|
105
|
+
* same-origin url or a root-relative path, e.g. `http://localhost:5173/src/ui/Button.tsx?t=1`
|
|
106
|
+
* or `/@fs/abs/Button.tsx`; without it, `file` is read as a vite url
|
|
107
|
+
*/
|
|
108
|
+
moduleUrl: z.string().max(MAX_MODULE_URL_CHARS).optional(),
|
|
109
|
+
/** the dev server keeps the first {@link MAX_HTML_CHARS} */
|
|
110
|
+
html: z.string(),
|
|
111
|
+
/** base64 png of the picked element */
|
|
112
|
+
screenshot: pngBase64Schema.optional()
|
|
113
|
+
});
|
|
114
|
+
/** what the browser client posts to the dev server for one pick */
|
|
115
|
+
const pickRequestSchema = z.object({
|
|
116
|
+
pickId: pickIdSchema,
|
|
117
|
+
/** refers to `elements[i]` by the literal marker `[i + 1]` */
|
|
118
|
+
note: z.string(),
|
|
119
|
+
elements: z.array(pickElementSchema).min(1).max(5)
|
|
120
|
+
});
|
|
121
|
+
/** what an agent reports back about a pick, sent to the page as the `tippa:reply` hmr event */
|
|
122
|
+
const pickReplySchema = z.object({
|
|
123
|
+
pickId: z.string(),
|
|
124
|
+
status: z.enum([
|
|
125
|
+
"working",
|
|
126
|
+
"done",
|
|
127
|
+
"question"
|
|
128
|
+
]),
|
|
129
|
+
message: z.string()
|
|
130
|
+
});
|
|
131
|
+
//#endregion
|
|
132
|
+
//#region src/http.ts
|
|
133
|
+
/** constant-time check of a shared-secret request header */
|
|
134
|
+
function hasSecretHeader(req, header, secret) {
|
|
135
|
+
const given = req.headers[header];
|
|
136
|
+
if (typeof given !== "string") return false;
|
|
137
|
+
const digest = (value) => createHash("sha256").update(value).digest();
|
|
138
|
+
return timingSafeEqual(digest(given), digest(secret));
|
|
139
|
+
}
|
|
140
|
+
function sendJson(res, status, body) {
|
|
141
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
142
|
+
res.end(JSON.stringify(body));
|
|
143
|
+
}
|
|
144
|
+
/** resolves undefined once the body passes `limit`, after draining the rest */
|
|
145
|
+
async function readBody(req, limit) {
|
|
146
|
+
const chunks = [];
|
|
147
|
+
let size = 0;
|
|
148
|
+
for await (const chunk of req) {
|
|
149
|
+
size += chunk.length;
|
|
150
|
+
if (size <= limit) chunks.push(chunk);
|
|
151
|
+
}
|
|
152
|
+
return size > limit ? void 0 : Buffer.concat(chunks).toString("utf8");
|
|
153
|
+
}
|
|
154
|
+
/** the browser went away mid-request: nothing to answer and nothing to report */
|
|
155
|
+
function isClientAbort(req) {
|
|
156
|
+
return req.destroyed && !req.complete;
|
|
157
|
+
}
|
|
158
|
+
//#endregion
|
|
159
|
+
export { removeDiscovery as _, MAX_HTML_CHARS as a, stateDir as b, pickRequestSchema as c, STATUS_EVENT as d, STATUS_REQUEST_EVENT as f, prepareStateDir as g, isAlive as h, sendJson as i, MAX_BODY_BYTES as l, discoverySchema as m, isClientAbort as n, pickIdSchema as o, discoveryPath as p, readBody as r, pickReplySchema as s, hasSecretHeader as t, REPLY_EVENT as u, removeOrphanedScreenshots as v, writeDiscovery as x, screenshotDirName as y };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { Logger, Plugin } from "vite";
|
|
3
|
+
//#region src/schema.d.ts
|
|
4
|
+
/** one picked element of a pick */
|
|
5
|
+
declare const pickElementSchema: z.ZodObject<{
|
|
6
|
+
component: z.ZodString;
|
|
7
|
+
file: z.ZodString;
|
|
8
|
+
line: z.ZodNumber;
|
|
9
|
+
column: z.ZodOptional<z.ZodNumber>;
|
|
10
|
+
moduleUrl: z.ZodOptional<z.ZodString>;
|
|
11
|
+
html: z.ZodString;
|
|
12
|
+
screenshot: z.ZodOptional<z.ZodBase64>;
|
|
13
|
+
}, z.core.$strip>;
|
|
14
|
+
/** what the browser client posts to the dev server for one pick */
|
|
15
|
+
declare const pickRequestSchema: z.ZodObject<{
|
|
16
|
+
pickId: z.ZodString;
|
|
17
|
+
note: z.ZodString;
|
|
18
|
+
elements: z.ZodArray<z.ZodObject<{
|
|
19
|
+
component: z.ZodString;
|
|
20
|
+
file: z.ZodString;
|
|
21
|
+
line: z.ZodNumber;
|
|
22
|
+
column: z.ZodOptional<z.ZodNumber>;
|
|
23
|
+
moduleUrl: z.ZodOptional<z.ZodString>;
|
|
24
|
+
html: z.ZodString;
|
|
25
|
+
screenshot: z.ZodOptional<z.ZodBase64>;
|
|
26
|
+
}, z.core.$strip>>;
|
|
27
|
+
}, z.core.$strip>;
|
|
28
|
+
/** what an agent reports back about a pick, sent to the page as the `tippa:reply` hmr event */
|
|
29
|
+
declare const pickReplySchema: z.ZodObject<{
|
|
30
|
+
pickId: z.ZodString;
|
|
31
|
+
status: z.ZodEnum<{
|
|
32
|
+
done: "done";
|
|
33
|
+
question: "question";
|
|
34
|
+
working: "working";
|
|
35
|
+
}>;
|
|
36
|
+
message: z.ZodString;
|
|
37
|
+
}, z.core.$strip>;
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/protocol.d.ts
|
|
40
|
+
/** see `pickRequestSchema` */
|
|
41
|
+
type PickRequest = z.output<typeof pickRequestSchema>;
|
|
42
|
+
/** see `pickElementSchema` */
|
|
43
|
+
type PickElement = z.output<typeof pickElementSchema>;
|
|
44
|
+
/** payload of the `tippa:reply` hmr event */
|
|
45
|
+
type PickReply = z.output<typeof pickReplySchema>;
|
|
46
|
+
type AgentStatus = "connected" | "waiting";
|
|
47
|
+
/** payload of the `tippa:status` hmr event */
|
|
48
|
+
interface StatusEvent {
|
|
49
|
+
status: AgentStatus;
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/agent.d.ts
|
|
53
|
+
/** how tippa reaches a coding agent; `claudeSession()` is one */
|
|
54
|
+
interface AgentAdapter {
|
|
55
|
+
/** shown in the status line: `tippa → connected to <label>` */
|
|
56
|
+
label: string;
|
|
57
|
+
connect(context: AgentContext): AgentConnection;
|
|
58
|
+
}
|
|
59
|
+
interface AgentContext {
|
|
60
|
+
/** vite's resolved root */
|
|
61
|
+
root: string;
|
|
62
|
+
logger: Logger;
|
|
63
|
+
}
|
|
64
|
+
interface AgentConnection {
|
|
65
|
+
readonly status: AgentStatus;
|
|
66
|
+
/** called after the first connection check, then once per change */
|
|
67
|
+
onStatus(listener: (status: AgentStatus) => void): void;
|
|
68
|
+
onReply(listener: (reply: PickReply) => void): void;
|
|
69
|
+
/** rejects with `AgentNotConnectedError` when disconnected, any other error when the agent refuses */
|
|
70
|
+
send(pick: PickRequest): Promise<void>;
|
|
71
|
+
close(): Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
/** what `send` rejects with when no agent is reachable; the endpoint answers 503 */
|
|
74
|
+
export declare class AgentNotConnectedError extends Error {
|
|
75
|
+
constructor(label: string);
|
|
76
|
+
}
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/claude-session.d.ts
|
|
79
|
+
/**
|
|
80
|
+
* sends picks to the claude code session running the `tippa-channel` helper.
|
|
81
|
+
* finds the helper through the nearest `.tippa/channel.json` at or above vite's root.
|
|
82
|
+
*/
|
|
83
|
+
export declare function claudeSession(): AgentAdapter;
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region src/plugin.d.ts
|
|
86
|
+
interface TippaOptions {
|
|
87
|
+
/** the agent picks are sent to, e.g. `claudeSession()` */
|
|
88
|
+
agent: AgentAdapter;
|
|
89
|
+
/** pick hotkey passed to the browser client; defaults to react-grab's */
|
|
90
|
+
key?: string;
|
|
91
|
+
}
|
|
92
|
+
export declare function tippa(options: TippaOptions): Plugin;
|
|
93
|
+
//#endregion
|
|
94
|
+
export type { AgentAdapter, AgentConnection, AgentContext, AgentStatus, PickElement, PickReply, PickRequest, StatusEvent, TippaOptions };
|