@wirecat/cli-core 0.411.0 → 0.413.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/CHANGELOG.md +32 -0
- package/dist/cli/bot/command.js +3 -3
- package/dist/cli/bot/messages-sync.d.ts +7 -0
- package/dist/cli/bot/{store.js → messages-sync.js} +4 -5
- package/dist/cli/bot/port.d.ts +3 -3
- package/dist/cli/config-command.js +1 -0
- package/dist/cli/entry.d.ts +59 -0
- package/dist/cli/entry.js +136 -0
- package/dist/cli/first-run.d.ts +1 -1
- package/dist/cli/first-run.js +1 -1
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +1 -0
- package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
- package/dist/cli/messenger/tasks-command.js +2 -2
- package/dist/cli/moved.js +1 -0
- package/dist/cli/settings.d.ts +2 -0
- package/dist/cli/settings.js +5 -1
- package/dist/domain/providers.d.ts +25 -3
- package/dist/domain/providers.js +89 -5
- package/dist/entities/index.d.ts +29 -1
- package/dist/entities/index.js +5 -1
- package/dist/entities/knowledge-lists.d.ts +28 -0
- package/dist/entities/knowledge-lists.js +29 -0
- package/dist/errors/reasons.js +16 -1
- package/dist/files/native-writer-lease.js +17 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/oauth/authorize.d.ts +21 -0
- package/dist/oauth/authorize.js +130 -0
- package/dist/oauth/index.d.ts +5 -0
- package/dist/oauth/index.js +4 -0
- package/dist/oauth/keeper.d.ts +41 -0
- package/dist/oauth/keeper.js +136 -0
- package/dist/oauth/provider.d.ts +54 -0
- package/dist/oauth/provider.js +1 -0
- package/dist/oauth/requests.d.ts +11 -0
- package/dist/oauth/requests.js +129 -0
- package/dist/oauth/storage.d.ts +48 -0
- package/dist/oauth/storage.js +197 -0
- package/dist/search/account-list.d.ts +5 -2
- package/dist/search/account-list.js +27 -11
- package/dist/search/query.js +3 -1
- package/dist/sends/permission-aliases.js +1 -0
- package/dist/services/documents/knowledge.d.ts +5 -3
- package/dist/services/documents/knowledge.js +4 -4
- package/dist/services/meeting-links.d.ts +3 -1
- package/dist/services/meeting-links.js +2 -2
- package/dist/services/messages-search.js +15 -3
- package/dist/services/project-gathering.d.ts +2 -0
- package/dist/services/project-gathering.js +18 -2
- package/dist/services/search-all.js +5 -2
- package/dist/store/accounts.d.ts +3 -0
- package/dist/store/accounts.js +2 -0
- package/dist/store/exports.d.ts +1 -0
- package/dist/store/exports.js +1 -0
- package/dist/store/index.d.ts +3 -1
- package/dist/store/index.js +2 -0
- package/dist/store/knowledge-query.d.ts +6 -0
- package/dist/store/knowledge-query.js +34 -0
- package/dist/store/providers.d.ts +7 -0
- package/dist/store/providers.js +5 -0
- package/dist/store/sqlite/knowledge.d.ts +5 -4
- package/dist/store/sqlite/knowledge.js +50 -17
- package/dist/store/sqlite/mail-rules.d.ts +2 -1
- package/dist/store/sqlite/mail-rules.js +12 -2
- package/dist/store/sqlite/notes.d.ts +11 -5
- package/dist/store/sqlite/notes.js +9 -2
- package/dist/store/sqlite/open-store.js +2 -0
- package/dist/store/sqlite/providers.d.ts +12 -0
- package/dist/store/sqlite/providers.js +45 -0
- package/dist/store/sqlite/tags.d.ts +1 -1
- package/dist/store/sqlite/tags.js +3 -3
- package/package.json +5 -1
- package/parity.json +31 -35
- package/dist/cli/bot/store.d.ts +0 -7
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ExchangeContext, OAuthProvider, RequestContext, TokenSet } from "./provider.js";
|
|
2
|
+
export declare const TOKEN_LOG_EVENT = "oauth.token";
|
|
3
|
+
/** The keys the module's log records carry, for a run log's `allow`; names only, never a token. */
|
|
4
|
+
export declare const TOKEN_LOG_ALLOW: readonly ["provider", "operation", "outcome", "status", "rotated", "pid"];
|
|
5
|
+
/**
|
|
6
|
+
* The token endpoint, form-encoded, for both grants. Nothing from the request or the response reaches a message or
|
|
7
|
+
* a log record: both carry codes and tokens.
|
|
8
|
+
*/
|
|
9
|
+
export declare const requestTokens: (provider: OAuthProvider, params: Record<string, string>, context: ExchangeContext) => Promise<TokenSet>;
|
|
10
|
+
/** Revokes the refresh token at `revokeUrl`. A provider without one, or with another method, declares `revoke`. */
|
|
11
|
+
export declare const revokeToken: (provider: OAuthProvider, tokens: TokenSet, context: RequestContext) => Promise<void>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { CliError, isCliError } from "@wirecat/cli-base";
|
|
2
|
+
const REQUEST_LIMIT_MS = 30_000;
|
|
3
|
+
export const TOKEN_LOG_EVENT = "oauth.token";
|
|
4
|
+
/** The keys the module's log records carry, for a run log's `allow`; names only, never a token. */
|
|
5
|
+
export const TOKEN_LOG_ALLOW = ["provider", "operation", "outcome", "status", "rotated", "pid"];
|
|
6
|
+
const object = (value) => {
|
|
7
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
8
|
+
throw new Error("not an object");
|
|
9
|
+
return value;
|
|
10
|
+
};
|
|
11
|
+
const requestSignal = (signal) => signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_LIMIT_MS)]) : AbortSignal.timeout(REQUEST_LIMIT_MS);
|
|
12
|
+
const cancelledBy = (signal) => {
|
|
13
|
+
if (!signal?.aborted)
|
|
14
|
+
return;
|
|
15
|
+
throw isCliError(signal.reason) ? signal.reason : new CliError("cancelled", "sign-in was cancelled");
|
|
16
|
+
};
|
|
17
|
+
const post = async (url, body, context) => {
|
|
18
|
+
cancelledBy(context.signal);
|
|
19
|
+
try {
|
|
20
|
+
return await context.fetch(url, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
23
|
+
body,
|
|
24
|
+
signal: requestSignal(context.signal),
|
|
25
|
+
redirect: "error",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
cancelledBy(context.signal);
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const clientFields = ({ client }) => ({
|
|
34
|
+
client_id: client.clientId,
|
|
35
|
+
...(client.clientSecret ? { client_secret: client.clientSecret } : {}),
|
|
36
|
+
});
|
|
37
|
+
/**
|
|
38
|
+
* The token endpoint, form-encoded, for both grants. Nothing from the request or the response reaches a message or
|
|
39
|
+
* a log record: both carry codes and tokens.
|
|
40
|
+
*/
|
|
41
|
+
export const requestTokens = async (provider, params, context) => {
|
|
42
|
+
const operation = params.grant_type ?? "unknown";
|
|
43
|
+
const record = (level, fields) => context.log?.[level]({ event: TOKEN_LOG_EVENT, provider: provider.id, operation, pid: process.pid, ...fields });
|
|
44
|
+
const again = `run ${context.signInCommand} again`;
|
|
45
|
+
const response = await post(provider.tokenUrl, new URLSearchParams({ ...params, ...clientFields(context) }), context);
|
|
46
|
+
if (!response) {
|
|
47
|
+
record("warn", { outcome: "no_response" });
|
|
48
|
+
throw new CliError("outcome_unknown", `${provider.name} token request did not finish; ${again} if it keeps failing`, {
|
|
49
|
+
reason: "oauth.no-response",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
record("warn", { outcome: "rejected", status: response.status });
|
|
54
|
+
if (response.status === 429 || response.status >= 500)
|
|
55
|
+
throw new CliError("provider_unavailable", `${provider.name} token request failed (HTTP ${response.status})`, {
|
|
56
|
+
status: response.status,
|
|
57
|
+
retryable: true,
|
|
58
|
+
reason: "oauth.unavailable",
|
|
59
|
+
});
|
|
60
|
+
throw new CliError("authentication_error", `${provider.name} sign-in was refused (HTTP ${response.status}); ${again}`, {
|
|
61
|
+
status: response.status,
|
|
62
|
+
reason: "oauth.expired",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
let body;
|
|
66
|
+
try {
|
|
67
|
+
body = object(await response.json());
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
record("error", { outcome: "unreadable_response", status: response.status });
|
|
71
|
+
throw new CliError("invalid_response", `${provider.name} returned an unreadable token response; ${again}`, {
|
|
72
|
+
reason: "oauth.invalid-response",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const previous = context.previous;
|
|
76
|
+
const refreshToken = typeof body.refresh_token === "string" && body.refresh_token ? body.refresh_token : previous?.refreshToken;
|
|
77
|
+
const expiresIn = body.expires_in;
|
|
78
|
+
if (typeof body.access_token !== "string" ||
|
|
79
|
+
!body.access_token ||
|
|
80
|
+
(expiresIn !== undefined && (typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0)) ||
|
|
81
|
+
(body.scope !== undefined && typeof body.scope !== "string") ||
|
|
82
|
+
(body.token_type !== undefined && typeof body.token_type !== "string")) {
|
|
83
|
+
record("error", { outcome: "invalid_response", status: response.status });
|
|
84
|
+
throw new CliError("invalid_response", `${provider.name} returned an invalid token response; ${again}`, {
|
|
85
|
+
reason: "oauth.invalid-response",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (!refreshToken) {
|
|
89
|
+
record("error", { outcome: "no_refresh_token", status: response.status });
|
|
90
|
+
throw new CliError("authentication_error", `${provider.name} returned no offline refresh token; ${again}`, {
|
|
91
|
+
reason: "oauth.failed",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const scopes = typeof body.scope === "string" ? body.scope.split(/\s+/).filter(Boolean) : (previous?.scopes ?? []);
|
|
95
|
+
const tokenType = typeof body.token_type === "string" ? body.token_type : previous?.tokenType;
|
|
96
|
+
record("info", {
|
|
97
|
+
outcome: "received",
|
|
98
|
+
status: response.status,
|
|
99
|
+
...(previous ? { rotated: refreshToken !== previous.refreshToken } : {}),
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
accessToken: body.access_token,
|
|
103
|
+
refreshToken,
|
|
104
|
+
...(typeof expiresIn === "number" ? { expiresAt: context.now() + expiresIn * 1000 } : {}),
|
|
105
|
+
scopes,
|
|
106
|
+
...(tokenType ? { tokenType } : {}),
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
/** Revokes the refresh token at `revokeUrl`. A provider without one, or with another method, declares `revoke`. */
|
|
110
|
+
export const revokeToken = async (provider, tokens, context) => {
|
|
111
|
+
if (provider.revoke)
|
|
112
|
+
return provider.revoke(tokens, context);
|
|
113
|
+
if (!provider.revokeUrl)
|
|
114
|
+
throw new CliError("validation_error", `${provider.name} declares no way to revoke a sign-in`);
|
|
115
|
+
const response = await post(provider.revokeUrl, new URLSearchParams({ token: tokens.refreshToken, ...clientFields(context) }), context);
|
|
116
|
+
context.log?.[response?.ok ? "info" : "warn"]({
|
|
117
|
+
event: TOKEN_LOG_EVENT,
|
|
118
|
+
provider: provider.id,
|
|
119
|
+
operation: "revoke",
|
|
120
|
+
pid: process.pid,
|
|
121
|
+
outcome: response?.ok ? "revoked" : response ? "rejected" : "no_response",
|
|
122
|
+
...(response ? { status: response.status } : {}),
|
|
123
|
+
});
|
|
124
|
+
if (!response?.ok)
|
|
125
|
+
throw new CliError("outcome_unknown", `${provider.name} did not confirm the revoke`, {
|
|
126
|
+
reason: "oauth.revoke-failed",
|
|
127
|
+
...(response ? { status: response.status } : {}),
|
|
128
|
+
});
|
|
129
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type CredentialSource, type CredentialsOptions } from "@wirecat/cli-base";
|
|
2
|
+
import type { OAuthClient, TokenSet } from "./provider.js";
|
|
3
|
+
export interface StoredTokens {
|
|
4
|
+
tokens: TokenSet;
|
|
5
|
+
/** The app the entry was signed in with, when the entry records it; refresh must use that one. */
|
|
6
|
+
client?: OAuthClient;
|
|
7
|
+
}
|
|
8
|
+
export interface EncodeInput {
|
|
9
|
+
tokens: TokenSet;
|
|
10
|
+
client: OAuthClient;
|
|
11
|
+
/** The entry as stored before, so fields the codec does not own survive a write. */
|
|
12
|
+
previous?: string;
|
|
13
|
+
/** Fields a CLI adds at sign-in, such as drive's `principal`. */
|
|
14
|
+
extra?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
/** Every failure throws a fixed message: a parser's message would quote the secret. */
|
|
17
|
+
export interface TokenCodec {
|
|
18
|
+
decode(raw: string): StoredTokens;
|
|
19
|
+
encode(input: EncodeInput): string;
|
|
20
|
+
}
|
|
21
|
+
export interface TokenStorage {
|
|
22
|
+
/** Names the entry across processes; the lock is keyed by it. */
|
|
23
|
+
readonly key: string;
|
|
24
|
+
/** Throws when the entry cannot be saved, so a sign-in fails before the browser, not after consent. */
|
|
25
|
+
probe(): void;
|
|
26
|
+
read(): StoredTokens | undefined;
|
|
27
|
+
write(input: Omit<EncodeInput, "previous">): void;
|
|
28
|
+
remove(): CredentialSource[];
|
|
29
|
+
}
|
|
30
|
+
export declare const oauthAccount: (providerId: string, profile: string) => string;
|
|
31
|
+
/** The entry every new sign-in writes: account `oauth:<provider>:<profile>` under the CLI's own service. */
|
|
32
|
+
export declare const standardCodec: (providerId: string) => TokenCodec;
|
|
33
|
+
/**
|
|
34
|
+
* drive's entry as drive-cli writes it today: `{principal, clientId, clientSecret?, tokens: {access_token,
|
|
35
|
+
* refresh_token, expiry_date, scope, token_type, …}}`. A write keeps every field it does not own.
|
|
36
|
+
*/
|
|
37
|
+
export declare const driveSessionCodec: TokenCodec;
|
|
38
|
+
/** zm's entry as zoom-cli writes it today: `{accessToken, refreshToken, expiresAt, scopes}`, all four required. */
|
|
39
|
+
export declare const zoomTokenCodec: TokenCodec;
|
|
40
|
+
export interface CredentialStorageOptions {
|
|
41
|
+
/** What the CLI passes `Credentials` today; the same options address the same entry. */
|
|
42
|
+
credentials: CredentialsOptions;
|
|
43
|
+
account: string;
|
|
44
|
+
/** Older accounts read when `account` is empty; a hit is copied to `account` and left in place. */
|
|
45
|
+
legacyAccounts?: readonly string[];
|
|
46
|
+
codec: TokenCodec;
|
|
47
|
+
}
|
|
48
|
+
export declare const credentialStorage: (options: CredentialStorageOptions) => TokenStorage;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { CliError, Credentials, keyringService, } from "@wirecat/cli-base";
|
|
2
|
+
const invalid = () => new CliError("authentication_error", "the saved sign-in cannot be read; sign in again", { reason: "oauth.invalid" });
|
|
3
|
+
const parse = (raw) => {
|
|
4
|
+
let value;
|
|
5
|
+
try {
|
|
6
|
+
value = JSON.parse(raw);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
throw invalid();
|
|
10
|
+
}
|
|
11
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
12
|
+
throw invalid();
|
|
13
|
+
return value;
|
|
14
|
+
};
|
|
15
|
+
const text = (value) => (typeof value === "string" && value ? value : undefined);
|
|
16
|
+
const finite = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
17
|
+
const previousOf = (raw) => {
|
|
18
|
+
if (raw === undefined)
|
|
19
|
+
return {};
|
|
20
|
+
try {
|
|
21
|
+
return parse(raw);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const defined = (value) => Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined));
|
|
28
|
+
export const oauthAccount = (providerId, profile) => {
|
|
29
|
+
if (!profile.trim())
|
|
30
|
+
throw new CliError("validation_error", "name a profile: a sign-in always belongs to one");
|
|
31
|
+
return `oauth:${providerId}:${profile}`;
|
|
32
|
+
};
|
|
33
|
+
/** The entry every new sign-in writes: account `oauth:<provider>:<profile>` under the CLI's own service. */
|
|
34
|
+
export const standardCodec = (providerId) => ({
|
|
35
|
+
decode: (raw) => {
|
|
36
|
+
const value = parse(raw);
|
|
37
|
+
const refreshToken = text(value.refreshToken);
|
|
38
|
+
const clientId = text(value.clientId);
|
|
39
|
+
if (value.version !== 1 || value.provider !== providerId || !refreshToken || !clientId)
|
|
40
|
+
throw invalid();
|
|
41
|
+
if (!Array.isArray(value.scopes) || !value.scopes.every((scope) => typeof scope === "string"))
|
|
42
|
+
throw invalid();
|
|
43
|
+
return {
|
|
44
|
+
client: { clientId },
|
|
45
|
+
tokens: defined({
|
|
46
|
+
accessToken: text(value.accessToken),
|
|
47
|
+
refreshToken,
|
|
48
|
+
expiresAt: finite(value.expiresAt),
|
|
49
|
+
scopes: value.scopes,
|
|
50
|
+
tokenType: text(value.tokenType),
|
|
51
|
+
}),
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
encode: ({ tokens, client, previous, extra }) => JSON.stringify(defined({
|
|
55
|
+
...previousOf(previous),
|
|
56
|
+
...extra,
|
|
57
|
+
version: 1,
|
|
58
|
+
provider: providerId,
|
|
59
|
+
clientId: client.clientId,
|
|
60
|
+
accessToken: tokens.accessToken,
|
|
61
|
+
refreshToken: tokens.refreshToken,
|
|
62
|
+
expiresAt: tokens.expiresAt,
|
|
63
|
+
scopes: tokens.scopes,
|
|
64
|
+
tokenType: tokens.tokenType,
|
|
65
|
+
})),
|
|
66
|
+
});
|
|
67
|
+
/**
|
|
68
|
+
* drive's entry as drive-cli writes it today: `{principal, clientId, clientSecret?, tokens: {access_token,
|
|
69
|
+
* refresh_token, expiry_date, scope, token_type, …}}`. A write keeps every field it does not own.
|
|
70
|
+
*/
|
|
71
|
+
export const driveSessionCodec = {
|
|
72
|
+
decode: (raw) => {
|
|
73
|
+
const value = parse(raw);
|
|
74
|
+
const tokens = value.tokens;
|
|
75
|
+
if (typeof tokens !== "object" || tokens === null || Array.isArray(tokens))
|
|
76
|
+
throw invalid();
|
|
77
|
+
const fields = tokens;
|
|
78
|
+
const refreshToken = text(fields.refresh_token);
|
|
79
|
+
const clientId = text(value.clientId);
|
|
80
|
+
if (!refreshToken || !clientId || typeof value.principal !== "string")
|
|
81
|
+
throw invalid();
|
|
82
|
+
return {
|
|
83
|
+
client: defined({ clientId, clientSecret: text(value.clientSecret) }),
|
|
84
|
+
tokens: defined({
|
|
85
|
+
accessToken: text(fields.access_token),
|
|
86
|
+
refreshToken,
|
|
87
|
+
expiresAt: finite(fields.expiry_date),
|
|
88
|
+
scopes: typeof fields.scope === "string" ? fields.scope.split(/\s+/).filter(Boolean) : [],
|
|
89
|
+
tokenType: text(fields.token_type),
|
|
90
|
+
}),
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
encode: ({ tokens, client, previous, extra }) => {
|
|
94
|
+
const before = previousOf(previous);
|
|
95
|
+
const oldTokens = typeof before.tokens === "object" && before.tokens !== null ? before.tokens : {};
|
|
96
|
+
const session = {
|
|
97
|
+
...before,
|
|
98
|
+
...extra,
|
|
99
|
+
clientId: client.clientId,
|
|
100
|
+
...(client.clientSecret ? { clientSecret: client.clientSecret } : {}),
|
|
101
|
+
tokens: defined({
|
|
102
|
+
...oldTokens,
|
|
103
|
+
access_token: tokens.accessToken,
|
|
104
|
+
refresh_token: tokens.refreshToken,
|
|
105
|
+
expiry_date: tokens.expiresAt,
|
|
106
|
+
...(tokens.scopes.length ? { scope: tokens.scopes.join(" ") } : {}),
|
|
107
|
+
token_type: tokens.tokenType,
|
|
108
|
+
}),
|
|
109
|
+
};
|
|
110
|
+
if (typeof session.principal !== "string")
|
|
111
|
+
throw new CliError("configuration_error", "drive's sign-in entry needs the account's principal");
|
|
112
|
+
return JSON.stringify(session);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
/** zm's entry as zoom-cli writes it today: `{accessToken, refreshToken, expiresAt, scopes}`, all four required. */
|
|
116
|
+
export const zoomTokenCodec = {
|
|
117
|
+
decode: (raw) => {
|
|
118
|
+
const value = parse(raw);
|
|
119
|
+
const accessToken = text(value.accessToken);
|
|
120
|
+
const refreshToken = text(value.refreshToken);
|
|
121
|
+
const expiresAt = finite(value.expiresAt);
|
|
122
|
+
if (!accessToken || !refreshToken || expiresAt === undefined)
|
|
123
|
+
throw invalid();
|
|
124
|
+
if (!Array.isArray(value.scopes) || !value.scopes.every((scope) => typeof scope === "string"))
|
|
125
|
+
throw invalid();
|
|
126
|
+
return { tokens: { accessToken, refreshToken, expiresAt, scopes: value.scopes } };
|
|
127
|
+
},
|
|
128
|
+
encode: ({ tokens }) => {
|
|
129
|
+
if (!tokens.accessToken)
|
|
130
|
+
throw new CliError("invalid_response", "zm's sign-in entry needs an access token");
|
|
131
|
+
return JSON.stringify({
|
|
132
|
+
accessToken: tokens.accessToken,
|
|
133
|
+
refreshToken: tokens.refreshToken,
|
|
134
|
+
expiresAt: tokens.expiresAt ?? 0,
|
|
135
|
+
scopes: tokens.scopes,
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
const keyringFailure = () => new CliError("configuration_error", "could not reach the saved sign-in; use an available keyring or file storage", {
|
|
140
|
+
reason: "oauth.not-saved",
|
|
141
|
+
});
|
|
142
|
+
export const credentialStorage = (options) => {
|
|
143
|
+
if (!options.credentials.keyring)
|
|
144
|
+
throw new CliError("configuration_error", "a sign-in store needs its keyring passed in, never a default");
|
|
145
|
+
const credentials = new Credentials(options.credentials);
|
|
146
|
+
const { account, codec } = options;
|
|
147
|
+
const service = keyringService(options.credentials.service, options.credentials.configDir, options.credentials.isolated);
|
|
148
|
+
const raw = (name) => {
|
|
149
|
+
try {
|
|
150
|
+
return credentials.read(name)?.secret;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
throw keyringFailure();
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const save = (value) => {
|
|
157
|
+
try {
|
|
158
|
+
credentials.write(account, value);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
throw keyringFailure();
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const current = () => {
|
|
165
|
+
const own = raw(account);
|
|
166
|
+
if (own !== undefined)
|
|
167
|
+
return own;
|
|
168
|
+
for (const legacy of options.legacyAccounts ?? []) {
|
|
169
|
+
const found = raw(legacy);
|
|
170
|
+
if (found === undefined)
|
|
171
|
+
continue;
|
|
172
|
+
codec.decode(found);
|
|
173
|
+
save(found);
|
|
174
|
+
return found;
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
};
|
|
178
|
+
return {
|
|
179
|
+
key: `${service}\u0000${account}`,
|
|
180
|
+
probe: () => {
|
|
181
|
+
raw(account);
|
|
182
|
+
},
|
|
183
|
+
read: () => {
|
|
184
|
+
const value = current();
|
|
185
|
+
return value === undefined ? undefined : codec.decode(value);
|
|
186
|
+
},
|
|
187
|
+
write: (input) => save(codec.encode({ ...input, previous: raw(account) })),
|
|
188
|
+
remove: () => {
|
|
189
|
+
try {
|
|
190
|
+
return credentials.remove(account);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
throw keyringFailure();
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
};
|
|
@@ -24,9 +24,12 @@ export interface ListContext {
|
|
|
24
24
|
command?: string;
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* An account as `--source` and a hit name it: a notes folder is `notes:<its id>`, never the store's ulid. The kind is
|
|
29
|
+
* `undefined` for a provider no registry entry names: no search reads it, and `fitting` reports it.
|
|
30
|
+
*/
|
|
28
31
|
export interface ListedAccount extends AccountKey {
|
|
29
|
-
kind: Kind;
|
|
32
|
+
kind: Kind | undefined;
|
|
30
33
|
}
|
|
31
34
|
/** One entry of the list as written, a group's member included, and the accounts it named. */
|
|
32
35
|
export interface ListedEntry {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
|
-
import { FOLDER_PROVIDER, INTERNAL_NOTES, isKind,
|
|
2
|
+
import { FOLDER_PROVIDER, INTERNAL_NOTES, isKind, kindIn, kindOf, providerNamed, providers as registered, UNKNOWN_PROVIDER, } from "../domain/providers.js";
|
|
3
3
|
import { isBotProvider } from "./query.js";
|
|
4
4
|
/** Store-wide: every profile, tg, MAX and memo reads the same groups. */
|
|
5
5
|
export const ACCOUNT_GROUPS = "accountGroups";
|
|
@@ -25,16 +25,20 @@ export const groupMembers = (value) => itemsOf(value).map((item) => item.toLower
|
|
|
25
25
|
? fail(`"${item}" names no account after the colon`)
|
|
26
26
|
: item);
|
|
27
27
|
const describe = (accounts) => accounts.map(({ provider, account, name }) => `${provider}:${account}${name ? ` (${name})` : ""}`).join(", ");
|
|
28
|
-
|
|
28
|
+
/** The app running knows its own provider is a messenger, entry or not. */
|
|
29
|
+
const kindHere = (provider, running) => provider === running.provider ? kindOf(provider) : kindIn(provider);
|
|
30
|
+
const heldOf = (stored, running) => [
|
|
29
31
|
...stored
|
|
30
|
-
.filter(({ provider }) =>
|
|
31
|
-
.map(({ provider, account, name }) => ({ provider, account, name, kind:
|
|
32
|
+
.filter(({ provider }) => kindIn(provider) !== "notes")
|
|
33
|
+
.map(({ provider, account, name }) => ({ provider, account, name, kind: kindHere(provider, running) })),
|
|
32
34
|
{ provider: "notes", account: INTERNAL_NOTES, name: null, kind: "notes" },
|
|
33
35
|
...stored
|
|
34
36
|
.filter(({ provider }) => provider === FOLDER_PROVIDER)
|
|
35
37
|
.map(({ id, name }) => ({ provider: "notes", account: String(id), name, kind: "notes" })),
|
|
36
38
|
];
|
|
37
|
-
const registry = () =>
|
|
39
|
+
const registry = () => registered()
|
|
40
|
+
.map(({ name, aliases }) => (aliases.length ? `${name} (${aliases.join(", ")})` : name))
|
|
41
|
+
.join(", ");
|
|
38
42
|
const providerOf = (prefix, held, { running, profiles }) => {
|
|
39
43
|
const wanted = prefix.toLowerCase();
|
|
40
44
|
if (wanted === running.command?.toLowerCase())
|
|
@@ -57,7 +61,11 @@ const accountOf = (prefix, ref, held, context) => {
|
|
|
57
61
|
const byId = ofProvider.find(({ account }) => account === ref || account === ref.toLowerCase());
|
|
58
62
|
if (byId)
|
|
59
63
|
return byId;
|
|
60
|
-
const keyed = (key) => ofProvider.find(({ account }) => account === key.account) ?? {
|
|
64
|
+
const keyed = (key) => ofProvider.find(({ account }) => account === key.account) ?? {
|
|
65
|
+
...key,
|
|
66
|
+
name: null,
|
|
67
|
+
kind: kindHere(key.provider, context.running),
|
|
68
|
+
};
|
|
61
69
|
const recorded = context.profiles[prefix.toLowerCase()]?.[ref];
|
|
62
70
|
if (recorded?.provider === FOLDER_PROVIDER) {
|
|
63
71
|
const folder = held.find((one) => one.provider === "notes" && one.name === recorded.account);
|
|
@@ -105,7 +113,7 @@ const keyOf = ({ provider, account }) => `${provider}\u0000${account}`;
|
|
|
105
113
|
* the account's name — a bare provider for all its accounts, or a bare kind for every provider of it.
|
|
106
114
|
*/
|
|
107
115
|
export const entriesOfList = (value, context) => {
|
|
108
|
-
const held = heldOf(context.held);
|
|
116
|
+
const held = heldOf(context.held, context.running);
|
|
109
117
|
const entries = [];
|
|
110
118
|
const entry = (item, group) => {
|
|
111
119
|
const colon = item.indexOf(":");
|
|
@@ -161,6 +169,7 @@ const READS = {
|
|
|
161
169
|
notes: "notes — search notes reads them",
|
|
162
170
|
documents: "documents — search notes reads them",
|
|
163
171
|
meetings: "meetings — search all reads them",
|
|
172
|
+
events: "calendar events — no search reads them yet",
|
|
164
173
|
};
|
|
165
174
|
/**
|
|
166
175
|
* The accounts of `kinds` the entries name, and each entry that names none of them with the reason. A search
|
|
@@ -170,16 +179,23 @@ export const fitting = (entries, kinds, what, reasonOf = (kind) => READS[kind])
|
|
|
170
179
|
const chosen = new Map();
|
|
171
180
|
const skipped = [];
|
|
172
181
|
for (const { entry, accounts } of entries) {
|
|
173
|
-
const fits = accounts.filter(({ kind }) => kinds.includes(kind));
|
|
182
|
+
const fits = accounts.filter(({ kind }) => kind !== undefined && kinds.includes(kind));
|
|
174
183
|
for (const { provider, account } of fits)
|
|
175
184
|
if (!chosen.has(keyOf({ provider, account })))
|
|
176
185
|
chosen.set(keyOf({ provider, account }), { provider, account });
|
|
186
|
+
const known = accounts.filter(({ kind }) => kind !== undefined);
|
|
187
|
+
const unknown = accounts.filter(({ kind }) => kind === undefined);
|
|
188
|
+
if (known.length)
|
|
189
|
+
for (const { provider, account } of unknown)
|
|
190
|
+
skipped.push({ source: `${provider}:${account}`, code: "not_found", reason: UNKNOWN_PROVIDER });
|
|
177
191
|
if (fits.length)
|
|
178
192
|
continue;
|
|
179
|
-
const other =
|
|
180
|
-
skipped.push(other
|
|
193
|
+
const other = known[0];
|
|
194
|
+
skipped.push(other?.kind
|
|
181
195
|
? { source: entry, code: "validation_error", reason: reasonOf(other.kind) }
|
|
182
|
-
:
|
|
196
|
+
: unknown.length
|
|
197
|
+
? { source: entry, code: "not_found", reason: UNKNOWN_PROVIDER }
|
|
198
|
+
: { source: entry, code: "not_found", reason: "no account in the store" });
|
|
183
199
|
}
|
|
184
200
|
if (chosen.size === 0)
|
|
185
201
|
fail(`--source names nothing ${what} reads: ${skipped.map(({ source, reason }) => `${source} (${reason})`).join(", ")}`);
|
package/dist/search/query.js
CHANGED
|
@@ -27,7 +27,9 @@ export const sourceOf = (name, value, providers) => {
|
|
|
27
27
|
return source;
|
|
28
28
|
if (isKind(source))
|
|
29
29
|
fail(`${name}${source} names ${source}, which a message search does not read — ` +
|
|
30
|
-
|
|
30
|
+
(source === "events"
|
|
31
|
+
? "no search reads them yet"
|
|
32
|
+
: `search ${source === "meetings" ? "all --source meetings" : "notes"} does`));
|
|
31
33
|
const named = providerNamed(source);
|
|
32
34
|
if (named && providers.includes(named.name))
|
|
33
35
|
return named.name;
|
|
@@ -28,6 +28,7 @@ const RENAMED = [
|
|
|
28
28
|
["conversations.batches.next", "conversations.batches.show"],
|
|
29
29
|
["conversations.consents.revoke", "conversations.consents.remove"],
|
|
30
30
|
["stats.messages.counters.refresh", "stats.messages.counters.sync"],
|
|
31
|
+
["store.fetch", "messages.sync"],
|
|
31
32
|
["store.gaps.plan", "store.gaps.show"],
|
|
32
33
|
["store.reindex", "store.rebuild"],
|
|
33
34
|
];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { QueryAst } from "@wirecat/cli-base/query";
|
|
2
|
-
import type { Author, Fact, Link, LinkInput, Memory, MemoryInput, MessageStore, Note } from "../../store/index.js";
|
|
2
|
+
import type { Author, Fact, Link, LinkInput, LinkListFilter, Memory, MemoryInput, MessageStore, Note } from "../../store/index.js";
|
|
3
3
|
export interface DocumentKnowledge {
|
|
4
4
|
documents(principal: string, query: QueryAst | undefined, page: {
|
|
5
5
|
limit: number;
|
|
@@ -9,8 +9,10 @@ export interface DocumentKnowledge {
|
|
|
9
9
|
hasMore: boolean;
|
|
10
10
|
}>;
|
|
11
11
|
document(principal: string, ref: string): Promise<Note>;
|
|
12
|
-
tags(principal: string, ref: string, action: "list" | "add" | "remove", tags?: string[]
|
|
13
|
-
|
|
12
|
+
tags(principal: string, ref: string, action: "list" | "add" | "remove", tags?: string[], filter?: {
|
|
13
|
+
tag?: string;
|
|
14
|
+
}): Promise<string[]>;
|
|
15
|
+
links(principal: string, ref: string, filter?: Omit<LinkListFilter, "from" | "to">): Promise<Link[]>;
|
|
14
16
|
addLink(principal: string, ref: string, input: Omit<LinkInput, "from" | "origin">): Promise<Link>;
|
|
15
17
|
removeLink(principal: string, ref: string, id: string): Promise<unknown>;
|
|
16
18
|
notes(principal: string, ref: string, options?: {
|
|
@@ -47,18 +47,18 @@ export const documentKnowledge = (store, folder) => {
|
|
|
47
47
|
return folderId ? store.notes.importedDocuments(folderId, query, page) : { items: [], hasMore: false };
|
|
48
48
|
},
|
|
49
49
|
document,
|
|
50
|
-
tags: async (principal, ref, action, tags = []) => {
|
|
50
|
+
tags: async (principal, ref, action, tags = [], filter) => {
|
|
51
51
|
const note = await document(principal, ref);
|
|
52
52
|
const target = { type: "document", id: note.id };
|
|
53
53
|
if (action === "add")
|
|
54
54
|
return store.knowledge.addTags(null, target, tags);
|
|
55
55
|
if (action === "remove")
|
|
56
56
|
return store.knowledge.removeTags(null, target, tags);
|
|
57
|
-
return store.knowledge.tags(null, target);
|
|
57
|
+
return store.knowledge.tags(null, target, filter?.tag);
|
|
58
58
|
},
|
|
59
|
-
links: async (principal, ref) => {
|
|
59
|
+
links: async (principal, ref, filter) => {
|
|
60
60
|
await document(principal, ref);
|
|
61
|
-
return store.notes.links({ from: ref });
|
|
61
|
+
return store.notes.links({ ...filter, from: ref });
|
|
62
62
|
},
|
|
63
63
|
addLink: async (principal, ref, input) => {
|
|
64
64
|
await document(principal, ref);
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { MeetingChatLinesInput, MeetingHitSearchRequest, MeetingLink } from "../meetings/index.js";
|
|
2
2
|
import type { MessageStore } from "../store/store.js";
|
|
3
3
|
export type { MeetingLink };
|
|
4
|
-
export type MeetingLinksRequest = Omit<MeetingHitSearchRequest, "limit" | "after" | "maxReadBytes"> & Pick<MeetingChatLinesInput, "maxMessages"
|
|
4
|
+
export type MeetingLinksRequest = Omit<MeetingHitSearchRequest, "limit" | "after" | "maxReadBytes"> & Pick<MeetingChatLinesInput, "maxMessages"> & {
|
|
5
|
+
url?: string;
|
|
6
|
+
};
|
|
5
7
|
/** Links shared in the chat of the selected meetings, most shared first; found when read, not stored. */
|
|
6
8
|
export declare const readMeetingLinks: (store: Pick<MessageStore, "meetings"> & {
|
|
7
9
|
knowledge?: MessageStore["knowledge"];
|
|
@@ -3,7 +3,7 @@ import { findMentions } from "../domain/mentions.js";
|
|
|
3
3
|
import { resolveMeetingSelection } from "./meeting-hit-search.js";
|
|
4
4
|
/** Links shared in the chat of the selected meetings, most shared first; found when read, not stored. */
|
|
5
5
|
export const readMeetingLinks = async (store, request) => {
|
|
6
|
-
const { maxMessages, ...selecting } = request;
|
|
6
|
+
const { maxMessages, url, ...selecting } = request;
|
|
7
7
|
const { input, selection } = await resolveMeetingSelection(store, selecting);
|
|
8
8
|
const lines = await store.meetings.meetingChatLines({
|
|
9
9
|
...input,
|
|
@@ -12,7 +12,7 @@ export const readMeetingLinks = async (store, request) => {
|
|
|
12
12
|
const links = new Map();
|
|
13
13
|
for (const line of lines)
|
|
14
14
|
for (const found of findMentions(line.text)) {
|
|
15
|
-
if (found.kind !== "url")
|
|
15
|
+
if (found.kind !== "url" || (url !== undefined && found.value !== url))
|
|
16
16
|
continue;
|
|
17
17
|
const link = links.get(found.value) ?? {
|
|
18
18
|
url: found.value,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { calendarKey } from "../domain/calendar.js";
|
|
3
3
|
import { parseLocator } from "../domain/locator.js";
|
|
4
|
+
import { kindIn, UNKNOWN_PROVIDER } from "../domain/providers.js";
|
|
4
5
|
import { syncsOneChat } from "../ports/traits.js";
|
|
5
6
|
import { PRESET_VERSION } from "../search/lucene/presets.js";
|
|
6
7
|
import { FIELD_VERSION, validateAst, validateFields } from "../search/lucene/registry.js";
|
|
@@ -69,13 +70,24 @@ export const prepareLucene = async (store, account, request, messenger) => {
|
|
|
69
70
|
throw new CliError("validation_error", "a search names at least one account");
|
|
70
71
|
if (request.senders !== undefined && leaves.some(({ field }) => field === "from"))
|
|
71
72
|
throw new CliError("validation_error", "--from and from: together — name the people once");
|
|
72
|
-
const
|
|
73
|
+
const chosen = request.accounts ??
|
|
73
74
|
listed?.accounts ??
|
|
74
75
|
(source !== undefined
|
|
75
76
|
? held.filter(({ provider }) => inSource(source, provider))
|
|
76
77
|
: sources.length
|
|
77
78
|
? held.filter(({ provider }) => sources.some((source) => inSource(source, provider)))
|
|
78
|
-
: [account])
|
|
79
|
+
: [account]);
|
|
80
|
+
// The account running is the app's own: its provider is a messenger whether or not an entry names it.
|
|
81
|
+
const unknown = chosen.filter(({ provider }) => provider !== account.provider && kindIn(provider) === undefined);
|
|
82
|
+
const unknownSkipped = unknown.map(({ provider, account }) => ({
|
|
83
|
+
source: `${provider}:${account}`,
|
|
84
|
+
code: "not_found",
|
|
85
|
+
reason: UNKNOWN_PROVIDER,
|
|
86
|
+
}));
|
|
87
|
+
if (unknown.length > 0 && unknown.length === chosen.length)
|
|
88
|
+
throw new CliError("not_found", `this search reads nothing: ${unknownSkipped.map(({ source }) => source).join(", ")} (${UNKNOWN_PROVIDER})`);
|
|
89
|
+
const skipped = [...(listed?.skipped ?? []), ...unknownSkipped];
|
|
90
|
+
const accounts = accountsOfKind(request.kind, chosen.filter((one) => !unknown.includes(one)), held, source ?? (sources.includes("email") ? "email" : sources[0]), messenger.app?.command, request.mailAccount, listed !== undefined || request.accounts !== undefined);
|
|
79
91
|
const scopeAccounts = accounts.map(({ provider, account }) => ({ provider, account }));
|
|
80
92
|
const chatLookup = scopeAccounts.some(({ provider }) => provider === MAIL) ? withMailThreads(store) : store;
|
|
81
93
|
const mailField = leaves.find(({ field, value }) => MAIL_FIELDS.has(field) &&
|
|
@@ -141,7 +153,7 @@ export const prepareLucene = async (store, account, request, messenger) => {
|
|
|
141
153
|
stemsReady,
|
|
142
154
|
...(stemmer ? { stemming: stemmingOf(ast.root, stemmer) } : unstemmed ? { stemming: unstemmed } : {}),
|
|
143
155
|
scopeAccounts,
|
|
144
|
-
...(
|
|
156
|
+
...(skipped.length ? { skipped } : {}),
|
|
145
157
|
...(selectedChat ? { selectedChat } : {}),
|
|
146
158
|
};
|
|
147
159
|
};
|
|
@@ -77,6 +77,8 @@ export declare const gatherProjectEvidence: (ports: ProjectGatheringPorts, reque
|
|
|
77
77
|
source: number | null;
|
|
78
78
|
lane: string | null;
|
|
79
79
|
state: "in-scope" | "out-of-scope" | "unavailable";
|
|
80
|
+
items: number;
|
|
81
|
+
capped: boolean;
|
|
80
82
|
}[];
|
|
81
83
|
fetch: {
|
|
82
84
|
state: ProjectFetchState;
|