@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.
@@ -0,0 +1,554 @@
1
+ import { ApiError, CliError } from "./errors.js";
2
+ import {
3
+ assertPerformAction,
4
+ consumeDocumentDeleteReadReceipt,
5
+ getDocumentDeleteReadReceipt,
6
+ issueDocumentDeleteReadReceipt,
7
+ } from "./action-gate.js";
8
+ import { mapLimit, toBoolean, toInteger } from "./utils.js";
9
+
10
+ function summarizePolicies(policies = []) {
11
+ const truthyAbilityCounts = {};
12
+ const falsyAbilityCounts = {};
13
+
14
+ for (const policy of policies || []) {
15
+ const abilities = policy?.abilities;
16
+ if (!abilities || typeof abilities !== "object") {
17
+ continue;
18
+ }
19
+ for (const [ability, enabled] of Object.entries(abilities)) {
20
+ if (enabled) {
21
+ truthyAbilityCounts[ability] = (truthyAbilityCounts[ability] || 0) + 1;
22
+ } else {
23
+ falsyAbilityCounts[ability] = (falsyAbilityCounts[ability] || 0) + 1;
24
+ }
25
+ }
26
+ }
27
+
28
+ const topTruthy = Object.entries(truthyAbilityCounts)
29
+ .sort((a, b) => b[1] - a[1])
30
+ .slice(0, 25)
31
+ .map(([ability, count]) => ({ ability, count }));
32
+
33
+ return {
34
+ policyCount: Array.isArray(policies) ? policies.length : 0,
35
+ truthyAbilityCounts,
36
+ falsyAbilityCounts,
37
+ topTruthy,
38
+ };
39
+ }
40
+
41
+ async function capabilitiesMapTool(ctx, args) {
42
+ const includePolicies = toBoolean(args.includePolicies, false);
43
+ const includeRaw = toBoolean(args.includeRaw, false);
44
+
45
+ async function probe(method, body) {
46
+ try {
47
+ const res = await ctx.client.call(method, body, { maxAttempts: 1 });
48
+ return {
49
+ ok: true,
50
+ status: 200,
51
+ data: res.body?.data,
52
+ policies: Array.isArray(res.body?.policies) ? res.body.policies : [],
53
+ };
54
+ } catch (err) {
55
+ if (err instanceof ApiError) {
56
+ return {
57
+ ok: false,
58
+ status: err.details.status,
59
+ error: err.message,
60
+ data: null,
61
+ policies: [],
62
+ };
63
+ }
64
+ throw err;
65
+ }
66
+ }
67
+
68
+ function inferCapability(policySummary, abilityKeys) {
69
+ const hasTruthy = abilityKeys.some((key) => (policySummary.truthyAbilityCounts[key] || 0) > 0);
70
+ if (hasTruthy) {
71
+ return true;
72
+ }
73
+ const hasAnySignal = abilityKeys.some(
74
+ (key) => (policySummary.truthyAbilityCounts[key] || 0) + (policySummary.falsyAbilityCounts[key] || 0) > 0
75
+ );
76
+ if (hasAnySignal) {
77
+ return false;
78
+ }
79
+ return null;
80
+ }
81
+
82
+ const authRes = await ctx.client.call("auth.info", {});
83
+ const user = authRes.body?.data?.user || null;
84
+ const team = authRes.body?.data?.team || null;
85
+ const role = user?.role || "unknown";
86
+
87
+ const collectionsProbe = await probe("collections.list", { limit: 3, offset: 0 });
88
+ const documentsProbe = await probe("documents.list", { limit: 3, offset: 0 });
89
+ const searchProbe = await probe("documents.search_titles", { query: "a", limit: 1, offset: 0 });
90
+
91
+ const evidencePolicies = [
92
+ ...(Array.isArray(authRes.body?.policies) ? authRes.body.policies : []),
93
+ ...collectionsProbe.policies,
94
+ ...documentsProbe.policies,
95
+ ...searchProbe.policies,
96
+ ];
97
+ const policySummary = summarizePolicies(evidencePolicies);
98
+
99
+ const canCreateCollection = inferCapability(policySummary, ["createCollection"]);
100
+ const canCreateDocumentInSomeCollection = inferCapability(policySummary, [
101
+ "createDocument",
102
+ "createChildDocument",
103
+ ]);
104
+ const canUpdateSomeDocument = inferCapability(policySummary, [
105
+ "update",
106
+ "updateDocument",
107
+ "archive",
108
+ "unarchive",
109
+ ]);
110
+ const canDeleteSomeDocument = inferCapability(policySummary, ["delete", "permanentDelete"]);
111
+
112
+ const canCreate =
113
+ canCreateCollection === true || canCreateDocumentInSomeCollection === true
114
+ ? true
115
+ : canCreateCollection === false && canCreateDocumentInSomeCollection === false
116
+ ? false
117
+ : null;
118
+
119
+ const canUpdate = canUpdateSomeDocument;
120
+ const canDelete = canDeleteSomeDocument;
121
+
122
+ const capabilities = {
123
+ canRead: true,
124
+ canSearch: searchProbe.ok,
125
+ canList: collectionsProbe.ok && documentsProbe.ok,
126
+ canCreate,
127
+ canUpdate,
128
+ canDelete,
129
+ canCreateCollection,
130
+ canCreateDocumentInSomeCollection,
131
+ canUpdateSomeDocument,
132
+ canDeleteSomeDocument,
133
+ role,
134
+ isAdmin: role === "admin",
135
+ isViewer: role === "viewer",
136
+ };
137
+
138
+ const result = {
139
+ user: user
140
+ ? {
141
+ id: user.id,
142
+ name: user.name,
143
+ email: user.email,
144
+ role,
145
+ }
146
+ : null,
147
+ team: team
148
+ ? {
149
+ id: team.id,
150
+ name: team.name,
151
+ url: team.url,
152
+ }
153
+ : null,
154
+ capabilities,
155
+ policySummary,
156
+ evidence: {
157
+ probes: {
158
+ collectionsList: {
159
+ ok: collectionsProbe.ok,
160
+ status: collectionsProbe.status,
161
+ error: collectionsProbe.error,
162
+ },
163
+ documentsList: {
164
+ ok: documentsProbe.ok,
165
+ status: documentsProbe.status,
166
+ error: documentsProbe.error,
167
+ },
168
+ documentsSearchTitles: {
169
+ ok: searchProbe.ok,
170
+ status: searchProbe.status,
171
+ error: searchProbe.error,
172
+ },
173
+ },
174
+ },
175
+ };
176
+
177
+ if (includeRaw) {
178
+ result.raw = {
179
+ authInfo: authRes.body,
180
+ collectionsProbe,
181
+ documentsProbe,
182
+ searchProbe,
183
+ };
184
+ }
185
+
186
+ if (!includePolicies) {
187
+ result.policySummary = {
188
+ policyCount: policySummary.policyCount,
189
+ topTruthy: policySummary.topTruthy,
190
+ };
191
+ }
192
+
193
+ return {
194
+ tool: "capabilities.map",
195
+ profile: ctx.profile.id,
196
+ result,
197
+ };
198
+ }
199
+
200
+ function assertSafeMarkerPrefix(markerPrefix, allowUnsafePrefix) {
201
+ if (typeof markerPrefix !== "string" || markerPrefix.length < 8) {
202
+ throw new CliError("markerPrefix must be a string with length >= 8");
203
+ }
204
+
205
+ if (
206
+ !allowUnsafePrefix &&
207
+ !markerPrefix.startsWith("outline-cli-") &&
208
+ !markerPrefix.startsWith("outline-agent-")
209
+ ) {
210
+ throw new CliError(
211
+ "Unsafe markerPrefix blocked. Use prefix starting with 'outline-cli-' (or legacy 'outline-agent-') or pass allowUnsafePrefix=true",
212
+ { code: "UNSAFE_MARKER_PREFIX", markerPrefix }
213
+ );
214
+ }
215
+ }
216
+
217
+ function normalizeDeleteCandidate(row, markerPrefix) {
218
+ if (!row || typeof row !== "object") {
219
+ return null;
220
+ }
221
+
222
+ const title = row.title || row?.document?.title;
223
+ const id = row.id || row?.document?.id;
224
+ if (!id || !title || !title.startsWith(markerPrefix)) {
225
+ return null;
226
+ }
227
+
228
+ const updatedAt = row.updatedAt || row?.document?.updatedAt;
229
+ const createdAt = row.createdAt || row?.document?.createdAt;
230
+
231
+ return {
232
+ id,
233
+ title,
234
+ updatedAt: updatedAt || null,
235
+ createdAt: createdAt || null,
236
+ collectionId: row.collectionId || row?.document?.collectionId || null,
237
+ };
238
+ }
239
+
240
+ function isOlderThan(candidate, olderThanHours) {
241
+ if (!olderThanHours || olderThanHours <= 0) {
242
+ return true;
243
+ }
244
+
245
+ const at = candidate.updatedAt || candidate.createdAt;
246
+ if (!at) {
247
+ return false;
248
+ }
249
+
250
+ const ts = Date.parse(at);
251
+ if (!Number.isFinite(ts)) {
252
+ return false;
253
+ }
254
+
255
+ const ageMs = Date.now() - ts;
256
+ return ageMs >= olderThanHours * 3600 * 1000;
257
+ }
258
+
259
+ async function safeDeleteCandidate(ctx, candidate, maxAttempts) {
260
+ const evidence = {
261
+ tokenIssued: false,
262
+ revisionChecked: false,
263
+ deleted: false,
264
+ };
265
+
266
+ let readToken;
267
+ try {
268
+ const armedInfo = await ctx.client.call("documents.info", { id: candidate.id }, { maxAttempts });
269
+ const receipt = await issueDocumentDeleteReadReceipt({
270
+ profileId: ctx.profile.id,
271
+ documentId: candidate.id,
272
+ revision: armedInfo.body?.data?.revision,
273
+ title: armedInfo.body?.data?.title || candidate.title,
274
+ ttlSeconds: 900,
275
+ });
276
+ readToken = receipt.token;
277
+ evidence.tokenIssued = true;
278
+
279
+ const verified = await getDocumentDeleteReadReceipt({
280
+ token: readToken,
281
+ profileId: ctx.profile.id,
282
+ documentId: candidate.id,
283
+ });
284
+
285
+ const latest = await ctx.client.call("documents.info", { id: candidate.id }, { maxAttempts });
286
+ const expectedRevision = Number(verified.revision);
287
+ const actualRevision = Number(latest.body?.data?.revision);
288
+ evidence.revisionChecked = true;
289
+ evidence.expectedRevision = Number.isFinite(expectedRevision) ? expectedRevision : null;
290
+ evidence.actualRevision = Number.isFinite(actualRevision) ? actualRevision : null;
291
+
292
+ if (
293
+ Number.isFinite(expectedRevision) &&
294
+ Number.isFinite(actualRevision) &&
295
+ expectedRevision !== actualRevision
296
+ ) {
297
+ throw new CliError("Delete read confirmation is stale; re-read document with armDelete=true", {
298
+ code: "DELETE_READ_TOKEN_STALE",
299
+ id: candidate.id,
300
+ expectedRevision,
301
+ actualRevision,
302
+ });
303
+ }
304
+
305
+ const deleted = await ctx.client.call("documents.delete", { id: candidate.id }, { maxAttempts });
306
+ const success = deleted.body?.success !== false;
307
+ evidence.deleted = success;
308
+ if (success) {
309
+ await consumeDocumentDeleteReadReceipt(readToken);
310
+ }
311
+
312
+ return {
313
+ id: candidate.id,
314
+ title: candidate.title,
315
+ ok: success,
316
+ ...evidence,
317
+ };
318
+ } catch (err) {
319
+ if (err instanceof ApiError || err instanceof CliError) {
320
+ return {
321
+ id: candidate.id,
322
+ title: candidate.title,
323
+ ok: false,
324
+ status: err instanceof ApiError ? err.details.status : undefined,
325
+ error: err.message,
326
+ ...evidence,
327
+ };
328
+ }
329
+ throw err;
330
+ }
331
+ }
332
+
333
+ async function directDeleteCandidate(ctx, candidate, maxAttempts) {
334
+ const evidence = {
335
+ tokenIssued: false,
336
+ revisionChecked: false,
337
+ deleted: false,
338
+ };
339
+ try {
340
+ const deleted = await ctx.client.call("documents.delete", { id: candidate.id }, { maxAttempts });
341
+ const success = deleted.body?.success !== false;
342
+ evidence.deleted = success;
343
+ return {
344
+ id: candidate.id,
345
+ title: candidate.title,
346
+ ok: success,
347
+ ...evidence,
348
+ };
349
+ } catch (err) {
350
+ if (err instanceof ApiError) {
351
+ return {
352
+ id: candidate.id,
353
+ title: candidate.title,
354
+ ok: false,
355
+ status: err.details.status,
356
+ error: err.message,
357
+ ...evidence,
358
+ };
359
+ }
360
+ throw err;
361
+ }
362
+ }
363
+
364
+ async function documentsCleanupTestTool(ctx, args) {
365
+ const markerPrefix = args.markerPrefix || "outline-cli-live-test-";
366
+ const dryRun = toBoolean(args.dryRun, true);
367
+ const deleteMode = args.deleteMode === "direct" ? "direct" : "safe";
368
+ const olderThanHours = toInteger(args.olderThanHours, 0);
369
+ const maxPages = Math.max(1, Math.min(50, toInteger(args.maxPages, 8)));
370
+ const pageLimit = Math.max(1, Math.min(100, toInteger(args.pageLimit, 50)));
371
+ const concurrency = Math.max(1, Math.min(10, toInteger(args.concurrency, 3)));
372
+ const allowUnsafePrefix = toBoolean(args.allowUnsafePrefix, false);
373
+ const includeErrors = toBoolean(args.includeErrors, true);
374
+
375
+ assertSafeMarkerPrefix(markerPrefix, allowUnsafePrefix);
376
+
377
+ if (!dryRun) {
378
+ assertPerformAction(args, {
379
+ tool: "documents.cleanup_test",
380
+ action: "delete test documents",
381
+ });
382
+ }
383
+
384
+ const scanned = [];
385
+ let offset = 0;
386
+ for (let page = 0; page < maxPages; page += 1) {
387
+ const res = await ctx.client.call("documents.search_titles", {
388
+ query: markerPrefix,
389
+ limit: pageLimit,
390
+ offset,
391
+ sort: "updatedAt",
392
+ direction: "DESC",
393
+ });
394
+
395
+ const rows = Array.isArray(res.body?.data) ? res.body.data : [];
396
+ scanned.push(...rows);
397
+
398
+ if (rows.length < pageLimit) {
399
+ break;
400
+ }
401
+ offset += pageLimit;
402
+ }
403
+
404
+ // Some deployments return limited title-search results by default.
405
+ // We add a second bounded pass explicitly targeting draft status.
406
+ let draftOffset = 0;
407
+ for (let page = 0; page < maxPages; page += 1) {
408
+ try {
409
+ const draftRes = await ctx.client.call("documents.search_titles", {
410
+ query: markerPrefix,
411
+ limit: pageLimit,
412
+ offset: draftOffset,
413
+ sort: "updatedAt",
414
+ direction: "DESC",
415
+ statusFilter: ["draft"],
416
+ });
417
+
418
+ const rows = Array.isArray(draftRes.body?.data) ? draftRes.body.data : [];
419
+ scanned.push(...rows);
420
+
421
+ if (rows.length < pageLimit) {
422
+ break;
423
+ }
424
+ draftOffset += pageLimit;
425
+ } catch {
426
+ break;
427
+ }
428
+ }
429
+
430
+ // Final fallback for deployments where search_titles omits recent drafts.
431
+ let listOffset = 0;
432
+ for (let page = 0; page < maxPages; page += 1) {
433
+ try {
434
+ const listRes = await ctx.client.call("documents.list", {
435
+ limit: pageLimit,
436
+ offset: listOffset,
437
+ sort: "updatedAt",
438
+ direction: "DESC",
439
+ });
440
+
441
+ const rows = Array.isArray(listRes.body?.data) ? listRes.body.data : [];
442
+ scanned.push(...rows);
443
+
444
+ if (rows.length < pageLimit) {
445
+ break;
446
+ }
447
+ listOffset += pageLimit;
448
+ } catch {
449
+ break;
450
+ }
451
+ }
452
+
453
+ const dedup = new Map();
454
+ for (const row of scanned) {
455
+ const candidate = normalizeDeleteCandidate(row, markerPrefix);
456
+ if (!candidate) {
457
+ continue;
458
+ }
459
+ if (!isOlderThan(candidate, olderThanHours)) {
460
+ continue;
461
+ }
462
+ dedup.set(candidate.id, candidate);
463
+ }
464
+
465
+ const candidates = Array.from(dedup.values()).sort((a, b) => {
466
+ const aAt = Date.parse(a.updatedAt || a.createdAt || 0);
467
+ const bAt = Date.parse(b.updatedAt || b.createdAt || 0);
468
+ return bAt - aAt;
469
+ });
470
+
471
+ if (dryRun) {
472
+ return {
473
+ tool: "documents.cleanup_test",
474
+ profile: ctx.profile.id,
475
+ result: {
476
+ markerPrefix,
477
+ deleteMode,
478
+ dryRun: true,
479
+ scannedCount: scanned.length,
480
+ candidateCount: candidates.length,
481
+ deletedCount: 0,
482
+ candidates,
483
+ },
484
+ };
485
+ }
486
+
487
+ const deleteMaxAttempts = 1;
488
+ const deleteResults = await mapLimit(candidates, concurrency, async (candidate) => {
489
+ if (deleteMode === "direct") {
490
+ return directDeleteCandidate(ctx, candidate, deleteMaxAttempts);
491
+ }
492
+ return safeDeleteCandidate(ctx, candidate, deleteMaxAttempts);
493
+ });
494
+
495
+ const failures = deleteResults.filter((r) => !r.ok);
496
+ const deleted = deleteResults.filter((r) => r.ok);
497
+
498
+ return {
499
+ tool: "documents.cleanup_test",
500
+ profile: ctx.profile.id,
501
+ result: {
502
+ markerPrefix,
503
+ deleteMode,
504
+ dryRun: false,
505
+ scannedCount: scanned.length,
506
+ candidateCount: candidates.length,
507
+ deletedCount: deleted.length,
508
+ failedCount: failures.length,
509
+ deleted,
510
+ errors: includeErrors ? failures : undefined,
511
+ },
512
+ };
513
+ }
514
+
515
+ export const PLATFORM_TOOLS = {
516
+ "capabilities.map": {
517
+ signature: "capabilities.map(args?: { includePolicies?: boolean; includeRaw?: boolean })",
518
+ description: "Return effective profile capabilities from auth context and optional policy summary.",
519
+ usageExample: {
520
+ tool: "capabilities.map",
521
+ args: {
522
+ includePolicies: true,
523
+ },
524
+ },
525
+ bestPractices: [
526
+ "Call once before planning mutating operations.",
527
+ "Use includePolicies=true when you need per-resource ability inference.",
528
+ "Keep includeRaw=false unless debugging capability mismatches.",
529
+ ],
530
+ handler: capabilitiesMapTool,
531
+ },
532
+ "documents.cleanup_test": {
533
+ signature:
534
+ "documents.cleanup_test(args?: { markerPrefix?: string; olderThanHours?: number; dryRun?: boolean; deleteMode?: 'safe'|'direct'; maxPages?: number; pageLimit?: number; concurrency?: number; allowUnsafePrefix?: boolean; performAction?: boolean })",
535
+ description: "Find and optionally delete test-created documents by marker prefix.",
536
+ usageExample: {
537
+ tool: "documents.cleanup_test",
538
+ args: {
539
+ markerPrefix: "outline-cli-live-test-",
540
+ olderThanHours: 24,
541
+ dryRun: true,
542
+ deleteMode: "safe",
543
+ },
544
+ },
545
+ bestPractices: [
546
+ "Use dryRun=true first to review deletion set.",
547
+ "Keep markerPrefix specific to your test suite to avoid accidental deletes.",
548
+ "Use deleteMode=safe (default) to enforce read-token and revision checks before delete.",
549
+ "Avoid allowUnsafePrefix unless operating in an isolated sandbox.",
550
+ "When dryRun=false this tool is action-gated; set performAction=true only after explicit confirmation.",
551
+ ],
552
+ handler: documentsCleanupTestTool,
553
+ },
554
+ };