@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 +1 -1
- package/src/cli.ts +10 -0
- package/src/commands/readProjectMembers.ts +2 -0
- package/src/commands/uploadFile.ts +183 -0
- package/src/lib/mimeTypes.ts +35 -0
- package/src/lib/resolveProjectId.ts +26 -0
package/package.json
CHANGED
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
|
+
};
|