@helpfeel/cosense-cli 1.16.2 → 1.17.1
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/package.json +4 -4
- package/src/commands/browsePageChanges.ts +46 -8
- package/src/lib/fileMarkup.ts +3 -2
- package/src/lib/parseUrl.ts +20 -0
- package/src/lib/request.ts +58 -14
- package/src/lib/resolveFileCredential.ts +2 -2
- package/src/lib/settings.ts +10 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@helpfeel/cosense-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.1",
|
|
4
4
|
"description": "Cosense (旧Scrapbox) のページを読み・調べ・編集するAgent Skillのハーネス",
|
|
5
5
|
"homepage": "https://github.com/helpfeel/cosense-cli",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
"tsx": "4.23.15"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@types/node": "24.19.
|
|
30
|
+
"@types/node": "24.19.2",
|
|
31
31
|
"npm-run-all": "4.1.5",
|
|
32
|
-
"oxfmt": "0.
|
|
33
|
-
"oxlint": "1.
|
|
32
|
+
"oxfmt": "0.72.0",
|
|
33
|
+
"oxlint": "1.87.0",
|
|
34
34
|
"typescript": "7.0.2"
|
|
35
35
|
},
|
|
36
36
|
"engines": {
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
serviceAccountSuffix,
|
|
11
11
|
type UserMap
|
|
12
12
|
} from '../lib/resolveUsers.ts';
|
|
13
|
-
import { resolveCredential } from '../lib/settings.ts';
|
|
13
|
+
import { resolveCredential, type Credential } from '../lib/settings.ts';
|
|
14
14
|
|
|
15
15
|
export const browsePageChangesSummary =
|
|
16
16
|
'ページの編集履歴(commit)をpageId起点で取得し、誰がいつ何をどう変えたかを自然言語で説明する。タイトル変更(リネーム)も検出する';
|
|
@@ -58,6 +58,7 @@ interface Change {
|
|
|
58
58
|
|
|
59
59
|
interface Commit {
|
|
60
60
|
id?: string;
|
|
61
|
+
parentId?: string | null;
|
|
61
62
|
changes?: Change[];
|
|
62
63
|
userId?: string;
|
|
63
64
|
created?: number;
|
|
@@ -65,6 +66,7 @@ interface Commit {
|
|
|
65
66
|
|
|
66
67
|
interface CommitsResponse {
|
|
67
68
|
commits?: Commit[];
|
|
69
|
+
hasMore?: boolean;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
interface ParsedArgs {
|
|
@@ -116,6 +118,46 @@ const resolveUserName = (
|
|
|
116
118
|
|
|
117
119
|
const quote = (text: string | undefined): string => `「${text ?? ''}」`;
|
|
118
120
|
|
|
121
|
+
// 1回の応答は件数と大きさで打ち切られ、続きがある時はhasMoreが真になる
|
|
122
|
+
const fetchAllCommits = async (
|
|
123
|
+
apiUrl: string,
|
|
124
|
+
since: string | undefined,
|
|
125
|
+
credential: Credential | undefined
|
|
126
|
+
): Promise<Commit[]> => {
|
|
127
|
+
const commits: Commit[] = [];
|
|
128
|
+
let head = since;
|
|
129
|
+
let continuing = false;
|
|
130
|
+
const requestedHeads = new Set<string>(since ? [since] : []);
|
|
131
|
+
while (true) {
|
|
132
|
+
const url = head ? `${apiUrl}?head=${encodeURIComponent(head)}` : apiUrl;
|
|
133
|
+
const data = (await requestJson(url, { credential })) as CommitsResponse;
|
|
134
|
+
const batch = data.commits ?? [];
|
|
135
|
+
const first = batch[0];
|
|
136
|
+
// 続きが空だと、取れた分を完全な結果として出してしまうので止める。hasMoreは次の要求が空でない事まで保証しない
|
|
137
|
+
if (continuing && !first) {
|
|
138
|
+
throw new Error(`no commits follow head "${head}"`);
|
|
139
|
+
}
|
|
140
|
+
// headに続かない応答を辿ると、同じ応答を取り続けて終わらないので止める
|
|
141
|
+
if (head && first && first.parentId !== head) {
|
|
142
|
+
throw new Error(`commits do not follow head "${head}"`);
|
|
143
|
+
}
|
|
144
|
+
// spreadで足すと、1回の応答が大きい時に引数の上限を超える
|
|
145
|
+
for (const commit of batch) commits.push(commit);
|
|
146
|
+
if (!data.hasMore) return commits;
|
|
147
|
+
const next = batch.at(-1)?.id;
|
|
148
|
+
if (!next) {
|
|
149
|
+
throw new Error('hasMore is true but the last commit has no id');
|
|
150
|
+
}
|
|
151
|
+
// 親子が循環した応答はparentIdの確認を通るので、同じheadを2度要求しない事で止める
|
|
152
|
+
if (requestedHeads.has(next)) {
|
|
153
|
+
throw new Error(`commit "${next}" appears again as head`);
|
|
154
|
+
}
|
|
155
|
+
requestedHeads.add(next);
|
|
156
|
+
head = next;
|
|
157
|
+
continuing = true;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
119
161
|
// 時系列の変更イベント。同一行への連続updateは1件に畳む
|
|
120
162
|
type ChangeEvent =
|
|
121
163
|
| { kind: 'title'; userIds: string[]; created?: number; title: string }
|
|
@@ -205,18 +247,14 @@ export const browsePageChanges = async (args: string[]): Promise<void> => {
|
|
|
205
247
|
const { projectUrl, pageId, since } = parseArgs(args);
|
|
206
248
|
const { origin, projectName } = parseProjectUrlStrict(projectUrl);
|
|
207
249
|
|
|
208
|
-
|
|
209
|
-
if (since) {
|
|
210
|
-
apiUrl += `?head=${encodeURIComponent(since)}`;
|
|
211
|
-
}
|
|
250
|
+
const apiUrl = `${origin}/api/commits/${projectName}/${pageId}`;
|
|
212
251
|
const credential = resolveCredential(origin, projectName);
|
|
213
252
|
|
|
214
|
-
const [
|
|
215
|
-
|
|
253
|
+
const [commits, userMap] = await Promise.all([
|
|
254
|
+
fetchAllCommits(apiUrl, since, credential),
|
|
216
255
|
fetchUserMap(origin, projectName)
|
|
217
256
|
]);
|
|
218
257
|
|
|
219
|
-
const commits = data.commits ?? [];
|
|
220
258
|
const events = buildEvents(commits);
|
|
221
259
|
|
|
222
260
|
const titleEvents = events.filter(e => e.kind === 'title');
|
package/src/lib/fileMarkup.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isEquivalentOrigin } from './parseUrl.ts';
|
|
1
2
|
import { requestJson } from './request.ts';
|
|
2
3
|
import type { Credential } from './settings.ts';
|
|
3
4
|
|
|
@@ -107,13 +108,13 @@ export const fetchFileMarkups = async (
|
|
|
107
108
|
const gyazos = collectMatches(lines, GYAZO_URL_PATTERN);
|
|
108
109
|
const markups = new Map<string, string>();
|
|
109
110
|
|
|
110
|
-
//
|
|
111
|
+
// ページと同一視できるoriginのアップロードファイルだけタグ展開する。cross-originのURLに
|
|
111
112
|
// ページのcredential(env COSENSE_PATを含む)を送るとPAT漏洩になるため、別host・
|
|
112
113
|
// 不正URL・取得失敗(404・権限なし・非公開Gyazo等)はいずれもURLのまま本文に残す。
|
|
113
114
|
// 従来の出力と同じに劣化するだけで、AIはURLからファイルの存在を認知できる
|
|
114
115
|
const fileTasks = [...files].map(async ([url, fileId]) => {
|
|
115
116
|
try {
|
|
116
|
-
if (
|
|
117
|
+
if (!isEquivalentOrigin(url, pageOrigin)) return;
|
|
117
118
|
const info = (await requestJson(`${pageOrigin}/api/gcs/${fileId}/info`, {
|
|
118
119
|
credential: pageCredential
|
|
119
120
|
})) as FileInfo;
|
package/src/lib/parseUrl.ts
CHANGED
|
@@ -20,6 +20,26 @@ export const parseOrigin = (input: string): string => {
|
|
|
20
20
|
return url.origin;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
// scrapbox.ioとcosen.seはドメイン移行中の同じCosenseで、どちらのURLも同じサーバーに届くのでcredentialを共有してよい
|
|
24
|
+
const EQUIVALENT_HOSTS = new Set(['scrapbox.io', 'cosen.se']);
|
|
25
|
+
|
|
26
|
+
// http(s)のURL同士で、credentialを共有してよいoriginかを判定する
|
|
27
|
+
export const isEquivalentOrigin = (
|
|
28
|
+
a: URL | string,
|
|
29
|
+
b: URL | string
|
|
30
|
+
): boolean => {
|
|
31
|
+
const from = new URL(a);
|
|
32
|
+
const to = new URL(b);
|
|
33
|
+
if (from.protocol !== 'http:' && from.protocol !== 'https:') return false;
|
|
34
|
+
return (
|
|
35
|
+
from.protocol === to.protocol &&
|
|
36
|
+
from.port === to.port &&
|
|
37
|
+
(from.hostname === to.hostname ||
|
|
38
|
+
(EQUIVALENT_HOSTS.has(from.hostname) &&
|
|
39
|
+
EQUIVALENT_HOSTS.has(to.hostname)))
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
23
43
|
export const parseProjectUrl = (input: string): ProjectUrl => {
|
|
24
44
|
const u = new URL(input);
|
|
25
45
|
const parts = u.pathname.split('/').filter(Boolean);
|
package/src/lib/request.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { rename, rm, stat } from 'node:fs/promises';
|
|
|
3
3
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { Readable } from 'node:stream';
|
|
5
5
|
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import { isEquivalentOrigin } from './parseUrl.ts';
|
|
6
7
|
import type { Credential } from './settings.ts';
|
|
7
8
|
|
|
8
9
|
const describeCredentialDenied = (credential: Credential): string =>
|
|
@@ -67,29 +68,74 @@ const buildCredentialHeaders = (
|
|
|
67
68
|
return headers;
|
|
68
69
|
};
|
|
69
70
|
|
|
71
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
72
|
+
|
|
73
|
+
const MAX_REDIRECTS = 5;
|
|
74
|
+
|
|
70
75
|
export const requestJson = async (
|
|
71
76
|
url: string,
|
|
72
77
|
options?: RequestOptions
|
|
73
78
|
): Promise<unknown> => {
|
|
74
79
|
const headers = buildCredentialHeaders(options?.credential);
|
|
75
80
|
const method = options?.method ?? 'GET';
|
|
76
|
-
|
|
81
|
+
// Node.jsのfetchは別originへのredirectでもこの独自credential headerを転送するので、自動redirectにしない
|
|
82
|
+
const init: RequestInit = { method, headers, redirect: 'manual' };
|
|
77
83
|
if (options?.body !== undefined) {
|
|
78
84
|
headers['Content-Type'] = 'application/json';
|
|
79
85
|
init.body = JSON.stringify(options.body);
|
|
80
86
|
}
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
const requestUrl = new URL(url);
|
|
88
|
+
let currentUrl = url;
|
|
89
|
+
for (let redirects = 0; ; redirects++) {
|
|
90
|
+
const res = await fetch(currentUrl, init);
|
|
91
|
+
if (!REDIRECT_STATUSES.has(res.status)) {
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
const body = await res.text().catch(() => '');
|
|
94
|
+
throw new HttpError({
|
|
95
|
+
status: res.status,
|
|
96
|
+
statusText: res.statusText,
|
|
97
|
+
url: currentUrl,
|
|
98
|
+
body,
|
|
99
|
+
credential: options?.credential
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return res.json();
|
|
103
|
+
}
|
|
104
|
+
const location = res.headers.get('location');
|
|
105
|
+
await res.body?.cancel().catch(() => {});
|
|
106
|
+
if (!location) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`HTTP ${res.status} without Location header: ${currentUrl}`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
const redirectUrl = URL.parse(location, currentUrl);
|
|
112
|
+
if (!redirectUrl) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`HTTP ${res.status} with invalid Location header: ${currentUrl}`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (redirectUrl.protocol !== 'http:' && redirectUrl.protocol !== 'https:') {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`HTTP ${res.status} redirect to ${redirectUrl.protocol} URL was not followed: ${currentUrl}`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (!isEquivalentOrigin(requestUrl, redirectUrl)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`HTTP ${res.status} redirect to ${redirectUrl.origin}${redirectUrl.pathname} was not followed because it leaves ${requestUrl.origin}. ` +
|
|
125
|
+
`Retry with URLs on ${redirectUrl.origin}.`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
// GETだけ追うのは、ページ改名の追従(followRename)が改名後のAPI URLへの302で返るため
|
|
129
|
+
if (method !== 'GET') {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`HTTP ${res.status} redirect of ${method} ${currentUrl} is not followed.`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (redirects >= MAX_REDIRECTS) {
|
|
135
|
+
throw new Error(`Too many redirects: ${url}`);
|
|
136
|
+
}
|
|
137
|
+
currentUrl = redirectUrl.href;
|
|
91
138
|
}
|
|
92
|
-
return res.json();
|
|
93
139
|
};
|
|
94
140
|
|
|
95
141
|
export interface DownloadResult {
|
|
@@ -98,8 +144,6 @@ export interface DownloadResult {
|
|
|
98
144
|
size: number;
|
|
99
145
|
}
|
|
100
146
|
|
|
101
|
-
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
102
|
-
|
|
103
147
|
export const downloadToFile = async (
|
|
104
148
|
url: string,
|
|
105
149
|
outputPath: string,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseProjectUrlStrict } from './parseUrl.ts';
|
|
1
|
+
import { isEquivalentOrigin, parseProjectUrlStrict } from './parseUrl.ts';
|
|
2
2
|
import {
|
|
3
3
|
resolveCredential,
|
|
4
4
|
resolveUserCredential,
|
|
@@ -15,7 +15,7 @@ export const resolveFileCredential = (
|
|
|
15
15
|
return resolveUserCredential(fileOrigin);
|
|
16
16
|
}
|
|
17
17
|
const { origin, projectName } = parseProjectUrlStrict(projectUrl);
|
|
18
|
-
if (origin
|
|
18
|
+
if (!isEquivalentOrigin(origin, fileOrigin)) {
|
|
19
19
|
throw new Error(
|
|
20
20
|
`--project origin mismatch: ${origin} (--project) vs ${fileOrigin} (file URL)`
|
|
21
21
|
);
|
package/src/lib/settings.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
|
+
import { isEquivalentOrigin } from './parseUrl.ts';
|
|
4
5
|
|
|
5
6
|
interface ProjectSetting {
|
|
6
7
|
origin: string;
|
|
@@ -204,7 +205,7 @@ export const writeUserToken = (origin: string, token: string): void => {
|
|
|
204
205
|
const url = (entry as { url?: unknown }).url;
|
|
205
206
|
if (typeof url !== 'string') return true;
|
|
206
207
|
try {
|
|
207
|
-
return
|
|
208
|
+
return !isEquivalentOrigin(url, origin);
|
|
208
209
|
} catch {
|
|
209
210
|
return true;
|
|
210
211
|
}
|
|
@@ -233,7 +234,8 @@ export const writeProjectServiceAccount = (
|
|
|
233
234
|
const existingName = parsed.pathname.split('/').filter(Boolean)[0];
|
|
234
235
|
if (!existingName) return true;
|
|
235
236
|
return !(
|
|
236
|
-
parsed
|
|
237
|
+
isEquivalentOrigin(parsed, origin) &&
|
|
238
|
+
existingName.toLowerCase() === projectNameLc
|
|
237
239
|
);
|
|
238
240
|
} catch {
|
|
239
241
|
return true;
|
|
@@ -264,12 +266,15 @@ export const resolveCredential = (
|
|
|
264
266
|
if (!settings) return undefined;
|
|
265
267
|
const projectNameLc = projectName.toLowerCase();
|
|
266
268
|
for (const project of settings.projects) {
|
|
267
|
-
if (
|
|
269
|
+
if (
|
|
270
|
+
isEquivalentOrigin(project.origin, origin) &&
|
|
271
|
+
project.projectNameLc === projectNameLc
|
|
272
|
+
) {
|
|
268
273
|
return { type: 'serviceAccount', value: project.serviceAccount };
|
|
269
274
|
}
|
|
270
275
|
}
|
|
271
276
|
for (const user of settings.users) {
|
|
272
|
-
if (user.origin
|
|
277
|
+
if (isEquivalentOrigin(user.origin, origin)) {
|
|
273
278
|
return { type: 'personalAccessToken', value: user.token };
|
|
274
279
|
}
|
|
275
280
|
}
|
|
@@ -284,7 +289,7 @@ export const resolveUserCredential = (
|
|
|
284
289
|
const settings = loadSettings();
|
|
285
290
|
if (!settings) return undefined;
|
|
286
291
|
for (const user of settings.users) {
|
|
287
|
-
if (user.origin
|
|
292
|
+
if (isEquivalentOrigin(user.origin, origin)) {
|
|
288
293
|
return { type: 'personalAccessToken', value: user.token };
|
|
289
294
|
}
|
|
290
295
|
}
|