@helpfeel/cosense-cli 1.4.2 → 1.4.4

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.4.2",
3
+ "version": "1.4.4",
4
4
  "description": "Cosense (旧Scrapbox) のページを読み・調べ・編集するAgent Skill用のCLI",
5
5
  "homepage": "https://github.com/helpfeel/cosense-cli",
6
6
  "license": "MIT",
@@ -16,9 +16,9 @@ import {
16
16
  import { resolveCredential } from '../lib/settings.ts';
17
17
 
18
18
  export const browsePageSummary =
19
- '単一ページを読む。メタデータ+アイコン記法+テロメア+本文をAIが読みやすい形式で出力する';
19
+ '単一ページを読む。メタデータ+アイコン記法+テロメア+本文をAIが読みやすい形式で出力する。行permalink (`#<lineId>`) 付きなら該当行をマークする';
20
20
 
21
- export const browsePageHelp = `browsePage - 単一ページを読む。メタデータ+アイコン記法+テロメア+本文をAIが読みやすい形式で出力する
21
+ export const browsePageHelp = `browsePage - 単一ページを読む。メタデータ+アイコン記法+テロメア+本文をAIが読みやすい形式で出力する。行permalink (#<lineId>) 付きなら該当行をマークする
22
22
 
23
23
  Usage:
24
24
  cosense browsePage <pageUrl>
@@ -1,27 +1,32 @@
1
- import { parseOrigin } from '../lib/parseUrl.ts';
2
- import { settingsPath, writeUserToken } from '../lib/settings.ts';
1
+ import { parseOrigin, parseProjectUrlStrict } from '../lib/parseUrl.ts';
2
+ import {
3
+ settingsPath,
4
+ writeProjectServiceAccount,
5
+ writeUserToken
6
+ } from '../lib/settings.ts';
3
7
 
4
- export const loginSummary = 'Personal Access Tokenを設定ファイルに保存する';
8
+ export const loginSummary =
9
+ 'Personal Access Token または Service Account を設定ファイルに保存する';
5
10
 
6
- export const loginHelp = `login - Personal Access Tokenを設定ファイルに保存する
11
+ export const loginHelp = `login - Personal Access Token または Service Account を設定ファイルに保存する
7
12
 
8
13
  Usage:
9
- cosense login <origin>
14
+ cosense login <origin|projectUrl>
10
15
 
11
16
  引数:
12
- <origin> Cosenseサーバーのorigin(例: https://scrapbox.io)
17
+ <origin> Cosenseサーバーのorigin(例: https://scrapbox.io)
18
+ <projectUrl> プロジェクトURL(例: https://scrapbox.io/Nota)
13
19
 
14
20
  動作:
15
- - PAT発行URL(<origin>/settings/personal-access-tokens)を出力し、PAT入力を求める
16
- - 入力されたPATを ~/.cosense/settings.json の users[] に書き込む
17
- - 同じoriginの既存entryは上書きされる
18
- - 設定ファイルとディレクトリは存在しなければ作成する(dir 0700, file 0600)
21
+ - 設定ファイル: ~/.cosense/settings.json(dir 0700, file 0600)
19
22
  - interactive terminal(TTY)でのみ動作する
20
23
 
21
24
  環境変数:
22
25
  COSENSE_PAT 設定されていれば、ファイルに保存された認証情報より優先される
23
26
  `;
24
27
 
28
+ const SERVICE_ACCOUNT_PREFIX = 'cs_';
29
+
25
30
  const readMaskedLine = async (): Promise<string> => {
26
31
  if (!process.stdin.isTTY) {
27
32
  throw new Error(
@@ -68,20 +73,43 @@ const readMaskedLine = async (): Promise<string> => {
68
73
  return chars.join('');
69
74
  };
70
75
 
71
- export const login = async (args: string[]): Promise<void> => {
72
- const [originArg, ...rest] = args;
73
- if (!originArg) throw new Error('Usage: cosense login <origin>');
74
- if (rest.length > 0) {
75
- throw new Error(`Unexpected argument: ${rest[0]}`);
76
- }
77
- const origin = parseOrigin(originArg);
76
+ interface OriginTarget {
77
+ kind: 'origin';
78
+ origin: string;
79
+ }
78
80
 
79
- if (!process.stdin.isTTY) {
81
+ interface ProjectTarget {
82
+ kind: 'project';
83
+ origin: string;
84
+ projectName: string;
85
+ }
86
+
87
+ type LoginTarget = OriginTarget | ProjectTarget;
88
+
89
+ const parseTarget = (input: string): LoginTarget => {
90
+ let url: URL;
91
+ try {
92
+ url = new URL(input);
93
+ } catch {
80
94
  throw new Error(
81
- 'cosense login must be run in an interactive terminal (TTY)'
95
+ `<origin|projectUrl> is not a valid URL: ${input}. ` +
96
+ `例: https://scrapbox.io または https://scrapbox.io/<project>`
97
+ );
98
+ }
99
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
100
+ throw new Error(
101
+ `<origin|projectUrl> must use http: or https: scheme: ${input}`
82
102
  );
83
103
  }
104
+ const parts = url.pathname.split('/').filter(Boolean);
105
+ if (parts.length === 0 && !url.search && !url.hash) {
106
+ return { kind: 'origin', origin: parseOrigin(input) };
107
+ }
108
+ const { origin, projectName } = parseProjectUrlStrict(input);
109
+ return { kind: 'project', origin, projectName };
110
+ };
84
111
 
112
+ const promptOrigin = (origin: string): void => {
85
113
  process.stdout.write(
86
114
  [
87
115
  `Personal Access Token (PAT) を発行する:`,
@@ -91,12 +119,67 @@ export const login = async (args: string[]): Promise<void> => {
91
119
  `PAT: `
92
120
  ].join('\n')
93
121
  );
122
+ };
123
+
124
+ const promptProject = (origin: string, projectName: string): void => {
125
+ process.stdout.write(
126
+ [
127
+ `Personal Access Token (PAT) を発行する:`,
128
+ ` ${origin}/settings/personal-access-tokens`,
129
+ ``,
130
+ `または、Service Account を発行する(あなたが Business Project の管理者である場合):`,
131
+ ` 1. ${origin}/${projectName} の Project Settings から Service Accounts ページを開く`,
132
+ ` 2. "Purpose of using API" に任意の内容を入力して Add`,
133
+ ` 3. 登録された Service Account の "Show Access Key" → Copy`,
134
+ ``,
135
+ `発行した PAT または Service Account を以下に貼り付けて Enter`,
136
+ `(入力は * でマスクされる。Service Account は cs_ で始まる):`,
137
+ `TOKEN: `
138
+ ].join('\n')
139
+ );
140
+ };
141
+
142
+ export const login = async (args: string[]): Promise<void> => {
143
+ const [targetArg, ...rest] = args;
144
+ if (!targetArg) throw new Error('Usage: cosense login <origin|projectUrl>');
145
+ if (rest.length > 0) {
146
+ throw new Error(`Unexpected argument: ${rest[0]}`);
147
+ }
148
+ const target = parseTarget(targetArg);
149
+
150
+ if (!process.stdin.isTTY) {
151
+ throw new Error(
152
+ 'cosense login must be run in an interactive terminal (TTY)'
153
+ );
154
+ }
155
+
156
+ if (target.kind === 'origin') {
157
+ promptOrigin(target.origin);
158
+ } else {
159
+ promptProject(target.origin, target.projectName);
160
+ }
94
161
 
95
162
  const token = (await readMaskedLine()).trim();
96
163
  if (!token) {
97
- throw new Error('PAT is empty');
164
+ throw new Error('Token is empty');
165
+ }
166
+
167
+ const isServiceAccount = token.startsWith(SERVICE_ACCOUNT_PREFIX);
168
+
169
+ if (isServiceAccount) {
170
+ if (target.kind === 'origin') {
171
+ throw new Error(
172
+ 'Service Account を登録するには project URL を指定してください: ' +
173
+ 'cosense login <origin>/<project>'
174
+ );
175
+ }
176
+ writeProjectServiceAccount(target.origin, target.projectName, token);
177
+ process.stdout.write(
178
+ `Saved Service Account for ${target.origin}/${target.projectName} to ${settingsPath}\n`
179
+ );
180
+ return;
98
181
  }
99
182
 
100
- writeUserToken(origin, token);
101
- process.stdout.write(`Saved PAT for ${origin} to ${settingsPath}\n`);
183
+ writeUserToken(target.origin, token);
184
+ process.stdout.write(`Saved PAT for ${target.origin} to ${settingsPath}\n`);
102
185
  };
@@ -158,8 +158,7 @@ const loadSettings = (): Settings | null => {
158
158
  return value;
159
159
  };
160
160
 
161
- export const writeUserToken = (origin: string, token: string): void => {
162
- let raw: Record<string, unknown>;
161
+ const readRawSettings = (): Record<string, unknown> => {
163
162
  try {
164
163
  const text = readFileSync(SETTINGS_PATH, 'utf8');
165
164
  let parsed: unknown;
@@ -177,15 +176,28 @@ export const writeUserToken = (origin: string, token: string): void => {
177
176
  ) {
178
177
  throw new Error(`${SETTINGS_PATH}: must be an object`);
179
178
  }
180
- raw = parsed as Record<string, unknown>;
179
+ return parsed as Record<string, unknown>;
181
180
  } catch (err) {
182
181
  if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
183
- raw = {};
184
- } else {
185
- throw err;
182
+ return {};
186
183
  }
184
+ throw err;
187
185
  }
186
+ };
188
187
 
188
+ const writeRawSettings = (raw: Record<string, unknown>): void => {
189
+ const dir = dirname(SETTINGS_PATH);
190
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
191
+ chmodSync(dir, 0o700);
192
+ writeFileSync(SETTINGS_PATH, `${JSON.stringify(raw, null, 2)}\n`, {
193
+ mode: 0o600
194
+ });
195
+ chmodSync(SETTINGS_PATH, 0o600);
196
+ cache = undefined;
197
+ };
198
+
199
+ export const writeUserToken = (origin: string, token: string): void => {
200
+ const raw = readRawSettings();
189
201
  const existing = Array.isArray(raw.users) ? (raw.users as unknown[]) : [];
190
202
  const filtered = existing.filter(entry => {
191
203
  if (typeof entry !== 'object' || entry === null) return true;
@@ -199,15 +211,37 @@ export const writeUserToken = (origin: string, token: string): void => {
199
211
  });
200
212
  filtered.push({ url: origin, token });
201
213
  raw.users = filtered;
214
+ writeRawSettings(raw);
215
+ };
202
216
 
203
- const dir = dirname(SETTINGS_PATH);
204
- mkdirSync(dir, { recursive: true, mode: 0o700 });
205
- chmodSync(dir, 0o700);
206
- writeFileSync(SETTINGS_PATH, `${JSON.stringify(raw, null, 2)}\n`, {
207
- mode: 0o600
217
+ export const writeProjectServiceAccount = (
218
+ origin: string,
219
+ projectName: string,
220
+ serviceAccount: string
221
+ ): void => {
222
+ const raw = readRawSettings();
223
+ const projectNameLc = projectName.toLowerCase();
224
+ const existing = Array.isArray(raw.projects)
225
+ ? (raw.projects as unknown[])
226
+ : [];
227
+ const filtered = existing.filter(entry => {
228
+ if (typeof entry !== 'object' || entry === null) return true;
229
+ const url = (entry as { url?: unknown }).url;
230
+ if (typeof url !== 'string') return true;
231
+ try {
232
+ const parsed = new URL(url);
233
+ const existingName = parsed.pathname.split('/').filter(Boolean)[0];
234
+ if (!existingName) return true;
235
+ return !(
236
+ parsed.origin === origin && existingName.toLowerCase() === projectNameLc
237
+ );
238
+ } catch {
239
+ return true;
240
+ }
208
241
  });
209
- chmodSync(SETTINGS_PATH, 0o600);
210
- cache = undefined;
242
+ filtered.push({ url: `${origin}/${projectName}`, serviceAccount });
243
+ raw.projects = filtered;
244
+ writeRawSettings(raw);
211
245
  };
212
246
 
213
247
  export const settingsPath = SETTINGS_PATH;