@helpfeel/cosense-cli 1.11.1 → 1.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helpfeel/cosense-cli",
3
- "version": "1.11.1",
3
+ "version": "1.12.0",
4
4
  "description": "Cosense (旧Scrapbox) のページを読み・調べ・編集するAgent Skill用のCLI",
5
5
  "homepage": "https://github.com/helpfeel/cosense-cli",
6
6
  "license": "MIT",
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,
@@ -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。省略時は拡張子から推定し、不明な拡張子はapplication/octet-streamになる
24
+
25
+ 出力(JSON):
26
+ embedUrl string ページ本文への埋め込みに使うファイルURL
27
+ originalname string アップロードしたファイル名
28
+ contentType string embedUrlが配信するファイルの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 === undefined || 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
85
+ ): Promise<void> => {
86
+ const res = await fetch(url, {
87
+ method: 'PUT',
88
+ headers: { '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が配信するのは保存済みファイルなので、contentTypeはローカル推定よりserver値を優先する
153
+ embedUrl = uploadRequest.embedUrl;
154
+ originalname = uploadRequest.originalname ?? name;
155
+ resultContentType = uploadRequest.contentType ?? contentType;
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,35 @@
1
+ import { extname } from 'node:path';
2
+
3
+ // アップロード時に申告するContent-Typeを拡張子から推定する最小マップ。
4
+ // 網羅せず、外れる型は--content-typeで上書きしてもらう
5
+ const MIME_TYPES: Record<string, string> = {
6
+ png: 'image/png',
7
+ jpg: 'image/jpeg',
8
+ jpeg: 'image/jpeg',
9
+ gif: 'image/gif',
10
+ webp: 'image/webp',
11
+ svg: 'image/svg+xml',
12
+ avif: 'image/avif',
13
+ mp4: 'video/mp4',
14
+ mov: 'video/quicktime',
15
+ webm: 'video/webm',
16
+ mp3: 'audio/mpeg',
17
+ wav: 'audio/wav',
18
+ m4a: 'audio/mp4',
19
+ ogg: 'audio/ogg',
20
+ weba: 'audio/webm',
21
+ aac: 'audio/aac',
22
+ pdf: 'application/pdf',
23
+ txt: 'text/plain',
24
+ md: 'text/markdown',
25
+ csv: 'text/csv',
26
+ tsv: 'text/tab-separated-values',
27
+ json: 'application/json',
28
+ html: 'text/html',
29
+ zip: 'application/zip'
30
+ };
31
+
32
+ export const contentTypeForFile = (filePath: string): string => {
33
+ const ext = extname(filePath).slice(1).toLowerCase();
34
+ return MIME_TYPES[ext] ?? 'application/octet-stream';
35
+ };
@@ -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
+ };