@helpfeel/cosense-cli 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/bin/cosense +3 -0
- package/package.json +43 -0
- package/src/cli.ts +214 -0
- package/src/commands/browsePage.ts +324 -0
- package/src/commands/browseRelatedPages.ts +79 -0
- package/src/commands/list1hopLinks.ts +66 -0
- package/src/commands/list2hopLinks.ts +53 -0
- package/src/commands/listPages.ts +164 -0
- package/src/commands/listProjects.ts +67 -0
- package/src/commands/login.ts +102 -0
- package/src/commands/previewEdit.ts +349 -0
- package/src/commands/readPage.ts +148 -0
- package/src/commands/readProjectMembers.ts +89 -0
- package/src/commands/search1hopLinks.ts +43 -0
- package/src/commands/search2hopLinks.ts +43 -0
- package/src/commands/searchFullText.ts +85 -0
- package/src/commands/searchVector.ts +86 -0
- package/src/commands/submitEdit.ts +73 -0
- package/src/commands/whoami.ts +48 -0
- package/src/lib/annotateRelations.ts +93 -0
- package/src/lib/encodeTitle.ts +13 -0
- package/src/lib/enrichTimestamps.ts +21 -0
- package/src/lib/formatTimestamp.ts +55 -0
- package/src/lib/parseUrl.ts +60 -0
- package/src/lib/relatedPages.ts +15 -0
- package/src/lib/relatedPagesFormat.ts +77 -0
- package/src/lib/request.ts +72 -0
- package/src/lib/resolveUsers.ts +107 -0
- package/src/lib/settings.ts +258 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export class HttpError extends Error {
|
|
2
|
+
readonly status: number;
|
|
3
|
+
readonly statusText: string;
|
|
4
|
+
readonly url: string;
|
|
5
|
+
readonly body: string;
|
|
6
|
+
|
|
7
|
+
constructor(params: {
|
|
8
|
+
status: number;
|
|
9
|
+
statusText: string;
|
|
10
|
+
url: string;
|
|
11
|
+
body: string;
|
|
12
|
+
}) {
|
|
13
|
+
let message = `HTTP ${params.status} ${params.statusText}\n${params.url}\n${params.body.slice(0, 500)}`;
|
|
14
|
+
if (params.status === 401 || params.status === 403) {
|
|
15
|
+
let origin: string | undefined;
|
|
16
|
+
try {
|
|
17
|
+
origin = new URL(params.url).origin;
|
|
18
|
+
} catch {
|
|
19
|
+
origin = undefined;
|
|
20
|
+
}
|
|
21
|
+
if (origin) {
|
|
22
|
+
message += `\n\nRun \`cosense login ${origin}\` to authenticate.`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'HttpError';
|
|
27
|
+
this.status = params.status;
|
|
28
|
+
this.statusText = params.statusText;
|
|
29
|
+
this.url = params.url;
|
|
30
|
+
this.body = params.body;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
import type { Credential } from './settings.ts';
|
|
35
|
+
|
|
36
|
+
interface RequestOptions {
|
|
37
|
+
credential?: Credential;
|
|
38
|
+
method?: 'GET' | 'POST';
|
|
39
|
+
body?: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const requestJson = async (
|
|
43
|
+
url: string,
|
|
44
|
+
options?: RequestOptions
|
|
45
|
+
): Promise<unknown> => {
|
|
46
|
+
const headers: Record<string, string> = {};
|
|
47
|
+
const credential = options?.credential;
|
|
48
|
+
if (credential) {
|
|
49
|
+
if (credential.type === 'serviceAccount') {
|
|
50
|
+
headers['x-service-account-access-key'] = credential.value;
|
|
51
|
+
} else {
|
|
52
|
+
headers['x-personal-access-token'] = credential.value;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const method = options?.method ?? 'GET';
|
|
56
|
+
const init: RequestInit = { method, headers };
|
|
57
|
+
if (options?.body !== undefined) {
|
|
58
|
+
headers['Content-Type'] = 'application/json';
|
|
59
|
+
init.body = JSON.stringify(options.body);
|
|
60
|
+
}
|
|
61
|
+
const res = await fetch(url, init);
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
const body = await res.text().catch(() => '');
|
|
64
|
+
throw new HttpError({
|
|
65
|
+
status: res.status,
|
|
66
|
+
statusText: res.statusText,
|
|
67
|
+
url,
|
|
68
|
+
body
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return res.json();
|
|
72
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { requestJson } from './request.ts';
|
|
2
|
+
import { resolveCredential } from './settings.ts';
|
|
3
|
+
|
|
4
|
+
interface UserInfo {
|
|
5
|
+
name?: string;
|
|
6
|
+
displayName?: string;
|
|
7
|
+
email?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type UserMap = Map<string, UserInfo>;
|
|
11
|
+
|
|
12
|
+
interface UserEntry {
|
|
13
|
+
id?: unknown;
|
|
14
|
+
name?: unknown;
|
|
15
|
+
displayName?: unknown;
|
|
16
|
+
email?: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface MemberSnapshotEntry {
|
|
20
|
+
data?: UserEntry;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface UsersResponse {
|
|
24
|
+
users?: UserEntry[];
|
|
25
|
+
memberSnapshots?: MemberSnapshotEntry[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const cache = new Map<string, UserMap>();
|
|
29
|
+
|
|
30
|
+
const stringOrUndefined = (value: unknown): string | undefined =>
|
|
31
|
+
typeof value === 'string' && value !== '' ? value : undefined;
|
|
32
|
+
|
|
33
|
+
const buildUserInfo = (entry: UserEntry): UserInfo => {
|
|
34
|
+
const info: UserInfo = {};
|
|
35
|
+
const name = stringOrUndefined(entry.name);
|
|
36
|
+
const displayName = stringOrUndefined(entry.displayName);
|
|
37
|
+
const email = stringOrUndefined(entry.email);
|
|
38
|
+
if (name) info.name = name;
|
|
39
|
+
if (displayName) info.displayName = displayName;
|
|
40
|
+
if (email) info.email = email;
|
|
41
|
+
return info;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const fetchUserMap = async (
|
|
45
|
+
origin: string,
|
|
46
|
+
projectName: string
|
|
47
|
+
): Promise<UserMap> => {
|
|
48
|
+
const cacheKey = `${origin}:${projectName.toLowerCase()}`;
|
|
49
|
+
const cached = cache.get(cacheKey);
|
|
50
|
+
if (cached) return cached;
|
|
51
|
+
|
|
52
|
+
const apiUrl = `${origin}/api/projects/${projectName}/users`;
|
|
53
|
+
const credential = resolveCredential(origin, projectName);
|
|
54
|
+
const data = (await requestJson(apiUrl, { credential })) as UsersResponse;
|
|
55
|
+
|
|
56
|
+
const map: UserMap = new Map();
|
|
57
|
+
for (const entry of data.users ?? []) {
|
|
58
|
+
const id = stringOrUndefined(entry.id);
|
|
59
|
+
if (!id || map.has(id)) continue;
|
|
60
|
+
map.set(id, buildUserInfo(entry));
|
|
61
|
+
}
|
|
62
|
+
for (const snap of data.memberSnapshots ?? []) {
|
|
63
|
+
const entry = snap.data;
|
|
64
|
+
if (!entry) continue;
|
|
65
|
+
const id = stringOrUndefined(entry.id);
|
|
66
|
+
if (!id || map.has(id)) continue;
|
|
67
|
+
map.set(id, buildUserInfo(entry));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
cache.set(cacheKey, map);
|
|
71
|
+
return map;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
interface UserRef {
|
|
75
|
+
id: string;
|
|
76
|
+
name?: string;
|
|
77
|
+
displayName?: string;
|
|
78
|
+
email?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const enrichUser = <T extends { id?: unknown }>(
|
|
82
|
+
user: T | null | undefined,
|
|
83
|
+
userMap: UserMap
|
|
84
|
+
): (T & UserRef) | null | undefined => {
|
|
85
|
+
if (!user) return user;
|
|
86
|
+
const id = stringOrUndefined(user.id);
|
|
87
|
+
if (!id) return user as T & UserRef;
|
|
88
|
+
const info = userMap.get(id);
|
|
89
|
+
if (!info) return user as T & UserRef;
|
|
90
|
+
return Object.assign(user, info) as T & UserRef;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
interface UserRefHolder {
|
|
94
|
+
user?: { id?: unknown } | null;
|
|
95
|
+
lastUpdateUser?: { id?: unknown } | null;
|
|
96
|
+
users?: ({ id?: unknown } | null | undefined)[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const enrichPageUsers = (
|
|
100
|
+
page: UserRefHolder | null | undefined,
|
|
101
|
+
userMap: UserMap
|
|
102
|
+
): void => {
|
|
103
|
+
if (!page) return;
|
|
104
|
+
enrichUser(page.user, userMap);
|
|
105
|
+
enrichUser(page.lastUpdateUser, userMap);
|
|
106
|
+
for (const editor of page.users ?? []) enrichUser(editor, userMap);
|
|
107
|
+
};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
interface ProjectSetting {
|
|
6
|
+
origin: string;
|
|
7
|
+
projectNameLc: string;
|
|
8
|
+
serviceAccount: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface UserSetting {
|
|
12
|
+
origin: string;
|
|
13
|
+
token: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Settings {
|
|
17
|
+
projects: ProjectSetting[];
|
|
18
|
+
users: UserSetting[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type Credential =
|
|
22
|
+
| { type: 'serviceAccount'; value: string }
|
|
23
|
+
| { type: 'personalAccessToken'; value: string };
|
|
24
|
+
|
|
25
|
+
const SETTINGS_PATH = join(homedir(), '.cosense', 'settings.json');
|
|
26
|
+
|
|
27
|
+
let cache: { value: Settings | null } | undefined;
|
|
28
|
+
|
|
29
|
+
const parseProjects = (raw: unknown): ProjectSetting[] => {
|
|
30
|
+
if (raw === undefined) return [];
|
|
31
|
+
if (!Array.isArray(raw)) {
|
|
32
|
+
throw new Error(`${SETTINGS_PATH}: projects must be an array`);
|
|
33
|
+
}
|
|
34
|
+
const result: ProjectSetting[] = [];
|
|
35
|
+
for (const [i, entry] of raw.entries()) {
|
|
36
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
37
|
+
throw new Error(`${SETTINGS_PATH}: projects[${i}] must be an object`);
|
|
38
|
+
}
|
|
39
|
+
const { url, serviceAccount } = entry as {
|
|
40
|
+
url?: unknown;
|
|
41
|
+
serviceAccount?: unknown;
|
|
42
|
+
};
|
|
43
|
+
if (typeof url !== 'string') {
|
|
44
|
+
throw new Error(`${SETTINGS_PATH}: projects[${i}].url must be a string`);
|
|
45
|
+
}
|
|
46
|
+
if (typeof serviceAccount !== 'string') {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`${SETTINGS_PATH}: projects[${i}].serviceAccount must be a string`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (serviceAccount.trim() === '') {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`${SETTINGS_PATH}: projects[${i}].serviceAccount must not be empty`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
let parsed: URL;
|
|
57
|
+
try {
|
|
58
|
+
parsed = new URL(url);
|
|
59
|
+
} catch {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`${SETTINGS_PATH}: projects[${i}].url is not a valid URL: ${url}`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`${SETTINGS_PATH}: projects[${i}].url must use http: or https: scheme: ${url}`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const projectName = parsed.pathname.split('/').filter(Boolean)[0];
|
|
70
|
+
if (!projectName) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`${SETTINGS_PATH}: projects[${i}].url must include a project path: ${url}`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
result.push({
|
|
76
|
+
origin: parsed.origin,
|
|
77
|
+
projectNameLc: projectName.toLowerCase(),
|
|
78
|
+
serviceAccount
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const parseUsers = (raw: unknown): UserSetting[] => {
|
|
85
|
+
if (raw === undefined) return [];
|
|
86
|
+
if (!Array.isArray(raw)) {
|
|
87
|
+
throw new Error(`${SETTINGS_PATH}: users must be an array`);
|
|
88
|
+
}
|
|
89
|
+
const result: UserSetting[] = [];
|
|
90
|
+
for (const [i, entry] of raw.entries()) {
|
|
91
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
92
|
+
throw new Error(`${SETTINGS_PATH}: users[${i}] must be an object`);
|
|
93
|
+
}
|
|
94
|
+
const { url, token } = entry as { url?: unknown; token?: unknown };
|
|
95
|
+
if (typeof url !== 'string') {
|
|
96
|
+
throw new Error(`${SETTINGS_PATH}: users[${i}].url must be a string`);
|
|
97
|
+
}
|
|
98
|
+
if (typeof token !== 'string') {
|
|
99
|
+
throw new Error(`${SETTINGS_PATH}: users[${i}].token must be a string`);
|
|
100
|
+
}
|
|
101
|
+
if (token.trim() === '') {
|
|
102
|
+
throw new Error(`${SETTINGS_PATH}: users[${i}].token must not be empty`);
|
|
103
|
+
}
|
|
104
|
+
let parsed: URL;
|
|
105
|
+
try {
|
|
106
|
+
parsed = new URL(url);
|
|
107
|
+
} catch {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`${SETTINGS_PATH}: users[${i}].url is not a valid URL: ${url}`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`${SETTINGS_PATH}: users[${i}].url must use http: or https: scheme: ${url}`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
result.push({ origin: parsed.origin, token });
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const parseSettings = (raw: unknown): Settings => {
|
|
123
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
124
|
+
throw new Error(`${SETTINGS_PATH}: must be an object`);
|
|
125
|
+
}
|
|
126
|
+
const { projects, users } = raw as {
|
|
127
|
+
projects?: unknown;
|
|
128
|
+
users?: unknown;
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
projects: parseProjects(projects),
|
|
132
|
+
users: parseUsers(users)
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const loadSettings = (): Settings | null => {
|
|
137
|
+
if (cache) return cache.value;
|
|
138
|
+
let raw: string;
|
|
139
|
+
try {
|
|
140
|
+
raw = readFileSync(SETTINGS_PATH, 'utf8');
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
143
|
+
cache = { value: null };
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
let parsed: unknown;
|
|
149
|
+
try {
|
|
150
|
+
parsed = JSON.parse(raw);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`${SETTINGS_PATH}: invalid JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
const value = parseSettings(parsed);
|
|
157
|
+
cache = { value };
|
|
158
|
+
return value;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const writeUserToken = (origin: string, token: string): void => {
|
|
162
|
+
let raw: Record<string, unknown>;
|
|
163
|
+
try {
|
|
164
|
+
const text = readFileSync(SETTINGS_PATH, 'utf8');
|
|
165
|
+
let parsed: unknown;
|
|
166
|
+
try {
|
|
167
|
+
parsed = JSON.parse(text);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`${SETTINGS_PATH}: invalid JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
if (
|
|
174
|
+
typeof parsed !== 'object' ||
|
|
175
|
+
parsed === null ||
|
|
176
|
+
Array.isArray(parsed)
|
|
177
|
+
) {
|
|
178
|
+
throw new Error(`${SETTINGS_PATH}: must be an object`);
|
|
179
|
+
}
|
|
180
|
+
raw = parsed as Record<string, unknown>;
|
|
181
|
+
} catch (err) {
|
|
182
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
183
|
+
raw = {};
|
|
184
|
+
} else {
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const existing = Array.isArray(raw.users) ? (raw.users as unknown[]) : [];
|
|
190
|
+
const filtered = existing.filter(entry => {
|
|
191
|
+
if (typeof entry !== 'object' || entry === null) return true;
|
|
192
|
+
const url = (entry as { url?: unknown }).url;
|
|
193
|
+
if (typeof url !== 'string') return true;
|
|
194
|
+
try {
|
|
195
|
+
return new URL(url).origin !== origin;
|
|
196
|
+
} catch {
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
filtered.push({ url: origin, token });
|
|
201
|
+
raw.users = filtered;
|
|
202
|
+
|
|
203
|
+
const dir = dirname(SETTINGS_PATH);
|
|
204
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
205
|
+
chmodSync(dir, 0o700);
|
|
206
|
+
writeFileSync(SETTINGS_PATH, `${JSON.stringify(raw, null, 2)}\n`, {
|
|
207
|
+
mode: 0o600
|
|
208
|
+
});
|
|
209
|
+
chmodSync(SETTINGS_PATH, 0o600);
|
|
210
|
+
cache = undefined;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export const settingsPath = SETTINGS_PATH;
|
|
214
|
+
|
|
215
|
+
const readEnvPatCredential = (): Credential | undefined => {
|
|
216
|
+
const raw = process.env.COSENSE_PAT;
|
|
217
|
+
if (typeof raw !== 'string') return undefined;
|
|
218
|
+
const value = raw.trim();
|
|
219
|
+
if (value === '') return undefined;
|
|
220
|
+
return { type: 'personalAccessToken', value };
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export const resolveCredential = (
|
|
224
|
+
origin: string,
|
|
225
|
+
projectName: string
|
|
226
|
+
): Credential | undefined => {
|
|
227
|
+
const envCredential = readEnvPatCredential();
|
|
228
|
+
if (envCredential) return envCredential;
|
|
229
|
+
const settings = loadSettings();
|
|
230
|
+
if (!settings) return undefined;
|
|
231
|
+
const projectNameLc = projectName.toLowerCase();
|
|
232
|
+
for (const project of settings.projects) {
|
|
233
|
+
if (project.origin === origin && project.projectNameLc === projectNameLc) {
|
|
234
|
+
return { type: 'serviceAccount', value: project.serviceAccount };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for (const user of settings.users) {
|
|
238
|
+
if (user.origin === origin) {
|
|
239
|
+
return { type: 'personalAccessToken', value: user.token };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export const resolveUserCredential = (
|
|
246
|
+
origin: string
|
|
247
|
+
): Credential | undefined => {
|
|
248
|
+
const envCredential = readEnvPatCredential();
|
|
249
|
+
if (envCredential) return envCredential;
|
|
250
|
+
const settings = loadSettings();
|
|
251
|
+
if (!settings) return undefined;
|
|
252
|
+
for (const user of settings.users) {
|
|
253
|
+
if (user.origin === origin) {
|
|
254
|
+
return { type: 'personalAccessToken', value: user.token };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return undefined;
|
|
258
|
+
};
|