@helpfeel/cosense-cli 1.11.1 → 1.12.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/cli.ts +13 -1
- package/src/commands/readProjectMembers.ts +2 -0
- package/src/commands/uploadFile.ts +183 -0
- package/src/lib/mimeTypes.ts +47 -0
- package/src/lib/resolveProjectId.ts +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@helpfeel/cosense-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.1",
|
|
4
4
|
"description": "Cosense (旧Scrapbox) のページを読み・調べ・編集するAgent Skill用のCLI",
|
|
5
5
|
"homepage": "https://github.com/helpfeel/cosense-cli",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,13 +24,13 @@
|
|
|
24
24
|
"format": "oxfmt"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"tsx": "4.23.
|
|
27
|
+
"tsx": "4.23.12"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@types/node": "24.13.3",
|
|
31
31
|
"npm-run-all": "4.1.5",
|
|
32
|
-
"oxfmt": "0.
|
|
33
|
-
"oxlint": "1.
|
|
32
|
+
"oxfmt": "0.62.0",
|
|
33
|
+
"oxlint": "1.77.0",
|
|
34
34
|
"typescript": "7.0.2"
|
|
35
35
|
},
|
|
36
36
|
"engines": {
|
package/src/cli.ts
CHANGED
|
@@ -73,6 +73,11 @@ import {
|
|
|
73
73
|
submitEditHelp,
|
|
74
74
|
submitEditSummary
|
|
75
75
|
} from './commands/submitEdit.ts';
|
|
76
|
+
import {
|
|
77
|
+
uploadFile,
|
|
78
|
+
uploadFileHelp,
|
|
79
|
+
uploadFileSummary
|
|
80
|
+
} from './commands/uploadFile.ts';
|
|
76
81
|
import {
|
|
77
82
|
search1hopLinks,
|
|
78
83
|
search1hopLinksHelp,
|
|
@@ -144,6 +149,11 @@ const commands: Record<string, CommandSpec> = {
|
|
|
144
149
|
summary: downloadFileSummary,
|
|
145
150
|
help: downloadFileHelp
|
|
146
151
|
},
|
|
152
|
+
uploadFile: {
|
|
153
|
+
handler: uploadFile,
|
|
154
|
+
summary: uploadFileSummary,
|
|
155
|
+
help: uploadFileHelp
|
|
156
|
+
},
|
|
147
157
|
readProjectMembers: {
|
|
148
158
|
handler: readProjectMembers,
|
|
149
159
|
summary: readProjectMembersSummary,
|
|
@@ -232,7 +242,9 @@ if (command === '--version') {
|
|
|
232
242
|
process.exit(0);
|
|
233
243
|
}
|
|
234
244
|
|
|
235
|
-
|
|
245
|
+
// cosense constructorやcosense __proto__はObject.prototypeのpropertyを引いてしまう
|
|
246
|
+
const spec =
|
|
247
|
+
command && Object.hasOwn(commands, command) ? commands[command] : undefined;
|
|
236
248
|
if (!spec) {
|
|
237
249
|
process.stderr.write(
|
|
238
250
|
`invalid command${command ? `: ${command}` : ''}\n` +
|
|
@@ -14,6 +14,7 @@ Usage:
|
|
|
14
14
|
<projectUrl> プロジェクトのURL(例: https://scrapbox.io/shokai)
|
|
15
15
|
|
|
16
16
|
戻り値(top-levelの主なkey):
|
|
17
|
+
projectId string projectのID
|
|
17
18
|
users Array<User> 現メンバー一覧
|
|
18
19
|
memberSnapshots Array<Snapshot>? 退去済みメンバーの記録
|
|
19
20
|
serviceAccounts Array<ServiceAccount>? Service Account一覧
|
|
@@ -42,6 +43,7 @@ ServiceAccount の field:
|
|
|
42
43
|
|
|
43
44
|
戻り値のJSON抜粋例:
|
|
44
45
|
{
|
|
46
|
+
"projectId": "58043f...",
|
|
45
47
|
"users": [
|
|
46
48
|
{
|
|
47
49
|
"id": "5724627723541f110097c291",
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { contentTypeForFile } from '../lib/mimeTypes.ts';
|
|
5
|
+
import { parseProjectUrlStrict } from '../lib/parseUrl.ts';
|
|
6
|
+
import { requestJson } from '../lib/request.ts';
|
|
7
|
+
import { resolveProjectId } from '../lib/resolveProjectId.ts';
|
|
8
|
+
import { resolveCredential } from '../lib/settings.ts';
|
|
9
|
+
|
|
10
|
+
export const uploadFileSummary =
|
|
11
|
+
'ファイルをprojectにアップロードして埋め込みURLを取得する';
|
|
12
|
+
|
|
13
|
+
export const uploadFileHelp = `uploadFile - ファイルをprojectにアップロードして埋め込みURLを取得する
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
cosense uploadFile <projectUrl> <filePath> [--content-type <type>]
|
|
17
|
+
|
|
18
|
+
引数:
|
|
19
|
+
<projectUrl> アップロード先projectのURL(例: https://scrapbox.io/example)。末尾に余分なpathがあるとerror
|
|
20
|
+
<filePath> アップロードするローカルファイルのパス
|
|
21
|
+
|
|
22
|
+
オプション:
|
|
23
|
+
--content-type <type> ファイルのMIME type
|
|
24
|
+
|
|
25
|
+
出力(JSON):
|
|
26
|
+
embedUrl string ページ本文への埋め込みに使うファイルURL
|
|
27
|
+
originalname string アップロードしたファイル名
|
|
28
|
+
contentType string? ファイルのMIME type
|
|
29
|
+
size number ファイルのbyte数
|
|
30
|
+
|
|
31
|
+
例:
|
|
32
|
+
cosense uploadFile 'https://scrapbox.io/example' ./photo.png
|
|
33
|
+
cosense uploadFile 'https://scrapbox.io/example' ./data.bin --content-type application/x-foo
|
|
34
|
+
|
|
35
|
+
HTTPエラー:
|
|
36
|
+
401/403: 認証・権限が無い。projectのmember権限(またはprojectのService Account)が必要
|
|
37
|
+
402: projectのファイル容量上限を超えている
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
interface ParsedArgs {
|
|
41
|
+
projectUrl: string;
|
|
42
|
+
filePath: string;
|
|
43
|
+
contentType?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const parseArgs = (args: string[]): ParsedArgs => {
|
|
47
|
+
const usage =
|
|
48
|
+
'Usage: cosense uploadFile <projectUrl> <filePath> [--content-type <type>]';
|
|
49
|
+
let contentType: string | undefined;
|
|
50
|
+
const positional: string[] = [];
|
|
51
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
52
|
+
const arg = args[i] as string;
|
|
53
|
+
if (arg === '--content-type') {
|
|
54
|
+
if (contentType !== undefined) {
|
|
55
|
+
throw new Error(`--content-type specified multiple times\n${usage}`);
|
|
56
|
+
}
|
|
57
|
+
const value = args[i + 1];
|
|
58
|
+
if (!value || value.startsWith('--')) {
|
|
59
|
+
throw new Error(`--content-type requires a value\n${usage}`);
|
|
60
|
+
}
|
|
61
|
+
contentType = value;
|
|
62
|
+
i += 1;
|
|
63
|
+
} else if (arg.startsWith('--')) {
|
|
64
|
+
throw new Error(`Unknown option: ${arg}\n${usage}`);
|
|
65
|
+
} else {
|
|
66
|
+
positional.push(arg);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (positional.length !== 2) {
|
|
70
|
+
throw new Error(usage);
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
projectUrl: positional[0] as string,
|
|
74
|
+
filePath: positional[1] as string,
|
|
75
|
+
contentType
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// request.tsのHttpErrorは401/403でcosense loginを案内するが、GCSのsigned URLへの
|
|
80
|
+
// PUT失敗にログイン案内は誤誘導なので専用のエラーメッセージを組み立てる
|
|
81
|
+
const uploadToSignedUrl = async (
|
|
82
|
+
url: string,
|
|
83
|
+
body: Uint8Array,
|
|
84
|
+
contentType: string | undefined
|
|
85
|
+
): Promise<void> => {
|
|
86
|
+
const res = await fetch(url, {
|
|
87
|
+
method: 'PUT',
|
|
88
|
+
headers: contentType ? { 'Content-Type': contentType } : {},
|
|
89
|
+
body
|
|
90
|
+
});
|
|
91
|
+
if (res.ok) {
|
|
92
|
+
await res.body?.cancel();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const text = await res.text().catch(() => '');
|
|
96
|
+
let message = `Upload to signed URL failed: HTTP ${res.status} ${res.statusText}\n${text.slice(0, 500)}`;
|
|
97
|
+
if (res.status === 403) {
|
|
98
|
+
message += '\n\nしばらく待ってから再実行すると成功する事がある';
|
|
99
|
+
}
|
|
100
|
+
throw new Error(message);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
interface UploadRequestResponse {
|
|
104
|
+
embedUrl?: string;
|
|
105
|
+
originalname?: string;
|
|
106
|
+
contentType?: string;
|
|
107
|
+
signedUrl?: string;
|
|
108
|
+
fileId?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface VerifyResponse {
|
|
112
|
+
embedUrl?: string;
|
|
113
|
+
originalname?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const uploadFile = async (args: string[]): Promise<void> => {
|
|
117
|
+
const { projectUrl, filePath, contentType: contentTypeArg } = parseArgs(args);
|
|
118
|
+
const { origin, projectName } = parseProjectUrlStrict(projectUrl);
|
|
119
|
+
const credential = resolveCredential(origin, projectName);
|
|
120
|
+
if (!credential) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`No credential found for ${origin}/${projectName}. Run \`cosense login ${origin}/${projectName}\` to authenticate.`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const fileStat = await stat(filePath).catch(() => null);
|
|
127
|
+
if (!fileStat?.isFile()) {
|
|
128
|
+
throw new Error(`<filePath> is not a file: ${filePath}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const body = await readFile(filePath);
|
|
132
|
+
const md5 = createHash('md5').update(body).digest('hex');
|
|
133
|
+
const contentType = contentTypeArg ?? contentTypeForFile(filePath);
|
|
134
|
+
const name = basename(filePath);
|
|
135
|
+
|
|
136
|
+
const projectId = await resolveProjectId(origin, projectName);
|
|
137
|
+
|
|
138
|
+
const uploadRequest = (await requestJson(
|
|
139
|
+
`${origin}/api/gcs/${projectId}/upload-request`,
|
|
140
|
+
{
|
|
141
|
+
credential,
|
|
142
|
+
method: 'POST',
|
|
143
|
+
body: { md5, size: body.length, contentType, name }
|
|
144
|
+
}
|
|
145
|
+
)) as UploadRequestResponse;
|
|
146
|
+
|
|
147
|
+
let embedUrl: string | undefined;
|
|
148
|
+
let originalname: string | undefined;
|
|
149
|
+
let resultContentType = contentType;
|
|
150
|
+
if (uploadRequest.embedUrl) {
|
|
151
|
+
// 同一ファイルがアップロード済みの場合、serverはupload-requestで即embedUrlを返す。
|
|
152
|
+
// embedUrlが配信するのは保存済みファイルなので、ローカル推定の型は当てにならない
|
|
153
|
+
embedUrl = uploadRequest.embedUrl;
|
|
154
|
+
originalname = uploadRequest.originalname ?? name;
|
|
155
|
+
resultContentType = uploadRequest.contentType?.trim() || undefined;
|
|
156
|
+
} else {
|
|
157
|
+
const { signedUrl, fileId } = uploadRequest;
|
|
158
|
+
if (!signedUrl || !fileId) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`Unexpected upload-request response: ${JSON.stringify(uploadRequest)}`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
await uploadToSignedUrl(signedUrl, body, contentType);
|
|
164
|
+
const verify = (await requestJson(`${origin}/api/gcs/${projectId}/verify`, {
|
|
165
|
+
credential,
|
|
166
|
+
method: 'POST',
|
|
167
|
+
body: { md5, fileId }
|
|
168
|
+
})) as VerifyResponse;
|
|
169
|
+
embedUrl = verify.embedUrl;
|
|
170
|
+
originalname = verify.originalname ?? name;
|
|
171
|
+
}
|
|
172
|
+
if (!embedUrl) {
|
|
173
|
+
throw new Error('Unexpected response: embedUrl is missing.');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const result = {
|
|
177
|
+
embedUrl,
|
|
178
|
+
originalname,
|
|
179
|
+
contentType: resultContentType,
|
|
180
|
+
size: body.length
|
|
181
|
+
};
|
|
182
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
183
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
|
|
3
|
+
// アップロード時に申告するContent-Typeを拡張子から推定するマップ。網羅はしないが、
|
|
4
|
+
// browsePageが<cosense:file type="...">でファイル種別をAIに伝えられるよう、判明した型は足していく。
|
|
5
|
+
// ここに無い型は--content-typeで上書きしてもらう
|
|
6
|
+
const MIME_TYPES: Record<string, string> = {
|
|
7
|
+
png: 'image/png',
|
|
8
|
+
jpg: 'image/jpeg',
|
|
9
|
+
jpeg: 'image/jpeg',
|
|
10
|
+
gif: 'image/gif',
|
|
11
|
+
webp: 'image/webp',
|
|
12
|
+
svg: 'image/svg+xml',
|
|
13
|
+
avif: 'image/avif',
|
|
14
|
+
mp4: 'video/mp4',
|
|
15
|
+
mov: 'video/quicktime',
|
|
16
|
+
webm: 'video/webm',
|
|
17
|
+
mp3: 'audio/mpeg',
|
|
18
|
+
wav: 'audio/wav',
|
|
19
|
+
m4a: 'audio/mp4',
|
|
20
|
+
ogg: 'audio/ogg',
|
|
21
|
+
weba: 'audio/webm',
|
|
22
|
+
aac: 'audio/aac',
|
|
23
|
+
pdf: 'application/pdf',
|
|
24
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
25
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
26
|
+
xlsm: 'application/vnd.ms-excel.sheet.macroEnabled.12',
|
|
27
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
28
|
+
doc: 'application/msword',
|
|
29
|
+
xls: 'application/vnd.ms-excel',
|
|
30
|
+
ppt: 'application/vnd.ms-powerpoint',
|
|
31
|
+
txt: 'text/plain',
|
|
32
|
+
md: 'text/markdown',
|
|
33
|
+
csv: 'text/csv',
|
|
34
|
+
tsv: 'text/tab-separated-values',
|
|
35
|
+
json: 'application/json',
|
|
36
|
+
html: 'text/html',
|
|
37
|
+
zip: 'application/zip'
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// 推定できない時にapplication/octet-streamを申告しない。octet-streamで申告するとserverが
|
|
41
|
+
// embedUrlから拡張子を落とすため、URLからファイル種別が読み取れなくなり、ページ本文での
|
|
42
|
+
// 埋め込み表示の判定もできなくなる
|
|
43
|
+
export const contentTypeForFile = (filePath: string): string | undefined => {
|
|
44
|
+
const ext = extname(filePath).slice(1).toLowerCase();
|
|
45
|
+
// a.constructorやa.__proto__はObject.prototypeのpropertyを引いてしまう
|
|
46
|
+
return Object.hasOwn(MIME_TYPES, ext) ? MIME_TYPES[ext] : undefined;
|
|
47
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { requestJson } from './request.ts';
|
|
2
|
+
import { resolveCredential } from './settings.ts';
|
|
3
|
+
|
|
4
|
+
const cache = new Map<string, string>();
|
|
5
|
+
|
|
6
|
+
export const resolveProjectId = async (
|
|
7
|
+
origin: string,
|
|
8
|
+
projectName: string
|
|
9
|
+
): Promise<string> => {
|
|
10
|
+
const cacheKey = `${origin}:${projectName.toLowerCase()}`;
|
|
11
|
+
const cached = cache.get(cacheKey);
|
|
12
|
+
if (cached) return cached;
|
|
13
|
+
|
|
14
|
+
const apiUrl = `${origin}/api/projects/${projectName}/users`;
|
|
15
|
+
const credential = resolveCredential(origin, projectName);
|
|
16
|
+
const data = (await requestJson(apiUrl, { credential })) as {
|
|
17
|
+
projectId?: unknown;
|
|
18
|
+
};
|
|
19
|
+
if (typeof data.projectId !== 'string' || data.projectId === '') {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`projectId not found in ${apiUrl} response. The Cosense server may be older than this CLI.`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
cache.set(cacheKey, data.projectId);
|
|
25
|
+
return data.projectId;
|
|
26
|
+
};
|