@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,279 @@
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
+ import { spawnSync } from "node:child_process";
7
+
8
+ const REPO_ROOT = process.cwd();
9
+ const CLI_BIN = path.join(REPO_ROOT, "bin", "outline-cli.js");
10
+
11
+ function tryParseJson(text) {
12
+ if (!text) {
13
+ return null;
14
+ }
15
+ try {
16
+ return JSON.parse(text);
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ function runCli(args, opts = {}) {
23
+ const res = spawnSync(process.execPath, [CLI_BIN, ...args], {
24
+ cwd: REPO_ROOT,
25
+ encoding: "utf8",
26
+ env: {
27
+ ...process.env,
28
+ OUTLINE_CLI_KEYCHAIN_MODE: "disabled",
29
+ OUTLINE_CLI_SKIP_INTEGRITY_CHECK: "true",
30
+ },
31
+ });
32
+
33
+ const stdout = (res.stdout || "").trim();
34
+ const stderr = (res.stderr || "").trim();
35
+ const expectCode = opts.expectCode ?? 0;
36
+
37
+ if (res.status !== expectCode) {
38
+ throw new Error(
39
+ [
40
+ `Command failed: node ${CLI_BIN} ${args.join(" ")}`,
41
+ `Exit code: ${res.status} (expected ${expectCode})`,
42
+ stdout ? `STDOUT:\n${stdout}` : "",
43
+ stderr ? `STDERR:\n${stderr}` : "",
44
+ ]
45
+ .filter(Boolean)
46
+ .join("\n\n")
47
+ );
48
+ }
49
+
50
+ return {
51
+ status: res.status,
52
+ stdout,
53
+ stderr,
54
+ stdoutJson: tryParseJson(stdout),
55
+ stderrJson: tryParseJson(stderr),
56
+ };
57
+ }
58
+
59
+ test("profile selection supports explicit, default, and single-profile fallback rules", async () => {
60
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "outline-cli-profile-selection-"));
61
+ const configPath = path.join(tmpDir, "config.json");
62
+
63
+ try {
64
+ const addAlpha = runCli([
65
+ "profile",
66
+ "add",
67
+ "alpha",
68
+ "--config",
69
+ configPath,
70
+ "--base-url",
71
+ "https://alpha.example.com",
72
+ "--api-key",
73
+ "ol_api_alpha123",
74
+ ]);
75
+ assert.equal(addAlpha.stdoutJson?.defaultProfile, null, "first add should not auto-set default");
76
+
77
+ const singleFallback = runCli([
78
+ "invoke",
79
+ "no.such.tool",
80
+ "--config",
81
+ configPath,
82
+ "--args",
83
+ "{}",
84
+ ], { expectCode: 1 });
85
+ assert.match(singleFallback.stderrJson?.error?.message || "", /Unknown tool: no\.such\.tool/);
86
+
87
+ const addBeta = runCli([
88
+ "profile",
89
+ "add",
90
+ "beta",
91
+ "--config",
92
+ configPath,
93
+ "--base-url",
94
+ "https://beta.example.com",
95
+ "--api-key",
96
+ "ol_api_beta123",
97
+ ]);
98
+ assert.equal(addBeta.stdoutJson?.defaultProfile, null, "adding a second profile should keep default unset");
99
+
100
+ const ambiguous = runCli([
101
+ "invoke",
102
+ "no.such.tool",
103
+ "--config",
104
+ configPath,
105
+ "--args",
106
+ "{}",
107
+ ], { expectCode: 1 });
108
+ assert.match(
109
+ ambiguous.stderrJson?.error?.message || "",
110
+ /Profile selection required: multiple profiles are saved and no default profile is set/
111
+ );
112
+
113
+ const explicitProfile = runCli([
114
+ "invoke",
115
+ "no.such.tool",
116
+ "--config",
117
+ configPath,
118
+ "--profile",
119
+ "beta",
120
+ "--args",
121
+ "{}",
122
+ ], { expectCode: 1 });
123
+ assert.match(explicitProfile.stderrJson?.error?.message || "", /Unknown tool: no\.such\.tool/);
124
+
125
+ const useBeta = runCli(["profile", "use", "beta", "--config", configPath]);
126
+ assert.equal(useBeta.stdoutJson?.defaultProfile, "beta");
127
+
128
+ const withDefault = runCli([
129
+ "invoke",
130
+ "no.such.tool",
131
+ "--config",
132
+ configPath,
133
+ "--args",
134
+ "{}",
135
+ ], { expectCode: 1 });
136
+ assert.match(withDefault.stderrJson?.error?.message || "", /Unknown tool: no\.such\.tool/);
137
+
138
+ const removeDefault = runCli([
139
+ "profile",
140
+ "remove",
141
+ "beta",
142
+ "--config",
143
+ configPath,
144
+ "--force",
145
+ ]);
146
+ assert.equal(removeDefault.stdoutJson?.defaultProfile, null, "forced default removal should clear default");
147
+
148
+ const fallbackAfterRemove = runCli([
149
+ "invoke",
150
+ "no.such.tool",
151
+ "--config",
152
+ configPath,
153
+ "--args",
154
+ "{}",
155
+ ], { expectCode: 1 });
156
+ assert.match(fallbackAfterRemove.stderrJson?.error?.message || "", /Unknown tool: no\.such\.tool/);
157
+ } finally {
158
+ await fs.rm(tmpDir, { recursive: true, force: true });
159
+ }
160
+ });
161
+
162
+ test("profile metadata annotate/suggest supports AI-oriented source routing", async () => {
163
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "outline-cli-profile-metadata-"));
164
+ const configPath = path.join(tmpDir, "config.json");
165
+
166
+ try {
167
+ const addEngineering = runCli([
168
+ "profile",
169
+ "add",
170
+ "engineering",
171
+ "--config",
172
+ configPath,
173
+ "--base-url",
174
+ "wiki.example.com/outline/doc/incident-runbook-abc123",
175
+ "--api-key",
176
+ "ol_api_alpha123",
177
+ "--description",
178
+ "Incident and runbook knowledge base",
179
+ "--keywords",
180
+ "incident,runbook,sre",
181
+ ]);
182
+ assert.equal(addEngineering.stdoutJson?.profile?.description, "Incident and runbook knowledge base");
183
+ assert.deepEqual(addEngineering.stdoutJson?.profile?.keywords, ["incident", "runbook", "sre"]);
184
+ assert.equal(addEngineering.stdoutJson?.profile?.baseUrl, "https://wiki.example.com/outline");
185
+ assert.equal(addEngineering.stdoutJson?.endpoint?.autoCorrected, true);
186
+
187
+ runCli([
188
+ "profile",
189
+ "add",
190
+ "marketing",
191
+ "--config",
192
+ configPath,
193
+ "--base-url",
194
+ "https://handbook.acme.example",
195
+ "--api-key",
196
+ "ol_api_beta123",
197
+ "--description",
198
+ "Campaign and event tracking handbook",
199
+ "--keywords",
200
+ "tracking,campaign,analytics",
201
+ "--set-default",
202
+ ]);
203
+
204
+ const annotate = runCli([
205
+ "profile",
206
+ "annotate",
207
+ "marketing",
208
+ "--config",
209
+ configPath,
210
+ "--append-keywords",
211
+ "landing page,utm",
212
+ ]);
213
+ assert.deepEqual(annotate.stdoutJson?.profile?.keywords, [
214
+ "tracking",
215
+ "campaign",
216
+ "analytics",
217
+ "landing page",
218
+ "utm",
219
+ ]);
220
+
221
+ const suggested = runCli([
222
+ "profile",
223
+ "suggest",
224
+ "landing page tracking events",
225
+ "--config",
226
+ configPath,
227
+ "--limit",
228
+ "2",
229
+ ]);
230
+ assert.equal(suggested.stdoutJson?.bestMatch?.id, "marketing");
231
+ assert.ok(Array.isArray(suggested.stdoutJson?.matches));
232
+ assert.equal(suggested.stdoutJson?.matches?.length, 2);
233
+ assert.ok(Number(suggested.stdoutJson?.matches?.[0]?.score || 0) >= Number(suggested.stdoutJson?.matches?.[1]?.score || 0));
234
+ } finally {
235
+ await fs.rm(tmpDir, { recursive: true, force: true });
236
+ }
237
+ });
238
+
239
+ test("profile add auto metadata + enrich can learn from query/title/url hints", async () => {
240
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "outline-cli-profile-enrich-"));
241
+ const configPath = path.join(tmpDir, "config.json");
242
+
243
+ try {
244
+ const addProfile = runCli([
245
+ "profile",
246
+ "add",
247
+ "marketing-handbook",
248
+ "--config",
249
+ configPath,
250
+ "--base-url",
251
+ "https://handbook.acme.example/doc/event-tracking-data-A7hLXuHZJl",
252
+ "--api-key",
253
+ "ol_api_marketing123",
254
+ ]);
255
+ assert.equal(addProfile.stdoutJson?.profile?.baseUrl, "https://handbook.acme.example");
256
+ assert.ok(typeof addProfile.stdoutJson?.profile?.description === "string");
257
+ assert.ok((addProfile.stdoutJson?.profile?.keywords || []).includes("marketing"));
258
+ assert.equal(addProfile.stdoutJson?.metadata?.autoGenerated, true);
259
+
260
+ const enrich = runCli([
261
+ "profile",
262
+ "enrich",
263
+ "marketing-handbook",
264
+ "--config",
265
+ configPath,
266
+ "--query",
267
+ "implement tracking collection for landing page",
268
+ "--titles",
269
+ "event tracking data,campaign detail page",
270
+ "--urls",
271
+ "https://handbook.acme.example/doc/campaign-detail-page-GWK1uA8w35",
272
+ ]);
273
+ assert.equal(enrich.stdoutJson?.changed, true);
274
+ assert.ok((enrich.stdoutJson?.delta?.addedKeywords || []).includes("landing page"));
275
+ assert.ok((enrich.stdoutJson?.delta?.addedKeywords || []).includes("event tracking"));
276
+ } finally {
277
+ await fs.rm(tmpDir, { recursive: true, force: true });
278
+ }
279
+ });
@@ -0,0 +1,113 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ getKeychainMode,
5
+ hydrateProfileFromKeychain,
6
+ removeProfileFromKeychain,
7
+ secureProfileForStorage,
8
+ } from "../src/secure-keyring.js";
9
+ import { CliError } from "../src/errors.js";
10
+
11
+ function withEnv(name, value, fn) {
12
+ const previous = process.env[name];
13
+ if (value == null) {
14
+ delete process.env[name];
15
+ } else {
16
+ process.env[name] = value;
17
+ }
18
+ try {
19
+ return fn();
20
+ } finally {
21
+ if (previous == null) {
22
+ delete process.env[name];
23
+ } else {
24
+ process.env[name] = previous;
25
+ }
26
+ }
27
+ }
28
+
29
+ test("keychain mode normalization supports required/optional/disabled", () => {
30
+ withEnv("OUTLINE_CLI_KEYCHAIN_MODE", "disabled", () => {
31
+ assert.equal(getKeychainMode(), "disabled");
32
+ });
33
+ withEnv("OUTLINE_CLI_KEYCHAIN_MODE", "optional", () => {
34
+ assert.equal(getKeychainMode(), "optional");
35
+ });
36
+ withEnv("OUTLINE_CLI_KEYCHAIN_MODE", "required", () => {
37
+ assert.equal(getKeychainMode(), "required");
38
+ });
39
+ withEnv("OUTLINE_CLI_KEYCHAIN_MODE", "invalid", () => {
40
+ assert.equal(getKeychainMode(), "required");
41
+ });
42
+ });
43
+
44
+ test("secureProfileForStorage keeps inline secret when keychain mode is disabled", () => {
45
+ withEnv("OUTLINE_CLI_KEYCHAIN_MODE", "disabled", () => {
46
+ const profile = {
47
+ name: "Local",
48
+ baseUrl: "https://example.com",
49
+ timeoutMs: 30000,
50
+ headers: {},
51
+ auth: {
52
+ type: "apiKey",
53
+ apiKey: "ol_api_123456789",
54
+ },
55
+ };
56
+ const secured = secureProfileForStorage({
57
+ configPath: "/tmp/config.json",
58
+ profileId: "local",
59
+ profile,
60
+ });
61
+
62
+ assert.equal(secured.keychain.used, false);
63
+ assert.equal(secured.profile.auth.apiKey, "ol_api_123456789");
64
+ assert.equal(secured.profile.auth.credentialStore, "config-inline");
65
+ });
66
+ });
67
+
68
+ test("hydrateProfileFromKeychain fails fast when keychain mode disabled and secret is external", () => {
69
+ withEnv("OUTLINE_CLI_KEYCHAIN_MODE", "disabled", () => {
70
+ assert.throws(
71
+ () =>
72
+ hydrateProfileFromKeychain({
73
+ configPath: "/tmp/config.json",
74
+ profile: {
75
+ id: "local",
76
+ auth: {
77
+ type: "apiKey",
78
+ credentialStore: "os-keychain",
79
+ credentialRef: {
80
+ service: "com.khanglvm.outline-cli",
81
+ account: "profile-auth:deadbeef:local",
82
+ },
83
+ },
84
+ },
85
+ }),
86
+ (err) => {
87
+ assert.ok(err instanceof CliError);
88
+ assert.equal(err.details?.code, "KEYCHAIN_DISABLED");
89
+ return true;
90
+ }
91
+ );
92
+ });
93
+ });
94
+
95
+ test("removeProfileFromKeychain is a no-op for inline credential storage", () => {
96
+ const result = withEnv("OUTLINE_CLI_KEYCHAIN_MODE", "disabled", () =>
97
+ removeProfileFromKeychain({
98
+ configPath: "/tmp/config.json",
99
+ profileId: "local",
100
+ profile: {
101
+ auth: {
102
+ type: "password",
103
+ credentialStore: "config-inline",
104
+ username: "user@example.com",
105
+ password: "secret",
106
+ },
107
+ },
108
+ })
109
+ );
110
+
111
+ assert.equal(result.removed, false);
112
+ assert.equal(result.reason, "inline-storage");
113
+ });