aibroker 0.13.0 → 0.14.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/dist/adapters/iterm/core.d.ts +11 -0
- package/dist/adapters/iterm/core.d.ts.map +1 -1
- package/dist/adapters/iterm/core.js +38 -16
- package/dist/adapters/iterm/core.js.map +1 -1
- package/dist/core/env.d.ts +14 -0
- package/dist/core/env.d.ts.map +1 -0
- package/dist/core/env.js +48 -0
- package/dist/core/env.js.map +1 -0
- package/dist/core/state.d.ts +27 -1
- package/dist/core/state.d.ts.map +1 -1
- package/dist/core/state.js +20 -1
- package/dist/core/state.js.map +1 -1
- package/dist/daemon/cli.js +6 -0
- package/dist/daemon/cli.js.map +1 -1
- package/dist/daemon/commands.d.ts.map +1 -1
- package/dist/daemon/commands.js +31 -10
- package/dist/daemon/commands.js.map +1 -1
- package/dist/daemon/core-handlers.d.ts.map +1 -1
- package/dist/daemon/core-handlers.js +120 -12
- package/dist/daemon/core-handlers.js.map +1 -1
- package/dist/daemon/dispatch.d.ts +8 -0
- package/dist/daemon/dispatch.d.ts.map +1 -1
- package/dist/daemon/dispatch.js +20 -3
- package/dist/daemon/dispatch.js.map +1 -1
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +32 -34
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/mailbox-watch.d.ts +26 -0
- package/dist/daemon/mailbox-watch.d.ts.map +1 -0
- package/dist/daemon/mailbox-watch.js +69 -0
- package/dist/daemon/mailbox-watch.js.map +1 -0
- package/dist/daemon/ota.d.ts.map +1 -1
- package/dist/daemon/ota.js +9 -3
- package/dist/daemon/ota.js.map +1 -1
- package/dist/daemon/todoist-cli.d.ts +10 -0
- package/dist/daemon/todoist-cli.d.ts.map +1 -0
- package/dist/daemon/todoist-cli.js +108 -0
- package/dist/daemon/todoist-cli.js.map +1 -0
- package/dist/daemon/todoist-ingress.d.ts +47 -0
- package/dist/daemon/todoist-ingress.d.ts.map +1 -0
- package/dist/daemon/todoist-ingress.js +101 -0
- package/dist/daemon/todoist-ingress.js.map +1 -0
- package/dist/daemon/todoist-oauth.d.ts +69 -0
- package/dist/daemon/todoist-oauth.d.ts.map +1 -0
- package/dist/daemon/todoist-oauth.js +182 -0
- package/dist/daemon/todoist-oauth.js.map +1 -0
- package/dist/daemon/todoist-owners.d.ts +23 -0
- package/dist/daemon/todoist-owners.d.ts.map +1 -0
- package/dist/daemon/todoist-owners.js +60 -0
- package/dist/daemon/todoist-owners.js.map +1 -0
- package/dist/daemon/todoist-reply.d.ts +58 -0
- package/dist/daemon/todoist-reply.d.ts.map +1 -0
- package/dist/daemon/todoist-reply.js +135 -0
- package/dist/daemon/todoist-reply.js.map +1 -0
- package/dist/daemon/todoist-webhook.d.ts +66 -3
- package/dist/daemon/todoist-webhook.d.ts.map +1 -1
- package/dist/daemon/todoist-webhook.js +244 -17
- package/dist/daemon/todoist-webhook.js.map +1 -1
- package/dist/mcp/index.js +30 -2
- package/dist/mcp/index.js.map +1 -1
- package/dist/transport/sync-facade.d.ts.map +1 -1
- package/dist/transport/sync-facade.js +12 -0
- package/dist/transport/sync-facade.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon/todoist-ingress.ts — granting a project the right to reach a session.
|
|
3
|
+
*
|
|
4
|
+
* The allowlist began as one environment variable of opaque project ids, read
|
|
5
|
+
* once at daemon start. That is the wrong shape for something you change while
|
|
6
|
+
* using the system: creating a Todoist project and then editing a file and
|
|
7
|
+
* restarting a daemon is not a workflow anyone will follow, and a project that
|
|
8
|
+
* looks ready but routes nowhere fails exactly the way this codebase keeps
|
|
9
|
+
* trying to stop things failing — silently, with everything appearing fine.
|
|
10
|
+
*
|
|
11
|
+
* So grants live here too, in a store the daemon re-reads, and every change is
|
|
12
|
+
* an operation with an audit record rather than a file edit. The environment
|
|
13
|
+
* variable still works and still wins nothing: the two are merged, so an
|
|
14
|
+
* existing setup keeps behaving exactly as it did.
|
|
15
|
+
*
|
|
16
|
+
* What does NOT change is that the list stays explicit. A task filed into a
|
|
17
|
+
* project becomes an instruction a session runs with the user's full rights,
|
|
18
|
+
* and Todoist's payload documents that the initiator may be a collaborator on
|
|
19
|
+
* a shared project. Granting ingress must be a decision. It is now a decision
|
|
20
|
+
* that is recorded.
|
|
21
|
+
*/
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { loadJson, saveJson } from "../core/json-store.js";
|
|
25
|
+
import { audit } from "./audit.js";
|
|
26
|
+
import { log } from "../core/log.js";
|
|
27
|
+
const FILE = join(homedir(), ".aibroker", "todoist-ingress.json");
|
|
28
|
+
function read() {
|
|
29
|
+
const r = loadJson(FILE);
|
|
30
|
+
if (r.status === "ok" && Array.isArray(r.data?.grants))
|
|
31
|
+
return r.data;
|
|
32
|
+
if (r.status === "unreadable") {
|
|
33
|
+
// Never rewrite a file we could not parse: an ingress list is a security
|
|
34
|
+
// boundary, and silently replacing it with an empty one is worse than
|
|
35
|
+
// refusing to add to it.
|
|
36
|
+
log(`todoist-ingress: ${FILE} is unreadable — grants from it are not in effect`);
|
|
37
|
+
}
|
|
38
|
+
return { grants: [] };
|
|
39
|
+
}
|
|
40
|
+
export function listGrants() {
|
|
41
|
+
return read().grants;
|
|
42
|
+
}
|
|
43
|
+
/** Grant a project the right to reach a session. Idempotent. */
|
|
44
|
+
export function grantIngress(projectId, opts = {}) {
|
|
45
|
+
const s = read();
|
|
46
|
+
const existing = s.grants.find((g) => g.projectId === projectId);
|
|
47
|
+
const grant = existing ?? {
|
|
48
|
+
projectId,
|
|
49
|
+
grantedAt: new Date().toISOString(),
|
|
50
|
+
};
|
|
51
|
+
if (opts.owner !== undefined)
|
|
52
|
+
grant.owner = opts.owner;
|
|
53
|
+
if (opts.projectName !== undefined)
|
|
54
|
+
grant.projectName = opts.projectName;
|
|
55
|
+
if (!existing)
|
|
56
|
+
s.grants.push(grant);
|
|
57
|
+
saveJson(FILE, s);
|
|
58
|
+
audit({
|
|
59
|
+
action: "ingress", actor: "aibroker", target: `todoist:project:${projectId}`,
|
|
60
|
+
outcome: existing ? "updated" : "granted",
|
|
61
|
+
reason: `${opts.projectName ?? projectId} → ${grant.owner ?? "(default owner)"}`,
|
|
62
|
+
});
|
|
63
|
+
log(`todoist-ingress: ${existing ? "updated" : "granted"} ${opts.projectName ?? projectId} → ${grant.owner ?? "default"}`);
|
|
64
|
+
return grant;
|
|
65
|
+
}
|
|
66
|
+
/** Revoke a grant. Returns whether anything was there to revoke. */
|
|
67
|
+
export function revokeIngress(projectId) {
|
|
68
|
+
const s = read();
|
|
69
|
+
const before = s.grants.length;
|
|
70
|
+
s.grants = s.grants.filter((g) => g.projectId !== projectId);
|
|
71
|
+
if (s.grants.length === before)
|
|
72
|
+
return false;
|
|
73
|
+
saveJson(FILE, s);
|
|
74
|
+
audit({
|
|
75
|
+
action: "ingress", actor: "aibroker", target: `todoist:project:${projectId}`,
|
|
76
|
+
outcome: "revoked",
|
|
77
|
+
});
|
|
78
|
+
log(`todoist-ingress: revoked ${projectId}`);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Merge stored grants into a config read from the environment.
|
|
83
|
+
*
|
|
84
|
+
* Called per request rather than at startup: the point of the store is that a
|
|
85
|
+
* grant takes effect when it is made, not when the daemon is next restarted.
|
|
86
|
+
* Returns a new object — the caller's config is never mutated.
|
|
87
|
+
*/
|
|
88
|
+
export function applyGrants(cfg) {
|
|
89
|
+
const grants = listGrants();
|
|
90
|
+
if (grants.length === 0)
|
|
91
|
+
return cfg;
|
|
92
|
+
const ids = new Set(cfg.ingressProjectIds);
|
|
93
|
+
const owners = new Map(cfg.projectOwners);
|
|
94
|
+
for (const g of grants) {
|
|
95
|
+
ids.add(g.projectId);
|
|
96
|
+
if (g.owner)
|
|
97
|
+
owners.set(g.projectId, g.owner);
|
|
98
|
+
}
|
|
99
|
+
return { ...cfg, ingressProjectIds: ids, projectOwners: owners };
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=todoist-ingress.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"todoist-ingress.js","sourceRoot":"","sources":["../../src/daemon/todoist-ingress.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,GAAG,EAAE,MAAM,gBAAgB,CAAC;AAGrC,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,EAAE,EAAE,WAAW,EAAE,sBAAsB,CAAC,CAAC;AAelE,SAAS,IAAI;IACX,MAAM,CAAC,GAAG,QAAQ,CAAQ,IAAI,CAAC,CAAC;IAChC,IAAI,CAAC,CAAC,MAAM,KAAK,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,EAAE,MAAM,CAAC;QAAE,OAAO,CAAC,CAAC,IAAI,CAAC;IACtE,IAAI,CAAC,CAAC,MAAM,KAAK,YAAY,EAAE,CAAC;QAC9B,yEAAyE;QACzE,sEAAsE;QACtE,yBAAyB;QACzB,GAAG,CAAC,oBAAoB,IAAI,mDAAmD,CAAC,CAAC;IACnF,CAAC;IACD,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC;AACxB,CAAC;AAED,MAAM,UAAU,UAAU;IACxB,OAAO,IAAI,EAAE,CAAC,MAAM,CAAC;AACvB,CAAC;AAED,gEAAgE;AAChE,MAAM,UAAU,YAAY,CAAC,SAAiB,EAAE,OAAiD,EAAE;IACjG,MAAM,CAAC,GAAG,IAAI,EAAE,CAAC;IACjB,MAAM,QAAQ,GAAG,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC;IACjE,MAAM,KAAK,GAAiB,QAAQ,IAAI;QACtC,SAAS;QACT,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;KACpC,CAAC;IACF,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS;QAAE,KAAK,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC;IACvD,IAAI,IAAI,CAAC,WAAW,KAAK,SAAS;QAAE,KAAK,CAAC,WAAW,GAAG,IAAI,CAAC,WAAW,CAAC;IACzE,IAAI,CAAC,QAAQ;QAAE,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACpC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;IAElB,KAAK,CAAC;QACJ,MAAM,EAAE,SAAS,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,mBAAmB,SAAS,EAAE;QAC5E,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS;QACzC,MAAM,EAAE,GAAG,IAAI,CAAC,WAAW,IAAI,SAAS,MAAM,KAAK,CAAC,KAAK,IAAI,iBAAiB,EAAE;KACjF,CAAC,CAAC;IACH,GAAG,CAAC,oBAAoB,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,IAAI,IAAI,CAAC,WAAW,IAAI,SAAS,MAAM,KAAK,CAAC,KAAK,IAAI,SAAS,EAAE,CAAC,CAAC;IAC3H,OAAO,KAAK,CAAC;AACf,CAAC;AAED,oEAAoE;AACpE,MAAM,UAAU,aAAa,CAAC,SAAiB;IAC7C,MAAM,CAAC,GAAG,IAAI,EAAE,CAAC;IACjB,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC;IAC/B,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC;IAC7D,IAAI,CAAC,CAAC,MAAM,CAAC,MAAM,KAAK,MAAM;QAAE,OAAO,KAAK,CAAC;IAC7C,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;IAClB,KAAK,CAAC;QACJ,MAAM,EAAE,SAAS,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,mBAAmB,SAAS,EAAE;QAC5E,OAAO,EAAE,SAAS;KACnB,CAAC,CAAC;IACH,GAAG,CAAC,4BAA4B,SAAS,EAAE,CAAC,CAAC;IAC7C,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,WAAW,CAAC,GAAkB;IAC5C,MAAM,MAAM,GAAG,UAAU,EAAE,CAAC;IAC5B,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,GAAG,CAAC;IAEpC,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IAC3C,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;IAC1C,KAAK,MAAM,CAAC,IAAI,MAAM,EAAE,CAAC;QACvB,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;QACrB,IAAI,CAAC,CAAC,KAAK;YAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC;IAChD,CAAC;IACD,OAAO,EAAE,GAAG,GAAG,EAAE,iBAAiB,EAAE,GAAG,EAAE,aAAa,EAAE,MAAM,EAAE,CAAC;AACnE,CAAC"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon/todoist-oauth.ts — the OAuth landing the redirect URL actually needs.
|
|
3
|
+
*
|
|
4
|
+
* Todoist does not deliver webhooks to the account that created the app. The
|
|
5
|
+
* app has to be authorised like any third party would authorise it, and the
|
|
6
|
+
* console's "install for myself" button does not do that — only a completed
|
|
7
|
+
* OAuth round trip does. Todoist's own guidance is to run the flow by hand and
|
|
8
|
+
* lift the `code` out of the address bar with developer tools, then exchange it
|
|
9
|
+
* from a separate HTTP client because the exchange has to be a POST.
|
|
10
|
+
*
|
|
11
|
+
* That instruction is a description of a missing endpoint. The redirect URL is
|
|
12
|
+
* ours; if it serves nothing, the browser lands on a 404 and the human has to
|
|
13
|
+
* play proxy for a machine-to-machine step. So we serve it: this module takes
|
|
14
|
+
* the `code` off the redirect, does the token exchange itself, stores the token
|
|
15
|
+
* and reports what happened — on the page and in the audit trail.
|
|
16
|
+
*
|
|
17
|
+
* The state parameter is not decoration. `aibroker todoist auth` mints one and
|
|
18
|
+
* records it; a callback that cannot present it is refused, because otherwise
|
|
19
|
+
* anyone who can reach the redirect can drive a code of their choosing into
|
|
20
|
+
* this exchange.
|
|
21
|
+
*/
|
|
22
|
+
/** Anything Todoist hands back on a successful exchange, plus when we got it. */
|
|
23
|
+
export interface StoredToken {
|
|
24
|
+
access_token: string;
|
|
25
|
+
token_type: string;
|
|
26
|
+
scope?: string;
|
|
27
|
+
obtained_at: string;
|
|
28
|
+
}
|
|
29
|
+
/** Mint and record the state for one authorisation attempt. */
|
|
30
|
+
export declare function beginAuth(): string;
|
|
31
|
+
/**
|
|
32
|
+
* Check a callback's state against the recorded one and consume it.
|
|
33
|
+
*
|
|
34
|
+
* Returns a reason when it does not hold up, so the caller can say precisely
|
|
35
|
+
* what was wrong rather than "invalid request".
|
|
36
|
+
*/
|
|
37
|
+
export declare function consumeState(got: string | null): {
|
|
38
|
+
ok: true;
|
|
39
|
+
} | {
|
|
40
|
+
ok: false;
|
|
41
|
+
reason: string;
|
|
42
|
+
};
|
|
43
|
+
export declare function loadToken(): StoredToken | null;
|
|
44
|
+
export declare function saveToken(t: StoredToken): void;
|
|
45
|
+
/** Build the URL a browser has to visit to authorise the app. */
|
|
46
|
+
export declare function authorizeUrl(clientId: string, scope: string, state: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* Trade the authorisation code for a token.
|
|
49
|
+
*
|
|
50
|
+
* Kept separate from the request handler so the exchange can be tested and so
|
|
51
|
+
* the secret is used in exactly one place.
|
|
52
|
+
*/
|
|
53
|
+
export declare function exchangeCode(clientId: string, clientSecret: string, code: string, fetchImpl?: typeof fetch): Promise<StoredToken>;
|
|
54
|
+
export interface OAuthDeps {
|
|
55
|
+
clientId?: string;
|
|
56
|
+
clientSecret: string;
|
|
57
|
+
fetchImpl?: typeof fetch;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Handle the redirect Todoist sends the browser to.
|
|
61
|
+
*
|
|
62
|
+
* Always answers with a readable page — this is the one endpoint here a human
|
|
63
|
+
* looks at directly, and "404" taught the last person to debug it nothing.
|
|
64
|
+
*/
|
|
65
|
+
export declare function handleOAuthCallback(url: URL, deps: OAuthDeps): Promise<{
|
|
66
|
+
status: number;
|
|
67
|
+
html: string;
|
|
68
|
+
}>;
|
|
69
|
+
//# sourceMappingURL=todoist-oauth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"todoist-oauth.d.ts","sourceRoot":"","sources":["../../src/daemon/todoist-oauth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAaH,iFAAiF;AACjF,MAAM,WAAW,WAAW;IAC1B,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;CACrB;AA4BD,+DAA+D;AAC/D,wBAAgB,SAAS,IAAI,MAAM,CAIlC;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAU7F;AAED,wBAAgB,SAAS,IAAI,WAAW,GAAG,IAAI,CAE9C;AAED,wBAAgB,SAAS,CAAC,CAAC,EAAE,WAAW,GAAG,IAAI,CAE9C;AAED,iEAAiE;AACjE,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAGnF;AAED;;;;;GAKG;AACH,wBAAsB,YAAY,CAChC,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,EACpB,IAAI,EAAE,MAAM,EACZ,SAAS,GAAE,OAAO,KAAa,GAC9B,OAAO,CAAC,WAAW,CAAC,CAwBtB;AAgBD,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED;;;;;GAKG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,GAAG,EACR,IAAI,EAAE,SAAS,GACd,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAoD3C"}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon/todoist-oauth.ts — the OAuth landing the redirect URL actually needs.
|
|
3
|
+
*
|
|
4
|
+
* Todoist does not deliver webhooks to the account that created the app. The
|
|
5
|
+
* app has to be authorised like any third party would authorise it, and the
|
|
6
|
+
* console's "install for myself" button does not do that — only a completed
|
|
7
|
+
* OAuth round trip does. Todoist's own guidance is to run the flow by hand and
|
|
8
|
+
* lift the `code` out of the address bar with developer tools, then exchange it
|
|
9
|
+
* from a separate HTTP client because the exchange has to be a POST.
|
|
10
|
+
*
|
|
11
|
+
* That instruction is a description of a missing endpoint. The redirect URL is
|
|
12
|
+
* ours; if it serves nothing, the browser lands on a 404 and the human has to
|
|
13
|
+
* play proxy for a machine-to-machine step. So we serve it: this module takes
|
|
14
|
+
* the `code` off the redirect, does the token exchange itself, stores the token
|
|
15
|
+
* and reports what happened — on the page and in the audit trail.
|
|
16
|
+
*
|
|
17
|
+
* The state parameter is not decoration. `aibroker todoist auth` mints one and
|
|
18
|
+
* records it; a callback that cannot present it is refused, because otherwise
|
|
19
|
+
* anyone who can reach the redirect can drive a code of their choosing into
|
|
20
|
+
* this exchange.
|
|
21
|
+
*/
|
|
22
|
+
import { randomBytes } from "node:crypto";
|
|
23
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { log } from "../core/log.js";
|
|
27
|
+
import { audit } from "./audit.js";
|
|
28
|
+
const STATE_DIR = join(homedir(), ".aibroker");
|
|
29
|
+
const TOKEN_FILE = join(STATE_DIR, "todoist-oauth.json");
|
|
30
|
+
const PENDING_FILE = join(STATE_DIR, "todoist-oauth-pending.json");
|
|
31
|
+
/** A pending authorisation is only good for one attempt, and not for long. */
|
|
32
|
+
const PENDING_TTL_MS = 15 * 60 * 1000;
|
|
33
|
+
function writePrivate(file, data) {
|
|
34
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
35
|
+
writeFileSync(file, JSON.stringify(data, null, 2), "utf-8");
|
|
36
|
+
// The token is a bearer credential for the whole account. Group and world
|
|
37
|
+
// have no business reading it, and the default umask is not a guarantee.
|
|
38
|
+
chmodSync(file, 0o600);
|
|
39
|
+
}
|
|
40
|
+
function readJson(file) {
|
|
41
|
+
if (!existsSync(file))
|
|
42
|
+
return null;
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(readFileSync(file, "utf-8"));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// A corrupt file is not a reason to erase it — see core/json-store.
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Mint and record the state for one authorisation attempt. */
|
|
52
|
+
export function beginAuth() {
|
|
53
|
+
const state = randomBytes(16).toString("hex");
|
|
54
|
+
writePrivate(PENDING_FILE, { state, created_at: new Date().toISOString() });
|
|
55
|
+
return state;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Check a callback's state against the recorded one and consume it.
|
|
59
|
+
*
|
|
60
|
+
* Returns a reason when it does not hold up, so the caller can say precisely
|
|
61
|
+
* what was wrong rather than "invalid request".
|
|
62
|
+
*/
|
|
63
|
+
export function consumeState(got) {
|
|
64
|
+
const pending = readJson(PENDING_FILE);
|
|
65
|
+
if (!pending)
|
|
66
|
+
return { ok: false, reason: "no authorisation is in progress — run `aibroker todoist auth` first" };
|
|
67
|
+
if (Date.now() - Date.parse(pending.created_at) > PENDING_TTL_MS) {
|
|
68
|
+
unlinkSync(PENDING_FILE);
|
|
69
|
+
return { ok: false, reason: "the authorisation attempt expired — run `aibroker todoist auth` again" };
|
|
70
|
+
}
|
|
71
|
+
if (!got || got !== pending.state)
|
|
72
|
+
return { ok: false, reason: "state did not match the pending authorisation" };
|
|
73
|
+
unlinkSync(PENDING_FILE);
|
|
74
|
+
return { ok: true };
|
|
75
|
+
}
|
|
76
|
+
export function loadToken() {
|
|
77
|
+
return readJson(TOKEN_FILE);
|
|
78
|
+
}
|
|
79
|
+
export function saveToken(t) {
|
|
80
|
+
writePrivate(TOKEN_FILE, t);
|
|
81
|
+
}
|
|
82
|
+
/** Build the URL a browser has to visit to authorise the app. */
|
|
83
|
+
export function authorizeUrl(clientId, scope, state) {
|
|
84
|
+
const q = new URLSearchParams({ client_id: clientId, scope, state });
|
|
85
|
+
return `https://todoist.com/oauth/authorize?${q.toString()}`;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Trade the authorisation code for a token.
|
|
89
|
+
*
|
|
90
|
+
* Kept separate from the request handler so the exchange can be tested and so
|
|
91
|
+
* the secret is used in exactly one place.
|
|
92
|
+
*/
|
|
93
|
+
export async function exchangeCode(clientId, clientSecret, code, fetchImpl = fetch) {
|
|
94
|
+
const res = await fetchImpl("https://todoist.com/oauth/access_token", {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
97
|
+
body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, code }).toString(),
|
|
98
|
+
});
|
|
99
|
+
const text = await res.text();
|
|
100
|
+
if (!res.ok)
|
|
101
|
+
throw new Error(`token exchange returned ${res.status}: ${text.slice(0, 200)}`);
|
|
102
|
+
let body;
|
|
103
|
+
try {
|
|
104
|
+
body = JSON.parse(text);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
throw new Error(`token exchange returned unparseable body: ${text.slice(0, 200)}`);
|
|
108
|
+
}
|
|
109
|
+
if (body.error)
|
|
110
|
+
throw new Error(`token exchange refused: ${body.error}`);
|
|
111
|
+
if (!body.access_token)
|
|
112
|
+
throw new Error("token exchange returned no access_token");
|
|
113
|
+
return {
|
|
114
|
+
access_token: body.access_token,
|
|
115
|
+
token_type: body.token_type ?? "Bearer",
|
|
116
|
+
scope: body.scope,
|
|
117
|
+
obtained_at: new Date().toISOString(),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function page(title, detail, ok) {
|
|
121
|
+
const accent = ok ? "#2d7a4b" : "#a33";
|
|
122
|
+
return `<!doctype html><meta charset="utf-8"><title>${title}</title>
|
|
123
|
+
<style>
|
|
124
|
+
body{font:16px/1.5 -apple-system,system-ui,sans-serif;margin:0;display:grid;place-items:center;height:100vh;background:#faf9f7;color:#1a1a1a}
|
|
125
|
+
main{max-width:34rem;padding:2rem}
|
|
126
|
+
h1{font-size:1.3rem;margin:0 0 .6rem;color:${accent}}
|
|
127
|
+
p{margin:.4rem 0;color:#444}
|
|
128
|
+
code{background:#eee;padding:.1rem .3rem;border-radius:3px;font-size:.9em}
|
|
129
|
+
@media(prefers-color-scheme:dark){body{background:#141414;color:#eee}p{color:#bbb}code{background:#2a2a2a}}
|
|
130
|
+
</style>
|
|
131
|
+
<main><h1>${title}</h1><p>${detail}</p></main>`;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Handle the redirect Todoist sends the browser to.
|
|
135
|
+
*
|
|
136
|
+
* Always answers with a readable page — this is the one endpoint here a human
|
|
137
|
+
* looks at directly, and "404" taught the last person to debug it nothing.
|
|
138
|
+
*/
|
|
139
|
+
export async function handleOAuthCallback(url, deps) {
|
|
140
|
+
const fail = (status, title, detail, reason) => {
|
|
141
|
+
audit({ action: "todoist-oauth", actor: "todoist", target: "aibroker", outcome: "failed", reason });
|
|
142
|
+
log(`todoist-oauth: ${reason}`);
|
|
143
|
+
return { status, html: page(title, detail, false) };
|
|
144
|
+
};
|
|
145
|
+
const denied = url.searchParams.get("error");
|
|
146
|
+
if (denied) {
|
|
147
|
+
return fail(400, "Authorisation declined", `Todoist reported <code>${denied}</code>. Nothing was changed.`, `authorisation declined by Todoist: ${denied}`);
|
|
148
|
+
}
|
|
149
|
+
const code = url.searchParams.get("code");
|
|
150
|
+
if (!code) {
|
|
151
|
+
return fail(400, "Nothing to exchange", "This is the OAuth landing for the AIBroker Todoist bridge. It expects a <code>code</code> from Todoist; " +
|
|
152
|
+
"opening it directly does nothing. Start with <code>aibroker todoist auth</code>.", "callback carried no code");
|
|
153
|
+
}
|
|
154
|
+
if (!deps.clientId) {
|
|
155
|
+
return fail(500, "Not configured", "Set <code>TODOIST_CLIENT_ID</code> in <code>~/.aibroker/env</code> and restart the daemon — " +
|
|
156
|
+
"the exchange cannot be made without it.", "TODOIST_CLIENT_ID is not set");
|
|
157
|
+
}
|
|
158
|
+
const state = consumeState(url.searchParams.get("state"));
|
|
159
|
+
if (!state.ok) {
|
|
160
|
+
return fail(400, "Unexpected callback", state.reason, `state check failed: ${state.reason}`);
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const token = await exchangeCode(deps.clientId, deps.clientSecret, code, deps.fetchImpl);
|
|
164
|
+
saveToken(token);
|
|
165
|
+
audit({
|
|
166
|
+
action: "todoist-oauth", actor: "todoist", target: "aibroker", outcome: "authorized",
|
|
167
|
+
// The token itself is never recorded — only that one was obtained, and for what.
|
|
168
|
+
meta: { scope: token.scope },
|
|
169
|
+
});
|
|
170
|
+
log(`todoist-oauth: authorised, scope ${token.scope ?? "(unreported)"}`);
|
|
171
|
+
return {
|
|
172
|
+
status: 200,
|
|
173
|
+
html: page("Authorised", `AIBroker can now act on this Todoist account${token.scope ? ` with <code>${token.scope}</code>` : ""}. ` +
|
|
174
|
+
"Webhooks will be delivered from now on. You can close this tab.", true),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
179
|
+
return fail(502, "Exchange failed", reason, reason);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=todoist-oauth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"todoist-oauth.js","sourceRoot":"","sources":["../../src/daemon/todoist-oauth.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon/todoist-owners.ts — which session took which task.
|
|
3
|
+
*
|
|
4
|
+
* A comment on a task is a correction to work already in flight: "actually, do
|
|
5
|
+
* it this way", "that's wrong, try again". It has to reach the session holding
|
|
6
|
+
* that work, and re-deriving the owner from the parent's project and labels is
|
|
7
|
+
* not the same thing. The original may have been addressed in its title — "pai
|
|
8
|
+
* send me a mail" — while the comment says only "make it next month". Re-derive
|
|
9
|
+
* and the correction lands on whatever the project or the default says, which
|
|
10
|
+
* is a different session than the one that did the work.
|
|
11
|
+
*
|
|
12
|
+
* So the owner is remembered at delivery and preferred at comment time.
|
|
13
|
+
*
|
|
14
|
+
* Bounded and on disk: bounded because this is a cache, not a ledger, and on
|
|
15
|
+
* disk because the daemon restarts more often than a conversation ends.
|
|
16
|
+
*/
|
|
17
|
+
/** Record who took a task, so a later comment on it reaches the same session. */
|
|
18
|
+
export declare function rememberOwner(taskId: string, owner: string): void;
|
|
19
|
+
/** Who took this task, if we still know. */
|
|
20
|
+
export declare function ownerOf(taskId: string): string | undefined;
|
|
21
|
+
/** Test seam: forget everything. */
|
|
22
|
+
export declare function forgetAllOwners(): void;
|
|
23
|
+
//# sourceMappingURL=todoist-owners.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"todoist-owners.d.ts","sourceRoot":"","sources":["../../src/daemon/todoist-owners.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AA8BH,iFAAiF;AACjF,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAUjE;AAED,4CAA4C;AAC5C,wBAAgB,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAG1D;AAED,oCAAoC;AACpC,wBAAgB,eAAe,IAAI,IAAI,CAEtC"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon/todoist-owners.ts — which session took which task.
|
|
3
|
+
*
|
|
4
|
+
* A comment on a task is a correction to work already in flight: "actually, do
|
|
5
|
+
* it this way", "that's wrong, try again". It has to reach the session holding
|
|
6
|
+
* that work, and re-deriving the owner from the parent's project and labels is
|
|
7
|
+
* not the same thing. The original may have been addressed in its title — "pai
|
|
8
|
+
* send me a mail" — while the comment says only "make it next month". Re-derive
|
|
9
|
+
* and the correction lands on whatever the project or the default says, which
|
|
10
|
+
* is a different session than the one that did the work.
|
|
11
|
+
*
|
|
12
|
+
* So the owner is remembered at delivery and preferred at comment time.
|
|
13
|
+
*
|
|
14
|
+
* Bounded and on disk: bounded because this is a cache, not a ledger, and on
|
|
15
|
+
* disk because the daemon restarts more often than a conversation ends.
|
|
16
|
+
*/
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { loadJson, saveJson } from "../core/json-store.js";
|
|
20
|
+
const FILE = join(homedir(), ".aibroker", "todoist-owners.json");
|
|
21
|
+
/** Enough for any live conversation; old entries are not worth keeping. */
|
|
22
|
+
const MAX = 500;
|
|
23
|
+
function empty() {
|
|
24
|
+
return { order: [], owners: {} };
|
|
25
|
+
}
|
|
26
|
+
function read() {
|
|
27
|
+
const r = loadJson(FILE);
|
|
28
|
+
// An unreadable cache is not a reason to lose the daemon: start empty and
|
|
29
|
+
// let the next delivery repopulate it. Never erase the file on a parse error
|
|
30
|
+
// — that is the bug core/json-store exists to avoid.
|
|
31
|
+
if (r.status === "ok" && Array.isArray(r.data?.order) && r.data?.owners)
|
|
32
|
+
return r.data;
|
|
33
|
+
return empty();
|
|
34
|
+
}
|
|
35
|
+
/** Record who took a task, so a later comment on it reaches the same session. */
|
|
36
|
+
export function rememberOwner(taskId, owner) {
|
|
37
|
+
if (!taskId || !owner)
|
|
38
|
+
return;
|
|
39
|
+
const s = read();
|
|
40
|
+
if (!(taskId in s.owners))
|
|
41
|
+
s.order.push(taskId);
|
|
42
|
+
s.owners[taskId] = owner;
|
|
43
|
+
while (s.order.length > MAX) {
|
|
44
|
+
const drop = s.order.shift();
|
|
45
|
+
if (drop)
|
|
46
|
+
delete s.owners[drop];
|
|
47
|
+
}
|
|
48
|
+
saveJson(FILE, s);
|
|
49
|
+
}
|
|
50
|
+
/** Who took this task, if we still know. */
|
|
51
|
+
export function ownerOf(taskId) {
|
|
52
|
+
if (!taskId)
|
|
53
|
+
return undefined;
|
|
54
|
+
return read().owners[taskId];
|
|
55
|
+
}
|
|
56
|
+
/** Test seam: forget everything. */
|
|
57
|
+
export function forgetAllOwners() {
|
|
58
|
+
saveJson(FILE, empty());
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=todoist-owners.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"todoist-owners.js","sourceRoot":"","sources":["../../src/daemon/todoist-owners.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAE3D,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,EAAE,EAAE,WAAW,EAAE,qBAAqB,CAAC,CAAC;AAEjE,2EAA2E;AAC3E,MAAM,GAAG,GAAG,GAAG,CAAC;AAQhB,SAAS,KAAK;IACZ,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC;AACnC,CAAC;AAED,SAAS,IAAI;IACX,MAAM,CAAC,GAAG,QAAQ,CAAQ,IAAI,CAAC,CAAC;IAChC,0EAA0E;IAC1E,6EAA6E;IAC7E,qDAAqD;IACrD,IAAI,CAAC,CAAC,MAAM,KAAK,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,MAAM;QAAE,OAAO,CAAC,CAAC,IAAI,CAAC;IACvF,OAAO,KAAK,EAAE,CAAC;AACjB,CAAC;AAED,iFAAiF;AACjF,MAAM,UAAU,aAAa,CAAC,MAAc,EAAE,KAAa;IACzD,IAAI,CAAC,MAAM,IAAI,CAAC,KAAK;QAAE,OAAO;IAC9B,MAAM,CAAC,GAAG,IAAI,EAAE,CAAC;IACjB,IAAI,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,MAAM,CAAC;QAAE,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAChD,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,KAAK,CAAC;IACzB,OAAO,CAAC,CAAC,KAAK,CAAC,MAAM,GAAG,GAAG,EAAE,CAAC;QAC5B,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;QAC7B,IAAI,IAAI;YAAE,OAAO,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAClC,CAAC;IACD,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;AACpB,CAAC;AAED,4CAA4C;AAC5C,MAAM,UAAU,OAAO,CAAC,MAAc;IACpC,IAAI,CAAC,MAAM;QAAE,OAAO,SAAS,CAAC;IAC9B,OAAO,IAAI,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;AAC/B,CAAC;AAED,oCAAoC;AACpC,MAAM,UAAU,eAAe;IAC7B,QAAQ,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;AAC1B,CAAC"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon/todoist-reply.ts — answering on the task you were asked from.
|
|
3
|
+
*
|
|
4
|
+
* A task arriving here is often a question, not a work order, and until now the
|
|
5
|
+
* answer went to a terminal the asker was not looking at. Filing from a watch
|
|
6
|
+
* and reading the reply at a desk is not a channel; it is two half channels.
|
|
7
|
+
*
|
|
8
|
+
* So a session answers on the task itself. The comment carries the agent mark,
|
|
9
|
+
* which `route()` already drops before anything else, so our own writes cannot
|
|
10
|
+
* come back as instructions.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately does NOT complete the task. Completion is the human saying done
|
|
13
|
+
* — the same rule the receiver enforces on the way in — and a question whose
|
|
14
|
+
* answer you have not read yet is not done. Left open, the task keeps its place
|
|
15
|
+
* in the list with a comment indicator; completed, it vanishes from view and
|
|
16
|
+
* the answer with it.
|
|
17
|
+
*/
|
|
18
|
+
export interface ReplyResult {
|
|
19
|
+
commentId: string;
|
|
20
|
+
taskId: string;
|
|
21
|
+
}
|
|
22
|
+
/** What a comment event needs from its parent task to be routable at all. */
|
|
23
|
+
export interface ParentTask {
|
|
24
|
+
content: string;
|
|
25
|
+
projectId: string;
|
|
26
|
+
labels: string[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Fetch the task a comment belongs to.
|
|
30
|
+
*
|
|
31
|
+
* A `note:added` payload carries `item_id` and the comment text and nothing
|
|
32
|
+
* else — no project, no labels. Without them the security boundary cannot be
|
|
33
|
+
* evaluated, so a comment is unroutable until its parent is resolved. This is
|
|
34
|
+
* the lookup that makes replying-by-comment possible at all.
|
|
35
|
+
*/
|
|
36
|
+
export declare function fetchParentTask(taskId: string, fetchImpl?: typeof fetch): Promise<ParentTask>;
|
|
37
|
+
/**
|
|
38
|
+
* Post a comment on a task as the agent.
|
|
39
|
+
*
|
|
40
|
+
* The mark is added here rather than trusted to callers: every path that can
|
|
41
|
+
* write to Todoist has to be echo-safe, and "remember the prefix" is not a
|
|
42
|
+
* safety mechanism.
|
|
43
|
+
*/
|
|
44
|
+
export declare function replyToTask(taskId: string, text: string, fetchImpl?: typeof fetch): Promise<ReplyResult>;
|
|
45
|
+
/**
|
|
46
|
+
* How many OPEN tasks in this project share a title.
|
|
47
|
+
*
|
|
48
|
+
* Two tasks called the same thing are indistinguishable in a list, so a reply
|
|
49
|
+
* posted on one looks — to whoever is watching the other — exactly like being
|
|
50
|
+
* ignored. That cost two full rounds today: comment routing was working
|
|
51
|
+
* perfectly and the answers were landing on a sibling nobody was looking at.
|
|
52
|
+
* Being ignored and being answered elsewhere must not look the same.
|
|
53
|
+
*
|
|
54
|
+
* Returns 1 in the ordinary case (this task alone) and 0 when it cannot tell —
|
|
55
|
+
* a lookup failure must not invent a duplicate warning.
|
|
56
|
+
*/
|
|
57
|
+
export declare function countTasksWithTitle(projectId: string, title: string, fetchImpl?: typeof fetch): Promise<number>;
|
|
58
|
+
//# sourceMappingURL=todoist-reply.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"todoist-reply.d.ts","sourceRoot":"","sources":["../../src/daemon/todoist-reply.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAOH,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,6EAA6E;AAC7E,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;;;GAOG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,MAAM,EACd,SAAS,GAAE,OAAO,KAAa,GAC9B,OAAO,CAAC,UAAU,CAAC,CAqBrB;AAED;;;;;;GAMG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,SAAS,GAAE,OAAO,KAAa,GAC9B,OAAO,CAAC,WAAW,CAAC,CAiCtB;AAQD;;;;;;;;;;;GAWG;AACH,wBAAsB,mBAAmB,CACvC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,SAAS,GAAE,OAAO,KAAa,GAC9B,OAAO,CAAC,MAAM,CAAC,CA4BjB"}
|