@helpfeel/cosense-cli 1.4.1 → 1.4.3
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/commands/login.ts +105 -22
- package/src/lib/settings.ts +47 -13
package/package.json
CHANGED
package/src/commands/login.ts
CHANGED
|
@@ -1,27 +1,32 @@
|
|
|
1
|
-
import { parseOrigin } from '../lib/parseUrl.ts';
|
|
2
|
-
import {
|
|
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 =
|
|
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>
|
|
17
|
+
<origin> Cosenseサーバーのorigin(例: https://scrapbox.io)
|
|
18
|
+
<projectUrl> プロジェクトURL(例: https://scrapbox.io/Nota)
|
|
13
19
|
|
|
14
20
|
動作:
|
|
15
|
-
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
};
|
package/src/lib/settings.ts
CHANGED
|
@@ -158,8 +158,7 @@ const loadSettings = (): Settings | null => {
|
|
|
158
158
|
return value;
|
|
159
159
|
};
|
|
160
160
|
|
|
161
|
-
|
|
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
|
-
|
|
179
|
+
return parsed as Record<string, unknown>;
|
|
181
180
|
} catch (err) {
|
|
182
181
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
183
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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;
|