@khanglvm/outline-cli 0.1.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/.env.test.example +2 -0
- package/AGENTS.md +107 -0
- package/CHANGELOG.md +102 -0
- package/README.md +244 -0
- package/bin/outline-agent.js +5 -0
- package/bin/outline-cli.js +13 -0
- package/package.json +25 -0
- package/scripts/generate-entry-integrity.mjs +123 -0
- package/scripts/release.mjs +353 -0
- package/src/action-gate.js +257 -0
- package/src/agent-skills.js +759 -0
- package/src/cli.js +956 -0
- package/src/config-store.js +720 -0
- package/src/entry-integrity-binding.generated.js +6 -0
- package/src/entry-integrity-manifest.generated.js +74 -0
- package/src/entry-integrity.js +112 -0
- package/src/errors.js +15 -0
- package/src/outline-client.js +237 -0
- package/src/result-store.js +183 -0
- package/src/secure-keyring.js +290 -0
- package/src/tool-arg-schemas.js +2346 -0
- package/src/tools.extended.js +3252 -0
- package/src/tools.js +1056 -0
- package/src/tools.mutation.js +1807 -0
- package/src/tools.navigation.js +2273 -0
- package/src/tools.platform.js +554 -0
- package/src/utils.js +176 -0
- package/test/action-gate.unit.test.js +157 -0
- package/test/agent-skills.unit.test.js +52 -0
- package/test/config-store.unit.test.js +89 -0
- package/test/hardening.unit.test.js +3778 -0
- package/test/live.integration.test.js +5140 -0
- package/test/profile-selection.unit.test.js +279 -0
- package/test/security.unit.test.js +113 -0
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import fsSync from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export const CONFIG_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
const OFFICIAL_OUTLINE_HOST_ALIASES = new Set([
|
|
9
|
+
"getoutline.com",
|
|
10
|
+
"www.getoutline.com",
|
|
11
|
+
"docs.getoutline.com",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const BASE_URL_UI_PATH_MARKERS = new Set([
|
|
15
|
+
"account",
|
|
16
|
+
"auth",
|
|
17
|
+
"collection",
|
|
18
|
+
"collections",
|
|
19
|
+
"d",
|
|
20
|
+
"dashboard",
|
|
21
|
+
"doc",
|
|
22
|
+
"home",
|
|
23
|
+
"s",
|
|
24
|
+
"search",
|
|
25
|
+
"settings",
|
|
26
|
+
"share",
|
|
27
|
+
"templates",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const PROFILE_KEYWORD_STOPWORDS = new Set([
|
|
31
|
+
"a",
|
|
32
|
+
"an",
|
|
33
|
+
"and",
|
|
34
|
+
"api",
|
|
35
|
+
"app",
|
|
36
|
+
"by",
|
|
37
|
+
"for",
|
|
38
|
+
"from",
|
|
39
|
+
"in",
|
|
40
|
+
"into",
|
|
41
|
+
"is",
|
|
42
|
+
"of",
|
|
43
|
+
"on",
|
|
44
|
+
"or",
|
|
45
|
+
"the",
|
|
46
|
+
"to",
|
|
47
|
+
"via",
|
|
48
|
+
"with",
|
|
49
|
+
"www",
|
|
50
|
+
"http",
|
|
51
|
+
"https",
|
|
52
|
+
"outline",
|
|
53
|
+
"workspace",
|
|
54
|
+
"knowledge",
|
|
55
|
+
"base",
|
|
56
|
+
"implement",
|
|
57
|
+
"collection",
|
|
58
|
+
"doc",
|
|
59
|
+
"site",
|
|
60
|
+
"data",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const PROFILE_HOST_TOKEN_BLACKLIST = new Set([
|
|
64
|
+
"com",
|
|
65
|
+
"net",
|
|
66
|
+
"org",
|
|
67
|
+
"io",
|
|
68
|
+
"site",
|
|
69
|
+
"app",
|
|
70
|
+
"www",
|
|
71
|
+
"localhost",
|
|
72
|
+
"local",
|
|
73
|
+
"internal",
|
|
74
|
+
"corp",
|
|
75
|
+
"company",
|
|
76
|
+
"outline",
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
export function defaultConfigPath() {
|
|
80
|
+
if (process.env.OUTLINE_CLI_CONFIG) {
|
|
81
|
+
return path.resolve(process.env.OUTLINE_CLI_CONFIG);
|
|
82
|
+
}
|
|
83
|
+
if (process.env.OUTLINE_AGENT_CONFIG) {
|
|
84
|
+
return path.resolve(process.env.OUTLINE_AGENT_CONFIG);
|
|
85
|
+
}
|
|
86
|
+
const modern = path.join(os.homedir(), ".config", "outline-cli", "config.json");
|
|
87
|
+
const legacy = path.join(os.homedir(), ".config", "outline-agent", "config.json");
|
|
88
|
+
if (!fsSync.existsSync(modern) && fsSync.existsSync(legacy)) {
|
|
89
|
+
return legacy;
|
|
90
|
+
}
|
|
91
|
+
return modern;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function defaultTmpDir() {
|
|
95
|
+
if (process.env.OUTLINE_CLI_TMP_DIR) {
|
|
96
|
+
return path.resolve(process.env.OUTLINE_CLI_TMP_DIR);
|
|
97
|
+
}
|
|
98
|
+
if (process.env.OUTLINE_AGENT_TMP_DIR) {
|
|
99
|
+
return path.resolve(process.env.OUTLINE_AGENT_TMP_DIR);
|
|
100
|
+
}
|
|
101
|
+
return path.join(os.homedir(), ".cache", "outline-cli", "tmp");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function blankConfig() {
|
|
105
|
+
return {
|
|
106
|
+
version: CONFIG_VERSION,
|
|
107
|
+
defaultProfile: null,
|
|
108
|
+
profiles: {},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function loadConfig(configPath) {
|
|
113
|
+
try {
|
|
114
|
+
const raw = await fs.readFile(configPath, "utf8");
|
|
115
|
+
const parsed = JSON.parse(raw);
|
|
116
|
+
if (!parsed || typeof parsed !== "object") {
|
|
117
|
+
return blankConfig();
|
|
118
|
+
}
|
|
119
|
+
if (!parsed.profiles || typeof parsed.profiles !== "object") {
|
|
120
|
+
parsed.profiles = {};
|
|
121
|
+
}
|
|
122
|
+
if (!Object.prototype.hasOwnProperty.call(parsed, "defaultProfile")) {
|
|
123
|
+
parsed.defaultProfile = null;
|
|
124
|
+
}
|
|
125
|
+
if (!parsed.version) {
|
|
126
|
+
parsed.version = CONFIG_VERSION;
|
|
127
|
+
}
|
|
128
|
+
return parsed;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (err && err.code === "ENOENT") {
|
|
131
|
+
return blankConfig();
|
|
132
|
+
}
|
|
133
|
+
throw new Error(`Failed to read config ${configPath}: ${err.message}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function saveConfig(configPath, config) {
|
|
138
|
+
const dir = path.dirname(configPath);
|
|
139
|
+
await fs.mkdir(dir, { recursive: true });
|
|
140
|
+
const payload = JSON.stringify(config, null, 2) + "\n";
|
|
141
|
+
await fs.writeFile(configPath, payload, { mode: 0o600 });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function normalizeBaseUrl(baseUrl) {
|
|
145
|
+
return normalizeBaseUrlWithHints(baseUrl).baseUrl;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function looksLikeHostnameCandidate(value) {
|
|
149
|
+
return /^[A-Za-z0-9.-]+(?::\d+)?(\/.*)?$/.test(String(value || ""));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizePathForBaseUrl(pathname, corrections) {
|
|
153
|
+
const path = String(pathname || "");
|
|
154
|
+
const segments = path.split("/").filter(Boolean);
|
|
155
|
+
if (segments.length === 0) {
|
|
156
|
+
return "/";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const lowerSegments = segments.map((segment) => segment.toLowerCase());
|
|
160
|
+
const apiIndex = lowerSegments.indexOf("api");
|
|
161
|
+
if (apiIndex >= 0) {
|
|
162
|
+
const kept = segments.slice(0, apiIndex);
|
|
163
|
+
corrections.push(apiIndex === 0 ? "trimmed_api_prefix" : "trimmed_path_after_api");
|
|
164
|
+
return kept.length > 0 ? `/${kept.join("/")}` : "/";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const markerIndex = lowerSegments.findIndex((segment) => BASE_URL_UI_PATH_MARKERS.has(segment));
|
|
168
|
+
if (markerIndex >= 0) {
|
|
169
|
+
const kept = segments.slice(0, markerIndex);
|
|
170
|
+
corrections.push("trimmed_ui_path");
|
|
171
|
+
return kept.length > 0 ? `/${kept.join("/")}` : "/";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return `/${segments.join("/")}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function normalizeKeywords(keywords) {
|
|
178
|
+
if (keywords == null) {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
const items = Array.isArray(keywords)
|
|
182
|
+
? keywords
|
|
183
|
+
: String(keywords)
|
|
184
|
+
.split(",")
|
|
185
|
+
.map((item) => item.trim());
|
|
186
|
+
return [...new Set(items.map((item) => String(item || "").trim()).filter(Boolean))];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function profileKeywordTokens(input) {
|
|
190
|
+
return String(input || "")
|
|
191
|
+
.toLowerCase()
|
|
192
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
193
|
+
.split(/[^a-z0-9]+/)
|
|
194
|
+
.map((token) => token.trim())
|
|
195
|
+
.filter((token) => token.length >= 2)
|
|
196
|
+
.filter((token) => !/\d/.test(token))
|
|
197
|
+
.filter((token) => !PROFILE_KEYWORD_STOPWORDS.has(token));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function profileKeywordBigrams(tokens) {
|
|
201
|
+
const out = [];
|
|
202
|
+
for (let i = 0; i < tokens.length - 1; i += 1) {
|
|
203
|
+
const left = tokens[i];
|
|
204
|
+
const right = tokens[i + 1];
|
|
205
|
+
if (!left || !right) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (left.length < 3 || right.length < 3) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
out.push(`${left} ${right}`);
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function profileHostKeywords(baseUrl) {
|
|
217
|
+
try {
|
|
218
|
+
const host = new URL(String(baseUrl || "")).hostname.toLowerCase();
|
|
219
|
+
return host
|
|
220
|
+
.split(".")
|
|
221
|
+
.map((token) => token.trim())
|
|
222
|
+
.filter(Boolean)
|
|
223
|
+
.filter((token) => !/\d/.test(token))
|
|
224
|
+
.filter((token) => !PROFILE_HOST_TOKEN_BLACKLIST.has(token))
|
|
225
|
+
.filter((token) => token.length >= 2);
|
|
226
|
+
} catch {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function rankProfileKeywordCandidates(sources) {
|
|
232
|
+
const weighted = new Map();
|
|
233
|
+
let index = 0;
|
|
234
|
+
for (const source of sources) {
|
|
235
|
+
const weight = Number(source?.weight) || 0;
|
|
236
|
+
if (weight <= 0) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
for (const item of source.items || []) {
|
|
240
|
+
const normalized = String(item || "").trim().toLowerCase();
|
|
241
|
+
if (!normalized) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const previous = weighted.get(normalized);
|
|
245
|
+
if (previous) {
|
|
246
|
+
previous.score += weight;
|
|
247
|
+
} else {
|
|
248
|
+
weighted.set(normalized, { score: weight, order: index });
|
|
249
|
+
index += 1;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return [...weighted.entries()]
|
|
254
|
+
.sort((a, b) => {
|
|
255
|
+
if (b[1].score !== a[1].score) {
|
|
256
|
+
return b[1].score - a[1].score;
|
|
257
|
+
}
|
|
258
|
+
if (a[1].order !== b[1].order) {
|
|
259
|
+
return a[1].order - b[1].order;
|
|
260
|
+
}
|
|
261
|
+
return a[0].localeCompare(b[0]);
|
|
262
|
+
})
|
|
263
|
+
.map(([keyword]) => keyword);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function toProfileDescriptionCandidate(value) {
|
|
267
|
+
const trimmed = String(value || "").trim();
|
|
268
|
+
return trimmed ? trimmed : undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function tokenizeProfileQuery(value) {
|
|
272
|
+
return String(value || "")
|
|
273
|
+
.toLowerCase()
|
|
274
|
+
.split(/[^a-z0-9]+/)
|
|
275
|
+
.filter(Boolean);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function safeHostFromUrl(value) {
|
|
279
|
+
try {
|
|
280
|
+
return new URL(String(value || "")).host.toLowerCase();
|
|
281
|
+
} catch {
|
|
282
|
+
return "";
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function scoreProfileForQuery(profile, id, rawQuery, queryTokens) {
|
|
287
|
+
const name = String(profile?.name || "").toLowerCase();
|
|
288
|
+
const description = String(profile?.description || "").toLowerCase();
|
|
289
|
+
const keywords = normalizeKeywords(profile?.keywords).map((item) => item.toLowerCase());
|
|
290
|
+
const baseUrl = String(profile?.baseUrl || "");
|
|
291
|
+
const baseHost = safeHostFromUrl(baseUrl);
|
|
292
|
+
const queryText = String(rawQuery || "").trim().toLowerCase();
|
|
293
|
+
const matchedOn = [];
|
|
294
|
+
let score = 0;
|
|
295
|
+
|
|
296
|
+
if (queryText) {
|
|
297
|
+
if (String(id).toLowerCase() === queryText) {
|
|
298
|
+
score += 1.4;
|
|
299
|
+
matchedOn.push("id_exact");
|
|
300
|
+
} else if (String(id).toLowerCase().includes(queryText)) {
|
|
301
|
+
score += 1.1;
|
|
302
|
+
matchedOn.push("id_partial");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (name === queryText) {
|
|
306
|
+
score += 1.2;
|
|
307
|
+
matchedOn.push("name_exact");
|
|
308
|
+
} else if (name.includes(queryText)) {
|
|
309
|
+
score += 0.8;
|
|
310
|
+
matchedOn.push("name_partial");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (description.includes(queryText)) {
|
|
314
|
+
score += 0.55;
|
|
315
|
+
matchedOn.push("description_partial");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (keywords.includes(queryText)) {
|
|
319
|
+
score += 1.1;
|
|
320
|
+
matchedOn.push("keyword_exact");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (baseHost && (queryText.includes(baseHost) || baseHost.includes(queryText))) {
|
|
324
|
+
score += 0.9;
|
|
325
|
+
matchedOn.push("host");
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (queryTokens.length > 0) {
|
|
330
|
+
const idTokens = new Set(tokenizeProfileQuery(id));
|
|
331
|
+
const nameTokens = new Set(tokenizeProfileQuery(name));
|
|
332
|
+
const descTokens = new Set(tokenizeProfileQuery(description));
|
|
333
|
+
const keywordTokens = new Set(keywords.flatMap((item) => tokenizeProfileQuery(item)));
|
|
334
|
+
const hostTokens = new Set(tokenizeProfileQuery(baseHost));
|
|
335
|
+
|
|
336
|
+
const overlaps = {
|
|
337
|
+
id: 0,
|
|
338
|
+
name: 0,
|
|
339
|
+
description: 0,
|
|
340
|
+
keywords: 0,
|
|
341
|
+
host: 0,
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
for (const token of queryTokens) {
|
|
345
|
+
if (idTokens.has(token)) {
|
|
346
|
+
overlaps.id += 1;
|
|
347
|
+
}
|
|
348
|
+
if (nameTokens.has(token)) {
|
|
349
|
+
overlaps.name += 1;
|
|
350
|
+
}
|
|
351
|
+
if (descTokens.has(token)) {
|
|
352
|
+
overlaps.description += 1;
|
|
353
|
+
}
|
|
354
|
+
if (keywordTokens.has(token)) {
|
|
355
|
+
overlaps.keywords += 1;
|
|
356
|
+
}
|
|
357
|
+
if (hostTokens.has(token)) {
|
|
358
|
+
overlaps.host += 1;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (overlaps.id > 0) {
|
|
363
|
+
score += overlaps.id * 0.5;
|
|
364
|
+
matchedOn.push("id_tokens");
|
|
365
|
+
}
|
|
366
|
+
if (overlaps.name > 0) {
|
|
367
|
+
score += overlaps.name * 0.4;
|
|
368
|
+
matchedOn.push("name_tokens");
|
|
369
|
+
}
|
|
370
|
+
if (overlaps.description > 0) {
|
|
371
|
+
score += Math.min(0.45, overlaps.description * 0.12);
|
|
372
|
+
matchedOn.push("description_tokens");
|
|
373
|
+
}
|
|
374
|
+
if (overlaps.keywords > 0) {
|
|
375
|
+
score += overlaps.keywords * 0.55;
|
|
376
|
+
matchedOn.push("keyword_tokens");
|
|
377
|
+
}
|
|
378
|
+
if (overlaps.host > 0) {
|
|
379
|
+
score += overlaps.host * 0.35;
|
|
380
|
+
matchedOn.push("host_tokens");
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
score: Number(score.toFixed(4)),
|
|
386
|
+
matchedOn: [...new Set(matchedOn)],
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function normalizeBaseUrlWithHints(baseUrl) {
|
|
391
|
+
if (!baseUrl || typeof baseUrl !== "string") {
|
|
392
|
+
throw new Error("baseUrl is required");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const rawInput = baseUrl.trim();
|
|
396
|
+
if (!rawInput) {
|
|
397
|
+
throw new Error("baseUrl is required");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const corrections = [];
|
|
401
|
+
let parseInput = rawInput;
|
|
402
|
+
if (!/^[a-z]+:\/\//i.test(parseInput)) {
|
|
403
|
+
if (!looksLikeHostnameCandidate(parseInput)) {
|
|
404
|
+
throw new Error("baseUrl must be a valid URL or hostname");
|
|
405
|
+
}
|
|
406
|
+
parseInput = `https://${parseInput}`;
|
|
407
|
+
corrections.push("added_https_scheme");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const parsed = new URL(parseInput);
|
|
411
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
412
|
+
throw new Error("baseUrl must start with http:// or https://");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (OFFICIAL_OUTLINE_HOST_ALIASES.has(parsed.hostname.toLowerCase())) {
|
|
416
|
+
parsed.protocol = "https:";
|
|
417
|
+
parsed.hostname = "app.getoutline.com";
|
|
418
|
+
parsed.port = "";
|
|
419
|
+
parsed.pathname = "/";
|
|
420
|
+
parsed.search = "";
|
|
421
|
+
parsed.hash = "";
|
|
422
|
+
corrections.push("mapped_official_outline_host");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (parsed.search) {
|
|
426
|
+
parsed.search = "";
|
|
427
|
+
corrections.push("removed_query");
|
|
428
|
+
}
|
|
429
|
+
if (parsed.hash) {
|
|
430
|
+
parsed.hash = "";
|
|
431
|
+
corrections.push("removed_hash");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const normalizedPath = normalizePathForBaseUrl(parsed.pathname, corrections);
|
|
435
|
+
parsed.pathname = normalizedPath;
|
|
436
|
+
|
|
437
|
+
const serialized = parsed.toString().replace(/\/+$/, "");
|
|
438
|
+
const normalized = serialized.endsWith("/api") ? serialized.slice(0, -4) : serialized;
|
|
439
|
+
if (serialized !== normalized) {
|
|
440
|
+
corrections.push("trimmed_api_suffix");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
input: rawInput,
|
|
445
|
+
baseUrl: normalized,
|
|
446
|
+
corrected: normalized !== rawInput || corrections.length > 0,
|
|
447
|
+
corrections: [...new Set(corrections)],
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function suggestProfiles(config, query, options = {}) {
|
|
452
|
+
const limit = Number.isFinite(Number(options.limit)) ? Math.max(1, Number(options.limit)) : 5;
|
|
453
|
+
const queryText = String(query || "").trim();
|
|
454
|
+
const queryTokens = tokenizeProfileQuery(queryText);
|
|
455
|
+
const rows = listProfiles(config).map((profile) => {
|
|
456
|
+
const { score, matchedOn } = scoreProfileForQuery(profile, profile.id, queryText, queryTokens);
|
|
457
|
+
return {
|
|
458
|
+
...redactProfile(profile),
|
|
459
|
+
isDefault: config?.defaultProfile === profile.id,
|
|
460
|
+
score,
|
|
461
|
+
matchedOn,
|
|
462
|
+
};
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
rows.sort((a, b) => {
|
|
466
|
+
if (b.score !== a.score) {
|
|
467
|
+
return b.score - a.score;
|
|
468
|
+
}
|
|
469
|
+
if (a.isDefault !== b.isDefault) {
|
|
470
|
+
return a.isDefault ? -1 : 1;
|
|
471
|
+
}
|
|
472
|
+
return String(a.id || "").localeCompare(String(b.id || ""));
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
query: queryText,
|
|
477
|
+
profileCount: rows.length,
|
|
478
|
+
matches: rows.slice(0, limit),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function suggestProfileMetadata(input = {}, options = {}) {
|
|
483
|
+
const id = String(input.id || "").trim();
|
|
484
|
+
const name = String(input.name || id || "").trim();
|
|
485
|
+
const baseUrl = String(input.baseUrl || "").trim();
|
|
486
|
+
const currentDescription = toProfileDescriptionCandidate(input.description);
|
|
487
|
+
const currentKeywords = normalizeKeywords(input.keywords);
|
|
488
|
+
const hints = Array.isArray(input.hints)
|
|
489
|
+
? input.hints.map((item) => String(item || "").trim()).filter(Boolean)
|
|
490
|
+
: [];
|
|
491
|
+
const maxKeywordsRaw = Number(options.maxKeywords);
|
|
492
|
+
const maxKeywords = Number.isFinite(maxKeywordsRaw)
|
|
493
|
+
? Math.max(1, Math.trunc(maxKeywordsRaw))
|
|
494
|
+
: 20;
|
|
495
|
+
const refreshDescription = !!options.refreshDescription;
|
|
496
|
+
const preserveKeywords = !!options.preserveKeywords;
|
|
497
|
+
|
|
498
|
+
const idTokens = profileKeywordTokens(id);
|
|
499
|
+
const nameTokens = profileKeywordTokens(name);
|
|
500
|
+
const hostTokens = profileHostKeywords(baseUrl);
|
|
501
|
+
const currentKeywordTokens = currentKeywords.flatMap((item) => profileKeywordTokens(item));
|
|
502
|
+
const hintTokens = hints.flatMap((hint) => profileKeywordTokens(hint));
|
|
503
|
+
const hintBigrams = hints.flatMap((hint) => profileKeywordBigrams(profileKeywordTokens(hint)));
|
|
504
|
+
|
|
505
|
+
const rankedCandidates = rankProfileKeywordCandidates([
|
|
506
|
+
{ weight: 4.0, items: currentKeywords },
|
|
507
|
+
{ weight: 3.0, items: hintBigrams },
|
|
508
|
+
{ weight: 2.5, items: hintTokens },
|
|
509
|
+
{ weight: 2.2, items: currentKeywordTokens },
|
|
510
|
+
{ weight: 1.8, items: nameTokens },
|
|
511
|
+
{ weight: 1.5, items: idTokens },
|
|
512
|
+
{ weight: 1.1, items: hostTokens },
|
|
513
|
+
]);
|
|
514
|
+
|
|
515
|
+
const nextKeywords = [];
|
|
516
|
+
const keywordSeed = preserveKeywords && currentKeywords.length > 0
|
|
517
|
+
? [...currentKeywords]
|
|
518
|
+
: [...currentKeywords, ...rankedCandidates];
|
|
519
|
+
for (const item of keywordSeed) {
|
|
520
|
+
const normalized = String(item || "").trim().toLowerCase();
|
|
521
|
+
if (!normalized) {
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (!nextKeywords.includes(normalized)) {
|
|
525
|
+
nextKeywords.push(normalized);
|
|
526
|
+
}
|
|
527
|
+
if (nextKeywords.length >= maxKeywords) {
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
let nextDescription = currentDescription;
|
|
533
|
+
if (!nextDescription || refreshDescription) {
|
|
534
|
+
const host = safeHostFromUrl(baseUrl);
|
|
535
|
+
const topicHints = nextKeywords
|
|
536
|
+
.filter((item) => !hostTokens.includes(item))
|
|
537
|
+
.slice(0, 4);
|
|
538
|
+
if (topicHints.length > 0) {
|
|
539
|
+
nextDescription = `${name || id || "Outline"} knowledge base for ${topicHints.join(", ")}`;
|
|
540
|
+
} else if (host) {
|
|
541
|
+
nextDescription = `${name || id || "Outline"} workspace (${host})`;
|
|
542
|
+
} else {
|
|
543
|
+
nextDescription = `${name || id || "Outline"} workspace`;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const previousKeywordSet = new Set(currentKeywords.map((item) => item.toLowerCase()));
|
|
548
|
+
const addedKeywords = nextKeywords.filter((item) => !previousKeywordSet.has(item));
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
description: nextDescription,
|
|
552
|
+
keywords: nextKeywords,
|
|
553
|
+
generated: {
|
|
554
|
+
descriptionGenerated: !currentDescription || (refreshDescription && nextDescription !== currentDescription),
|
|
555
|
+
keywordsAdded: addedKeywords.length,
|
|
556
|
+
hintsUsed: hints.length,
|
|
557
|
+
maxKeywords,
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export function listProfiles(config) {
|
|
563
|
+
return Object.entries(config.profiles || {}).map(([id, profile]) => ({
|
|
564
|
+
id,
|
|
565
|
+
...profile,
|
|
566
|
+
}));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export function getProfile(config, explicitId) {
|
|
570
|
+
const profiles = config?.profiles || {};
|
|
571
|
+
|
|
572
|
+
if (explicitId) {
|
|
573
|
+
const profile = profiles[explicitId];
|
|
574
|
+
if (!profile) {
|
|
575
|
+
throw new Error(`Profile not found: ${explicitId}`);
|
|
576
|
+
}
|
|
577
|
+
return {
|
|
578
|
+
id: explicitId,
|
|
579
|
+
...profile,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (config.defaultProfile) {
|
|
584
|
+
const profile = profiles[config.defaultProfile];
|
|
585
|
+
if (!profile) {
|
|
586
|
+
throw new Error(`Profile not found: ${config.defaultProfile}`);
|
|
587
|
+
}
|
|
588
|
+
return {
|
|
589
|
+
id: config.defaultProfile,
|
|
590
|
+
...profile,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const profileIds = Object.keys(profiles);
|
|
595
|
+
if (profileIds.length === 1) {
|
|
596
|
+
const id = profileIds[0];
|
|
597
|
+
return {
|
|
598
|
+
id,
|
|
599
|
+
...profiles[id],
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (profileIds.length > 1) {
|
|
604
|
+
throw new Error(
|
|
605
|
+
"Profile selection required: multiple profiles are saved and no default profile is set. Use --profile <id> or `outline-cli profile use <id>`."
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
throw new Error("No profiles configured. Use `outline-cli profile add <id> ...` first.");
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export function redactProfile(profile) {
|
|
613
|
+
if (!profile) {
|
|
614
|
+
return profile;
|
|
615
|
+
}
|
|
616
|
+
const clone = structuredClone(profile);
|
|
617
|
+
if (clone.auth) {
|
|
618
|
+
if (clone.auth.apiKey) {
|
|
619
|
+
clone.auth.apiKey = redactSecret(clone.auth.apiKey);
|
|
620
|
+
}
|
|
621
|
+
if (clone.auth.password) {
|
|
622
|
+
clone.auth.password = "***";
|
|
623
|
+
}
|
|
624
|
+
if (clone.auth.clientSecret) {
|
|
625
|
+
clone.auth.clientSecret = "***";
|
|
626
|
+
}
|
|
627
|
+
if (clone.auth.tokenRequestBody && typeof clone.auth.tokenRequestBody === "object") {
|
|
628
|
+
for (const key of Object.keys(clone.auth.tokenRequestBody)) {
|
|
629
|
+
if (/secret|password|token|key/i.test(key)) {
|
|
630
|
+
clone.auth.tokenRequestBody[key] = "***";
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return clone;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export function redactSecret(secret) {
|
|
639
|
+
if (!secret) {
|
|
640
|
+
return secret;
|
|
641
|
+
}
|
|
642
|
+
if (secret.length <= 8) {
|
|
643
|
+
return "***";
|
|
644
|
+
}
|
|
645
|
+
return `${secret.slice(0, 4)}...${secret.slice(-4)}`;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export function buildProfile({
|
|
649
|
+
id,
|
|
650
|
+
name,
|
|
651
|
+
description,
|
|
652
|
+
keywords,
|
|
653
|
+
baseUrl,
|
|
654
|
+
authType,
|
|
655
|
+
apiKey,
|
|
656
|
+
username,
|
|
657
|
+
password,
|
|
658
|
+
tokenEndpoint,
|
|
659
|
+
tokenField,
|
|
660
|
+
tokenRequestBody,
|
|
661
|
+
timeoutMs,
|
|
662
|
+
headers,
|
|
663
|
+
}) {
|
|
664
|
+
const normalizedBaseUrl = normalizeBaseUrlWithHints(baseUrl).baseUrl;
|
|
665
|
+
const normalizedKeywords = normalizeKeywords(keywords);
|
|
666
|
+
const profile = {
|
|
667
|
+
name: name || id,
|
|
668
|
+
description: typeof description === "string" && description.trim() ? description.trim() : undefined,
|
|
669
|
+
keywords: normalizedKeywords.length > 0 ? normalizedKeywords : undefined,
|
|
670
|
+
baseUrl: normalizedBaseUrl,
|
|
671
|
+
timeoutMs: timeoutMs || 30000,
|
|
672
|
+
headers: headers || {},
|
|
673
|
+
auth: {},
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
const mode = authType || (apiKey ? "apiKey" : username && password ? "password" : null);
|
|
677
|
+
if (!mode) {
|
|
678
|
+
throw new Error("Provide either --api-key or --username + --password");
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (mode === "apiKey") {
|
|
682
|
+
if (!apiKey) {
|
|
683
|
+
throw new Error("--api-key is required for auth type apiKey");
|
|
684
|
+
}
|
|
685
|
+
profile.auth = {
|
|
686
|
+
type: "apiKey",
|
|
687
|
+
apiKey,
|
|
688
|
+
};
|
|
689
|
+
return profile;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (mode === "basic") {
|
|
693
|
+
if (!username || !password) {
|
|
694
|
+
throw new Error("--username and --password are required for auth type basic");
|
|
695
|
+
}
|
|
696
|
+
profile.auth = {
|
|
697
|
+
type: "basic",
|
|
698
|
+
username,
|
|
699
|
+
password,
|
|
700
|
+
};
|
|
701
|
+
return profile;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (mode === "password") {
|
|
705
|
+
if (!username || !password) {
|
|
706
|
+
throw new Error("--username and --password are required for auth type password");
|
|
707
|
+
}
|
|
708
|
+
profile.auth = {
|
|
709
|
+
type: "password",
|
|
710
|
+
username,
|
|
711
|
+
password,
|
|
712
|
+
tokenEndpoint: tokenEndpoint || null,
|
|
713
|
+
tokenField: tokenField || "access_token",
|
|
714
|
+
tokenRequestBody: tokenRequestBody || null,
|
|
715
|
+
};
|
|
716
|
+
return profile;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
throw new Error(`Unsupported auth type: ${mode}`);
|
|
720
|
+
}
|