@helpfeel/cosense-cli 1.16.1 → 1.17.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 CHANGED
@@ -46,7 +46,6 @@ cosense --help
46
46
  - install [direnv](https://direnv.net/)
47
47
  - run `npm install`
48
48
  - install [Codex CLI](https://github.com/openai/codex) and run `codex login`
49
- - modelは `gpt-5.6-sol`、reasoning effortは `high` 以上を推奨
50
49
  - run `claude` or `codex`
51
50
 
52
51
  Claude Codeでこのディレクトリをtrustすると、開発フローで使うpluginが `.claude/settings.json` によりインストールされる。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helpfeel/cosense-cli",
3
- "version": "1.16.1",
3
+ "version": "1.17.0",
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.13.6",
30
+ "@types/node": "24.19.1",
31
31
  "npm-run-all": "4.1.5",
32
- "oxfmt": "0.70.0",
33
- "oxlint": "1.85.0",
32
+ "oxfmt": "0.71.0",
33
+ "oxlint": "1.86.0",
34
34
  "typescript": "7.0.2"
35
35
  },
36
36
  "engines": {
@@ -30,9 +30,6 @@ Usage:
30
30
  HTTPエラー:
31
31
  401/403: 認証・権限が無い。private projectのファイルはproject member権限が必要
32
32
  404: fileIdに対応するファイルが存在しない
33
-
34
- 注記:
35
- 動画ファイルはサーバー側の制限により取得できない場合がある
36
33
  `;
37
34
 
38
35
  interface ParsedArgs {
@@ -76,8 +76,8 @@ const parseArgs = (args: string[]): ParsedArgs => {
76
76
  };
77
77
  };
78
78
 
79
- // request.tsのHttpErrorは401/403でcosense loginを案内するが、GCSのsigned URLへの
80
- // PUT失敗にログイン案内は誤誘導なので専用のエラーメッセージを組み立てる
79
+ // request.tsのHttpErrorは401/403でcosense loginか権限の確認を案内するが、GCSのsigned URLへの
80
+ // PUT失敗にはどちらも誤誘導なので専用のエラーメッセージを組み立てる
81
81
  const uploadToSignedUrl = async (
82
82
  url: string,
83
83
  body: Uint8Array,
@@ -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
- // ページと同じhostのアップロードファイルだけタグ展開する。cross-originのURLに
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 (new URL(url).origin !== pageOrigin) return;
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;
@@ -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);
@@ -3,8 +3,14 @@ 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
 
9
+ const describeCredentialDenied = (credential: Credential): string =>
10
+ credential.type === 'serviceAccount'
11
+ ? 'Service Account key was sent, but the server denied access. Check that the key belongs to the project of the target resource and that this operation is allowed.'
12
+ : 'Personal Access Token was sent, but the server denied access. Check that the account has permission for the target resource and this operation.';
13
+
8
14
  export class HttpError extends Error {
9
15
  readonly status: number;
10
16
  readonly statusText: string;
@@ -16,9 +22,13 @@ export class HttpError extends Error {
16
22
  statusText: string;
17
23
  url: string;
18
24
  body: string;
25
+ credential?: Credential;
19
26
  }) {
20
27
  let message = `HTTP ${params.status} ${params.statusText}\n${params.url}\n${params.body.slice(0, 500)}`;
21
- if (params.status === 401 || params.status === 403) {
28
+ if (params.status === 403 && params.credential) {
29
+ // credential付きの403はtokenの入れ直しでは直らないので、login案内ではなく権限の確認を促す
30
+ message += `\n\n${describeCredentialDenied(params.credential)}`;
31
+ } else if (params.status === 401 || params.status === 403) {
22
32
  let origin: string | undefined;
23
33
  try {
24
34
  origin = new URL(params.url).origin;
@@ -58,28 +68,74 @@ const buildCredentialHeaders = (
58
68
  return headers;
59
69
  };
60
70
 
71
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
72
+
73
+ const MAX_REDIRECTS = 5;
74
+
61
75
  export const requestJson = async (
62
76
  url: string,
63
77
  options?: RequestOptions
64
78
  ): Promise<unknown> => {
65
79
  const headers = buildCredentialHeaders(options?.credential);
66
80
  const method = options?.method ?? 'GET';
67
- const init: RequestInit = { method, headers };
81
+ // Node.jsのfetchは別originへのredirectでもこの独自credential headerを転送するので、自動redirectにしない
82
+ const init: RequestInit = { method, headers, redirect: 'manual' };
68
83
  if (options?.body !== undefined) {
69
84
  headers['Content-Type'] = 'application/json';
70
85
  init.body = JSON.stringify(options.body);
71
86
  }
72
- const res = await fetch(url, init);
73
- if (!res.ok) {
74
- const body = await res.text().catch(() => '');
75
- throw new HttpError({
76
- status: res.status,
77
- statusText: res.statusText,
78
- url,
79
- body
80
- });
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;
81
138
  }
82
- return res.json();
83
139
  };
84
140
 
85
141
  export interface DownloadResult {
@@ -88,8 +144,6 @@ export interface DownloadResult {
88
144
  size: number;
89
145
  }
90
146
 
91
- const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
92
-
93
147
  export const downloadToFile = async (
94
148
  url: string,
95
149
  outputPath: string,
@@ -129,7 +183,8 @@ export const downloadToFile = async (
129
183
  status: res.status,
130
184
  statusText: res.statusText,
131
185
  url,
132
- body
186
+ body,
187
+ credential: options?.credential
133
188
  });
134
189
  }
135
190
  if (!res.body) {
@@ -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 !== fileOrigin) {
18
+ if (!isEquivalentOrigin(origin, fileOrigin)) {
19
19
  throw new Error(
20
20
  `--project origin mismatch: ${origin} (--project) vs ${fileOrigin} (file URL)`
21
21
  );
@@ -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 new URL(url).origin !== origin;
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.origin === origin && existingName.toLowerCase() === projectNameLc
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 (project.origin === origin && project.projectNameLc === projectNameLc) {
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 === 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 === origin) {
292
+ if (isEquivalentOrigin(user.origin, origin)) {
288
293
  return { type: 'personalAccessToken', value: user.token };
289
294
  }
290
295
  }