@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/src/utils.js ADDED
@@ -0,0 +1,176 @@
1
+ import fs from "node:fs/promises";
2
+
3
+ export async function parseJsonArg({ json, file, name }) {
4
+ if (json && file) {
5
+ throw new Error(`Provide either --${name} or --${name}-file, not both`);
6
+ }
7
+
8
+ let raw;
9
+ if (file) {
10
+ raw = await fs.readFile(file, "utf8");
11
+ } else if (json) {
12
+ raw = json;
13
+ } else {
14
+ return undefined;
15
+ }
16
+
17
+ try {
18
+ return JSON.parse(raw);
19
+ } catch (err) {
20
+ throw new Error(`Invalid JSON for ${name}: ${err.message}`);
21
+ }
22
+ }
23
+
24
+ export function compactValue(value) {
25
+ if (Array.isArray(value)) {
26
+ return value
27
+ .map((item) => compactValue(item))
28
+ .filter((item) => item !== undefined);
29
+ }
30
+
31
+ if (value && typeof value === "object") {
32
+ const result = {};
33
+ for (const [key, child] of Object.entries(value)) {
34
+ const compacted = compactValue(child);
35
+ if (compacted === undefined) {
36
+ continue;
37
+ }
38
+ if (Array.isArray(compacted) && compacted.length === 0) {
39
+ continue;
40
+ }
41
+ if (
42
+ compacted &&
43
+ typeof compacted === "object" &&
44
+ !Array.isArray(compacted) &&
45
+ Object.keys(compacted).length === 0
46
+ ) {
47
+ continue;
48
+ }
49
+ result[key] = compacted;
50
+ }
51
+ if (Object.keys(result).length === 0) {
52
+ return undefined;
53
+ }
54
+ return result;
55
+ }
56
+
57
+ if (value === null || value === undefined) {
58
+ return undefined;
59
+ }
60
+ return value;
61
+ }
62
+
63
+ export function parseCsv(input) {
64
+ if (!input) {
65
+ return [];
66
+ }
67
+ return input
68
+ .split(",")
69
+ .map((part) => part.trim())
70
+ .filter(Boolean);
71
+ }
72
+
73
+ export function pickPath(obj, path) {
74
+ if (!path) {
75
+ return undefined;
76
+ }
77
+ const chunks = path.split(".").filter(Boolean);
78
+ let cur = obj;
79
+ for (const chunk of chunks) {
80
+ if (cur == null || typeof cur !== "object") {
81
+ return undefined;
82
+ }
83
+ cur = cur[chunk];
84
+ }
85
+ return cur;
86
+ }
87
+
88
+ export function projectObject(obj, fields) {
89
+ if (!fields || fields.length === 0) {
90
+ return obj;
91
+ }
92
+ const result = {};
93
+ for (const field of fields) {
94
+ const value = pickPath(obj, field);
95
+ if (value !== undefined) {
96
+ result[field] = value;
97
+ }
98
+ }
99
+ return result;
100
+ }
101
+
102
+ export function ensureStringArray(value, label) {
103
+ if (value === undefined || value === null) {
104
+ return undefined;
105
+ }
106
+
107
+ if (Array.isArray(value)) {
108
+ return value.map((item) => String(item));
109
+ }
110
+
111
+ if (typeof value === "string") {
112
+ return [value];
113
+ }
114
+
115
+ throw new Error(`${label} must be a string or string[]`);
116
+ }
117
+
118
+ export function nowIso() {
119
+ return new Date().toISOString();
120
+ }
121
+
122
+ export function sleep(ms) {
123
+ return new Promise((resolve) => setTimeout(resolve, ms));
124
+ }
125
+
126
+ export function sanitizeFileToken(input) {
127
+ return String(input || "result").replace(/[^a-zA-Z0-9._-]/g, "_");
128
+ }
129
+
130
+ export function toInteger(value, fallback) {
131
+ if (value === undefined || value === null || value === "") {
132
+ return fallback;
133
+ }
134
+ const parsed = Number(value);
135
+ if (!Number.isFinite(parsed)) {
136
+ throw new Error(`Invalid number: ${value}`);
137
+ }
138
+ return Math.trunc(parsed);
139
+ }
140
+
141
+ export function toBoolean(value, fallback = undefined) {
142
+ if (value === undefined || value === null || value === "") {
143
+ return fallback;
144
+ }
145
+ if (typeof value === "boolean") {
146
+ return value;
147
+ }
148
+ const normalized = String(value).toLowerCase();
149
+ if (["1", "true", "yes", "on"].includes(normalized)) {
150
+ return true;
151
+ }
152
+ if (["0", "false", "no", "off"].includes(normalized)) {
153
+ return false;
154
+ }
155
+ throw new Error(`Invalid boolean value: ${value}`);
156
+ }
157
+
158
+ export async function mapLimit(items, limit, fn) {
159
+ if (!Array.isArray(items) || items.length === 0) {
160
+ return [];
161
+ }
162
+ const concurrency = Math.max(1, Math.min(limit || 1, items.length));
163
+ const out = new Array(items.length);
164
+ let idx = 0;
165
+
166
+ async function worker() {
167
+ while (idx < items.length) {
168
+ const current = idx;
169
+ idx += 1;
170
+ out[current] = await fn(items[current], current);
171
+ }
172
+ }
173
+
174
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
175
+ return out;
176
+ }
@@ -0,0 +1,157 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ import { CliError } from "../src/errors.js";
8
+ import {
9
+ assertPerformAction,
10
+ consumeDocumentDeleteReadReceipt,
11
+ getActionGateStorePath,
12
+ getDocumentDeleteReadReceipt,
13
+ isLikelyMutatingMethod,
14
+ issueDocumentDeleteReadReceipt,
15
+ } from "../src/action-gate.js";
16
+
17
+ test("assertPerformAction blocks when performAction is not true", () => {
18
+ assert.throws(
19
+ () =>
20
+ assertPerformAction({}, {
21
+ tool: "documents.update",
22
+ action: "update a document",
23
+ }),
24
+ (err) => {
25
+ assert.ok(err instanceof CliError);
26
+ assert.equal(err.details?.code, "ACTION_GATED");
27
+ return true;
28
+ }
29
+ );
30
+
31
+ assert.doesNotThrow(() =>
32
+ assertPerformAction({ performAction: true }, {
33
+ tool: "documents.update",
34
+ action: "update a document",
35
+ })
36
+ );
37
+ });
38
+
39
+ test("isLikelyMutatingMethod treats import/export paths as mutating", () => {
40
+ assert.equal(isLikelyMutatingMethod("documents.import"), true);
41
+ assert.equal(isLikelyMutatingMethod("documents.importFile"), true);
42
+ assert.equal(isLikelyMutatingMethod("/documents.export"), true);
43
+ assert.equal(isLikelyMutatingMethod("fileOperations.delete"), true);
44
+ assert.equal(isLikelyMutatingMethod("fileOperations.info"), false);
45
+ });
46
+
47
+ test("delete read receipt lifecycle: issue -> validate -> consume", async () => {
48
+ const previousTmp = process.env.OUTLINE_CLI_TMP_DIR;
49
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "outline-cli-action-gate-"));
50
+ process.env.OUTLINE_CLI_TMP_DIR = tmpDir;
51
+
52
+ try {
53
+ const issued = await issueDocumentDeleteReadReceipt({
54
+ profileId: "test-profile",
55
+ documentId: "doc-123",
56
+ revision: 7,
57
+ title: "Doc 123",
58
+ ttlSeconds: 600,
59
+ });
60
+
61
+ assert.ok(typeof issued.token === "string" && issued.token.length > 0);
62
+ assert.equal(issued.documentId, "doc-123");
63
+ assert.equal(issued.revision, 7);
64
+
65
+ const storePath = getActionGateStorePath();
66
+ assert.ok(storePath.startsWith(path.resolve(tmpDir)));
67
+
68
+ const fetched = await getDocumentDeleteReadReceipt({
69
+ token: issued.token,
70
+ profileId: "test-profile",
71
+ documentId: "doc-123",
72
+ });
73
+ assert.equal(fetched.documentId, "doc-123");
74
+ assert.equal(fetched.revision, 7);
75
+
76
+ await assert.rejects(
77
+ () =>
78
+ getDocumentDeleteReadReceipt({
79
+ token: issued.token,
80
+ profileId: "test-profile",
81
+ documentId: "doc-999",
82
+ }),
83
+ (err) => {
84
+ assert.ok(err instanceof CliError);
85
+ assert.equal(err.details?.code, "DELETE_READ_TOKEN_DOCUMENT_MISMATCH");
86
+ return true;
87
+ }
88
+ );
89
+
90
+ const consumed = await consumeDocumentDeleteReadReceipt(issued.token);
91
+ assert.equal(consumed, true);
92
+
93
+ await assert.rejects(
94
+ () =>
95
+ getDocumentDeleteReadReceipt({
96
+ token: issued.token,
97
+ profileId: "test-profile",
98
+ documentId: "doc-123",
99
+ }),
100
+ (err) => {
101
+ assert.ok(err instanceof CliError);
102
+ assert.equal(err.details?.code, "DELETE_READ_TOKEN_INVALID");
103
+ return true;
104
+ }
105
+ );
106
+ } finally {
107
+ if (previousTmp == null) {
108
+ delete process.env.OUTLINE_CLI_TMP_DIR;
109
+ } else {
110
+ process.env.OUTLINE_CLI_TMP_DIR = previousTmp;
111
+ }
112
+ await fs.rm(tmpDir, { recursive: true, force: true });
113
+ }
114
+ });
115
+
116
+ test("delete read receipt only accepts document-scoped token kind", async () => {
117
+ const previousTmp = process.env.OUTLINE_CLI_TMP_DIR;
118
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "outline-cli-action-gate-kind-"));
119
+ process.env.OUTLINE_CLI_TMP_DIR = tmpDir;
120
+
121
+ try {
122
+ const issued = await issueDocumentDeleteReadReceipt({
123
+ profileId: "test-profile",
124
+ documentId: "doc-123",
125
+ revision: 7,
126
+ title: "Doc 123",
127
+ ttlSeconds: 600,
128
+ });
129
+
130
+ const storePath = getActionGateStorePath();
131
+ const raw = await fs.readFile(storePath, "utf8");
132
+ const parsed = JSON.parse(raw);
133
+ parsed.receipts[issued.token].kind = "webhooks.delete.read";
134
+ await fs.writeFile(storePath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
135
+
136
+ await assert.rejects(
137
+ () =>
138
+ getDocumentDeleteReadReceipt({
139
+ token: issued.token,
140
+ profileId: "test-profile",
141
+ documentId: "doc-123",
142
+ }),
143
+ (err) => {
144
+ assert.ok(err instanceof CliError);
145
+ assert.equal(err.details?.code, "DELETE_READ_TOKEN_INVALID");
146
+ return true;
147
+ }
148
+ );
149
+ } finally {
150
+ if (previousTmp == null) {
151
+ delete process.env.OUTLINE_CLI_TMP_DIR;
152
+ } else {
153
+ process.env.OUTLINE_CLI_TMP_DIR = previousTmp;
154
+ }
155
+ await fs.rm(tmpDir, { recursive: true, force: true });
156
+ }
157
+ });
@@ -0,0 +1,52 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { getAgentSkillHelp, listHelpSections } from "../src/agent-skills.js";
5
+ import { CliError } from "../src/errors.js";
6
+
7
+ test("listHelpSections exposes ai-skills section", () => {
8
+ const sections = listHelpSections();
9
+ assert.ok(Array.isArray(sections));
10
+ assert.ok(sections.some((section) => section.id === "ai-skills"));
11
+ });
12
+
13
+ test("getAgentSkillHelp returns summary guidance by default", () => {
14
+ const payload = getAgentSkillHelp();
15
+
16
+ assert.equal(payload.section, "ai-skills");
17
+ assert.equal(payload.view, "summary");
18
+ assert.ok(payload.totalSkills >= 10);
19
+ assert.equal(payload.skills.length, payload.returnedSkills);
20
+ assert.ok(payload.globalGuidance?.principles?.length >= 1);
21
+ assert.ok(payload.skills.some((skill) => skill.id === "legacy_wiki_migration"));
22
+ });
23
+
24
+ test("getAgentSkillHelp filters by scenario and query", () => {
25
+ const byScenario = getAgentSkillHelp({ scenario: "uc-19" });
26
+ assert.equal(byScenario.returnedSkills, 1);
27
+ assert.equal(byScenario.skills[0].id, "oauth_compliance_audit");
28
+
29
+ const byQuery = getAgentSkillHelp({ query: "documents.import_file" });
30
+ assert.ok(byQuery.skills.some((skill) => skill.id === "legacy_wiki_migration"));
31
+ });
32
+
33
+ test("getAgentSkillHelp returns full skill payload and validates view", () => {
34
+ const payload = getAgentSkillHelp({
35
+ skill: "template_pipeline_execution",
36
+ view: "full",
37
+ });
38
+
39
+ assert.equal(payload.returnedSkills, 1);
40
+ assert.equal(payload.skills[0].id, "template_pipeline_execution");
41
+ assert.ok(Array.isArray(payload.skills[0].sequence));
42
+ assert.ok(payload.skills[0].sequence.some((step) => step.tool === "templates.extract_placeholders"));
43
+
44
+ assert.throws(
45
+ () => getAgentSkillHelp({ view: "compact" }),
46
+ (err) => {
47
+ assert.ok(err instanceof CliError);
48
+ assert.equal(err.details?.code, "AI_HELP_INVALID_VIEW");
49
+ return true;
50
+ }
51
+ );
52
+ });
@@ -0,0 +1,89 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ buildProfile,
5
+ normalizeBaseUrlWithHints,
6
+ suggestProfileMetadata,
7
+ suggestProfiles,
8
+ } from "../src/config-store.js";
9
+
10
+ test("normalizeBaseUrlWithHints auto-corrects official and document URLs", () => {
11
+ const official = normalizeBaseUrlWithHints("https://www.getoutline.com");
12
+ assert.equal(official.baseUrl, "https://app.getoutline.com");
13
+ assert.equal(official.corrected, true);
14
+ assert.ok(official.corrections.includes("mapped_official_outline_host"));
15
+
16
+ const docUrl = normalizeBaseUrlWithHints(
17
+ "https://handbook.acme.example/doc/event-tracking-data-A7hLXuHZJl#d-A7hLXuHZJl"
18
+ );
19
+ assert.equal(docUrl.baseUrl, "https://handbook.acme.example");
20
+ assert.equal(docUrl.corrected, true);
21
+ assert.ok(docUrl.corrections.includes("trimmed_ui_path"));
22
+
23
+ const reverseProxy = normalizeBaseUrlWithHints("https://wiki.example.com/outline/api/auth.info");
24
+ assert.equal(reverseProxy.baseUrl, "https://wiki.example.com/outline");
25
+ assert.ok(reverseProxy.corrections.includes("trimmed_path_after_api"));
26
+ });
27
+
28
+ test("buildProfile persists optional description and keywords metadata", () => {
29
+ const profile = buildProfile({
30
+ id: "acme-handbook",
31
+ name: "Acme Handbook",
32
+ description: "Tracking and campaign specs",
33
+ keywords: ["tracking", "campaign", "tracking", " event "],
34
+ baseUrl: "https://handbook.acme.example/doc/event-tracking-data-A7hLXuHZJl",
35
+ authType: "apiKey",
36
+ apiKey: "ol_api_example",
37
+ });
38
+
39
+ assert.equal(profile.description, "Tracking and campaign specs");
40
+ assert.deepEqual(profile.keywords, ["tracking", "campaign", "event"]);
41
+ assert.equal(profile.baseUrl, "https://handbook.acme.example");
42
+ });
43
+
44
+ test("suggestProfiles ranks profiles by keywords/description/host signals", () => {
45
+ const config = {
46
+ version: 1,
47
+ defaultProfile: "engineering",
48
+ profiles: {
49
+ engineering: {
50
+ name: "Engineering",
51
+ baseUrl: "https://wiki.example.com",
52
+ description: "Runbooks and incident policy",
53
+ keywords: ["incident", "runbook", "sre"],
54
+ auth: { type: "apiKey", apiKey: "ol_api_eng" },
55
+ },
56
+ marketing: {
57
+ name: "Acme Handbook",
58
+ baseUrl: "https://handbook.acme.example",
59
+ description: "Marketing campaign and event tracking handbook",
60
+ keywords: ["tracking", "campaign", "analytics"],
61
+ auth: { type: "apiKey", apiKey: "ol_api_marketing" },
62
+ },
63
+ },
64
+ };
65
+
66
+ const result = suggestProfiles(config, "campaign tracking handbook", { limit: 2 });
67
+ assert.equal(result.matches.length, 2);
68
+ assert.equal(result.matches[0].id, "marketing");
69
+ assert.ok(result.matches[0].score > result.matches[1].score);
70
+ assert.ok(Array.isArray(result.matches[0].matchedOn));
71
+ });
72
+
73
+ test("suggestProfileMetadata can generate and enrich metadata from hints", () => {
74
+ const next = suggestProfileMetadata({
75
+ id: "acme-handbook",
76
+ name: "Acme Handbook",
77
+ baseUrl: "https://handbook.acme.example",
78
+ hints: [
79
+ "implement tracking collection for landing page",
80
+ "campaign detail page",
81
+ ],
82
+ }, { maxKeywords: 12 });
83
+
84
+ assert.ok(next.description.includes("Acme Handbook"));
85
+ assert.ok(next.keywords.includes("tracking"));
86
+ assert.ok(next.keywords.includes("landing page"));
87
+ assert.ok(next.keywords.includes("campaign detail"));
88
+ assert.equal(next.generated.hintsUsed, 2);
89
+ });