@kitae9999/openlog-cli 0.2.0 → 1.1.0

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/README.md CHANGED
@@ -8,16 +8,24 @@ npx -y @kitae9999/openlog-cli whoami
8
8
  npx -y @kitae9999/openlog-cli mcp
9
9
  ```
10
10
 
11
- MCP tools:
11
+ Local MCP permissions:
12
12
 
13
- - `get_auth_status`
14
- - `get_me`
15
- - `list_my_notifications`
16
- - `list_my_posts`
17
- - `list_my_liked_posts`
18
- - `upload_post_image`
19
- - `publish_post`
20
- - `get_post_detail`
13
+ ```bash
14
+ openlog mcp permissions
15
+ openlog mcp permissions set read-only
16
+ openlog mcp permissions set safe-write
17
+ openlog mcp permissions set full
18
+ openlog mcp permissions reset
19
+ ```
20
+
21
+ `safe-write` is the default. Permission changes apply after the MCP server is restarted or reloaded.
22
+
23
+ MCP tool groups:
24
+
25
+ - Account and posts: authentication, notifications, authored/liked posts, image upload, post detail, and confirmed publishing
26
+ - Workspace context: workspace discovery, activity, working brief, tasks, logs, todos, memories, outputs, and graph links
27
+ - Safe writes: create/update, todo completion, link creation, image upload, and confirmed output/post publishing
28
+ - Full-only writes: individual workspace-document/link deletes and working-brief clear
21
29
 
22
30
  MCP client configuration:
23
31
 
@@ -34,3 +42,5 @@ MCP client configuration:
34
42
 
35
43
  Set `OPENLOG_API_BASE_URL` to point at a non-production API. Set
36
44
  `OPENLOG_WEB_BASE_URL` to control absolute post URLs returned by `publish_post`.
45
+ Set `OPENLOG_MCP_CONFIG_FILE` to replace the default
46
+ `~/.openlog/mcp-config.json` permission file.
@@ -12,9 +12,14 @@ export class ApiError extends Error {
12
12
  export class OpenLogApiClient {
13
13
  apiBaseUrl;
14
14
  accessToken;
15
+ refreshToken;
16
+ onTokenRefresh;
17
+ refreshPromise;
15
18
  constructor(options = {}) {
16
19
  this.apiBaseUrl = (options.apiBaseUrl ?? getApiBaseUrl()).replace(/\/$/, "");
17
20
  this.accessToken = options.accessToken;
21
+ this.refreshToken = options.refreshToken;
22
+ this.onTokenRefresh = options.onTokenRefresh;
18
23
  }
19
24
  async get(path) {
20
25
  return this.request(path, { method: "GET" });
@@ -26,6 +31,20 @@ export class OpenLogApiClient {
26
31
  body: body ? JSON.stringify(body) : undefined,
27
32
  });
28
33
  }
34
+ async put(path, body) {
35
+ return this.request(path, {
36
+ method: "PUT",
37
+ headers: body ? { "content-type": "application/json" } : undefined,
38
+ body: body ? JSON.stringify(body) : undefined,
39
+ });
40
+ }
41
+ async patch(path, body) {
42
+ return this.request(path, {
43
+ method: "PATCH",
44
+ headers: body ? { "content-type": "application/json" } : undefined,
45
+ body: body ? JSON.stringify(body) : undefined,
46
+ });
47
+ }
29
48
  async postNoContent(path, body) {
30
49
  await this.request(path, {
31
50
  method: "POST",
@@ -40,7 +59,10 @@ export class OpenLogApiClient {
40
59
  body: body ? JSON.stringify(body) : undefined,
41
60
  });
42
61
  }
43
- async request(path, init) {
62
+ async deleteNoContent(path) {
63
+ await this.request(path, { method: "DELETE" });
64
+ }
65
+ async request(path, init, allowRefresh = true) {
44
66
  const url = `${this.apiBaseUrl}${path}`;
45
67
  const response = await fetch(url, {
46
68
  ...init,
@@ -51,15 +73,54 @@ export class OpenLogApiClient {
51
73
  : {}),
52
74
  },
53
75
  });
76
+ if (response.status === 401 && allowRefresh && this.refreshToken) {
77
+ await this.refreshAccessToken();
78
+ return this.request(path, init, false);
79
+ }
54
80
  if (!response.ok) {
55
81
  throw new ApiError(response.status, url, await readErrorMessage(response));
56
82
  }
57
83
  if (response.status === 204) {
58
84
  return undefined;
59
85
  }
60
- return (await response.json());
86
+ const responseBody = await response.text();
87
+ if (!responseBody) {
88
+ return undefined;
89
+ }
90
+ return JSON.parse(responseBody);
91
+ }
92
+ async refreshAccessToken() {
93
+ if (!this.refreshPromise) {
94
+ this.refreshPromise = this.performTokenRefresh().finally(() => {
95
+ this.refreshPromise = undefined;
96
+ });
97
+ }
98
+ await this.refreshPromise;
99
+ }
100
+ async performTokenRefresh() {
101
+ const response = await fetch(`${this.apiBaseUrl}/auth/device/refresh`, {
102
+ method: "POST",
103
+ headers: { "content-type": "application/json" },
104
+ body: JSON.stringify({ refreshToken: this.refreshToken }),
105
+ });
106
+ if (!response.ok) {
107
+ throw new ApiError(response.status, `${this.apiBaseUrl}/auth/device/refresh`, await readErrorMessage(response));
108
+ }
109
+ const tokens = (await response.json());
110
+ if (!isRefreshedTokens(tokens)) {
111
+ throw new Error("OpenLog refresh response is incomplete.");
112
+ }
113
+ this.accessToken = tokens.accessToken;
114
+ this.refreshToken = tokens.refreshToken;
115
+ await this.onTokenRefresh?.(tokens);
61
116
  }
62
117
  }
118
+ function isRefreshedTokens(value) {
119
+ return Boolean(value.accessToken &&
120
+ value.refreshToken &&
121
+ Number.isFinite(value.expiresIn) &&
122
+ Number.isFinite(value.refreshExpiresIn));
123
+ }
63
124
  async function readErrorMessage(response) {
64
125
  try {
65
126
  const body = (await response.json());
@@ -10,8 +10,11 @@ export async function readAuthFile() {
10
10
  }
11
11
  return {
12
12
  accessToken: parsed.accessToken,
13
+ refreshToken: parsed.refreshToken,
13
14
  apiBaseUrl: parsed.apiBaseUrl,
14
15
  createdAt: parsed.createdAt,
16
+ accessTokenExpiresAt: parsed.accessTokenExpiresAt,
17
+ refreshTokenExpiresAt: parsed.refreshTokenExpiresAt,
15
18
  };
16
19
  }
17
20
  catch (error) {
@@ -24,20 +27,40 @@ export async function readAuthFile() {
24
27
  throw error;
25
28
  }
26
29
  }
27
- export async function writeAuthFile(accessToken) {
30
+ export async function writeAuthFile(tokens) {
28
31
  const authFile = {
29
- accessToken,
32
+ accessToken: tokens.accessToken,
33
+ refreshToken: tokens.refreshToken,
30
34
  apiBaseUrl: getApiBaseUrl(),
31
35
  createdAt: new Date().toISOString(),
36
+ accessTokenExpiresAt: expiresAt(tokens.expiresIn),
37
+ refreshTokenExpiresAt: expiresAt(tokens.refreshExpiresIn),
32
38
  };
39
+ await persistAuthFile(authFile);
40
+ return authFile;
41
+ }
42
+ export async function updateAuthFileTokens(authFile, tokens) {
43
+ const updatedAuthFile = {
44
+ ...authFile,
45
+ accessToken: tokens.accessToken,
46
+ refreshToken: tokens.refreshToken,
47
+ accessTokenExpiresAt: expiresAt(tokens.expiresIn),
48
+ refreshTokenExpiresAt: expiresAt(tokens.refreshExpiresIn),
49
+ };
50
+ await persistAuthFile(updatedAuthFile);
51
+ return updatedAuthFile;
52
+ }
53
+ async function persistAuthFile(authFile) {
33
54
  const authFilePath = getAuthFilePath();
34
55
  await mkdir(path.dirname(authFilePath), { recursive: true, mode: 0o700 });
35
56
  await writeFile(authFilePath, `${JSON.stringify(authFile, null, 2)}\n`, {
36
57
  mode: 0o600,
37
58
  });
38
59
  await chmod(authFilePath, 0o600);
39
- return authFile;
40
60
  }
41
61
  export async function deleteAuthFile() {
42
62
  await rm(getAuthFilePath(), { force: true });
43
63
  }
64
+ function expiresAt(expiresIn) {
65
+ return new Date(Date.now() + expiresIn * 1000).toISOString();
66
+ }
@@ -0,0 +1,34 @@
1
+ import { OpenLogApiClient } from "./api-client.js";
2
+ import { readAuthFile, updateAuthFileTokens, } from "./auth-store.js";
3
+ let cachedClient;
4
+ export async function createAuthenticatedApiClient() {
5
+ const authFile = await readAuthFile();
6
+ if (!authFile) {
7
+ throw new Error("Run `openlog login` before using OpenLog.");
8
+ }
9
+ const fingerprint = authFingerprint(authFile);
10
+ if (cachedClient?.fingerprint === fingerprint) {
11
+ return cachedClient.client;
12
+ }
13
+ const client = new OpenLogApiClient({
14
+ accessToken: authFile.accessToken,
15
+ refreshToken: authFile.refreshToken,
16
+ apiBaseUrl: authFile.apiBaseUrl,
17
+ onTokenRefresh: async (tokens) => {
18
+ const updatedAuthFile = await updateAuthFileTokens(authFile, tokens);
19
+ cachedClient = {
20
+ fingerprint: authFingerprint(updatedAuthFile),
21
+ client,
22
+ };
23
+ },
24
+ });
25
+ cachedClient = { fingerprint, client };
26
+ return client;
27
+ }
28
+ function authFingerprint(authFile) {
29
+ return [
30
+ authFile.apiBaseUrl,
31
+ authFile.accessToken,
32
+ authFile.refreshToken ?? "",
33
+ ].join("\u0000");
34
+ }
package/dist/config.js CHANGED
@@ -12,3 +12,7 @@ export function getAuthFilePath() {
12
12
  return (process.env.OPENLOG_AUTH_FILE ??
13
13
  path.join(os.homedir(), ".openlog", "auth.json"));
14
14
  }
15
+ export function getMcpConfigFilePath() {
16
+ return (process.env.OPENLOG_MCP_CONFIG_FILE ??
17
+ path.join(os.homedir(), ".openlog", "mcp-config.json"));
18
+ }
package/dist/index.js CHANGED
@@ -4,6 +4,8 @@ import { login } from "./login.js";
4
4
  import { runMcpServer } from "./mcp-server.js";
5
5
  import { installMcp, printMcpInstallHelp } from "./mcp-install.js";
6
6
  import { ApiError, OpenLogApiClient } from "./api-client.js";
7
+ import { createAuthenticatedApiClient } from "./authenticated-client.js";
8
+ import { runMcpPermissionsCommand } from "./mcp-permissions-command.js";
7
9
  const command = process.argv[2] ?? "help";
8
10
  const subcommand = process.argv[3];
9
11
  try {
@@ -11,8 +13,7 @@ try {
11
13
  await login();
12
14
  }
13
15
  else if (command === "logout") {
14
- await deleteAuthFile();
15
- console.log("Logged out.");
16
+ await logout();
16
17
  }
17
18
  else if (command === "whoami") {
18
19
  await whoami();
@@ -21,10 +22,12 @@ try {
21
22
  if (subcommand === "install") {
22
23
  await installMcpCommand();
23
24
  }
25
+ else if (subcommand === "permissions") {
26
+ await runMcpPermissionsCommand(process.argv.slice(4));
27
+ }
24
28
  else if (subcommand === undefined ||
25
29
  subcommand === "serve" ||
26
30
  subcommand === "start") {
27
- printMcpStartupHint();
28
31
  await runMcpServer();
29
32
  }
30
33
  else {
@@ -56,46 +59,27 @@ async function installMcpCommand() {
56
59
  });
57
60
  }
58
61
  async function whoami() {
59
- const authFile = await readAuthFile();
60
- if (!authFile) {
61
- throw new Error("Not logged in. Run `openlog login` first.");
62
- }
63
- const apiClient = new OpenLogApiClient({
64
- accessToken: authFile.accessToken,
65
- apiBaseUrl: authFile.apiBaseUrl,
66
- });
62
+ const apiClient = await createAuthenticatedApiClient();
67
63
  const me = await apiClient.get("/auth/me");
68
64
  console.log(JSON.stringify(me, null, 2));
69
65
  }
70
- function printMcpStartupHint() {
71
- if (!process.stderr.isTTY) {
72
- return;
73
- }
74
- console.error(`OpenLog MCP server is running over stdio.
75
-
76
- This terminal is now reserved for MCP protocol traffic.
77
- Press Ctrl+C to stop it.
78
-
79
- To use it from an MCP client, add:
80
- {
81
- "mcpServers": {
82
- "openlog": {
83
- "command": "openlog",
84
- "args": ["mcp"]
66
+ async function logout() {
67
+ const authFile = await readAuthFile();
68
+ if (authFile?.refreshToken) {
69
+ try {
70
+ const apiClient = new OpenLogApiClient({
71
+ apiBaseUrl: authFile.apiBaseUrl,
72
+ });
73
+ await apiClient.postNoContent("/auth/device/revoke", {
74
+ refreshToken: authFile.refreshToken,
75
+ });
76
+ }
77
+ catch (error) {
78
+ console.error(`Warning: remote session revoke failed: ${formatCliError(error)}`);
79
+ }
85
80
  }
86
- }
87
- }
88
-
89
- Available tools:
90
- get_auth_status
91
- get_me
92
- list_my_notifications
93
- list_my_posts
94
- list_my_liked_posts
95
- upload_post_image
96
- publish_post
97
- get_post_detail
98
- `);
81
+ await deleteAuthFile();
82
+ console.log("Logged out.");
99
83
  }
100
84
  function printHelp() {
101
85
  console.log(`OpenLog CLI
@@ -109,11 +93,19 @@ Usage:
109
93
  Register OpenLog MCP with Codex
110
94
  openlog mcp install claude-code
111
95
  Register OpenLog MCP with Claude Code
96
+ openlog mcp permissions
97
+ Show the active local MCP permission profile
98
+ openlog mcp permissions set read-only|safe-write|full
99
+ Change the local MCP permission profile
100
+ openlog mcp permissions reset
101
+ Restore the default safe-write profile
112
102
 
113
103
  Environment:
114
104
  OPENLOG_API_BASE_URL Override the OpenLog API base URL
115
105
  OPENLOG_WEB_BASE_URL Override the OpenLog web base URL for published post links
116
106
  OPENLOG_AUTH_FILE Override the local auth file path
107
+ OPENLOG_MCP_CONFIG_FILE
108
+ Override the local MCP permission config path
117
109
  `);
118
110
  }
119
111
  function formatCliError(error) {
package/dist/login.js CHANGED
@@ -14,18 +14,18 @@ export async function login() {
14
14
  ? "The approval page was opened in your browser."
15
15
  : "Open the URL above in your browser.");
16
16
  console.log("Waiting for approval...");
17
- const accessToken = await pollForAccessToken(apiClient, deviceLogin);
18
- await writeAuthFile(accessToken);
17
+ const tokens = await pollForTokens(apiClient, deviceLogin);
18
+ await writeAuthFile(tokens);
19
19
  console.log("Login successful.");
20
20
  }
21
- async function pollForAccessToken(apiClient, deviceLogin) {
21
+ async function pollForTokens(apiClient, deviceLogin) {
22
22
  const deadline = Date.now() + deviceLogin.expiresIn * 1000;
23
23
  let interval = deviceLogin.interval;
24
24
  while (Date.now() < deadline) {
25
25
  await sleep(interval * 1000);
26
26
  const response = await apiClient.post("/auth/device/token", { deviceCode: deviceLogin.deviceCode });
27
27
  if (response.status === "APPROVED") {
28
- return response.accessToken;
28
+ return response;
29
29
  }
30
30
  interval = response.interval ?? interval;
31
31
  }
@@ -28,7 +28,8 @@ Aliases:
28
28
  claude Same as claude-code
29
29
 
30
30
  Notes:
31
- If OPENLOG_API_BASE_URL or OPENLOG_WEB_BASE_URL is set, it is added to the client config.
31
+ If OPENLOG_API_BASE_URL, OPENLOG_WEB_BASE_URL, or OPENLOG_MCP_CONFIG_FILE is set,
32
+ it is added to the client config.
32
33
  `);
33
34
  }
34
35
  function buildServerConfig() {
@@ -41,6 +42,9 @@ function buildServerConfig() {
41
42
  if (webBaseUrl !== DEFAULT_WEB_BASE_URL) {
42
43
  env.OPENLOG_WEB_BASE_URL = webBaseUrl;
43
44
  }
45
+ if (process.env.OPENLOG_MCP_CONFIG_FILE) {
46
+ env.OPENLOG_MCP_CONFIG_FILE = process.env.OPENLOG_MCP_CONFIG_FILE;
47
+ }
44
48
  return {
45
49
  command: "openlog",
46
50
  args: ["mcp"],
@@ -0,0 +1,45 @@
1
+ import { isMcpPermissionProfile, readMcpPermissions, resetMcpPermissions, writeMcpPermissionProfile, } from "./mcp-permissions.js";
2
+ export async function runMcpPermissionsCommand(args, writeOutput = console.log) {
3
+ const action = args[0] ?? "show";
4
+ if ((action === "show" && args.length === 1) || args.length === 0) {
5
+ writeOutput(formatPermissions(await readMcpPermissions()));
6
+ return;
7
+ }
8
+ if (action === "set" && args.length === 2) {
9
+ const profile = args[1];
10
+ if (!isMcpPermissionProfile(profile)) {
11
+ throw new Error(permissionUsage());
12
+ }
13
+ const permissions = await writeMcpPermissionProfile(profile);
14
+ writeOutput(formatPermissions(permissions));
15
+ // 실행 중인 MCP server는 시작 시 권한을 확정하므로 재시작 안내가 필요하다.
16
+ writeOutput("Restart or reload the OpenLog MCP server to apply this profile.");
17
+ return;
18
+ }
19
+ if (action === "reset" && args.length === 1) {
20
+ const permissions = await resetMcpPermissions();
21
+ writeOutput(formatPermissions(permissions));
22
+ writeOutput("Restart or reload the OpenLog MCP server to apply this profile.");
23
+ return;
24
+ }
25
+ throw new Error(permissionUsage());
26
+ }
27
+ export function permissionUsage() {
28
+ return `OpenLog MCP permissions
29
+
30
+ Usage:
31
+ openlog mcp permissions
32
+ openlog mcp permissions show
33
+ openlog mcp permissions set read-only
34
+ openlog mcp permissions set safe-write
35
+ openlog mcp permissions set full
36
+ openlog mcp permissions reset`;
37
+ }
38
+ function formatPermissions(permissions) {
39
+ return [
40
+ `Profile: ${permissions.profile}`,
41
+ `Capabilities: ${permissions.capabilities.join(", ")}`,
42
+ `Source: ${permissions.configured ? "local config" : "default"}`,
43
+ ...(permissions.updatedAt ? [`Updated: ${permissions.updatedAt}`] : []),
44
+ ].join("\n");
45
+ }
@@ -0,0 +1,103 @@
1
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getMcpConfigFilePath } from "./config.js";
4
+ export const MCP_PERMISSION_PROFILES = [
5
+ "read-only",
6
+ "safe-write",
7
+ "full",
8
+ ];
9
+ export const MCP_CAPABILITIES = ["read", "write", "publish", "delete"];
10
+ export const DEFAULT_MCP_PERMISSION_PROFILE = "safe-write";
11
+ const PROFILE_CAPABILITIES = {
12
+ // 프로필은 상위 프로필이 하위 capability를 모두 포함하도록 누적 구성한다.
13
+ "read-only": ["read"],
14
+ "safe-write": ["read", "write", "publish"],
15
+ full: ["read", "write", "publish", "delete"],
16
+ };
17
+ export async function readMcpPermissions() {
18
+ const filePath = getMcpConfigFilePath();
19
+ let content;
20
+ try {
21
+ content = await readFile(filePath, "utf8");
22
+ }
23
+ catch (error) {
24
+ if (hasErrorCode(error, "ENOENT")) {
25
+ // 설정 파일이 없는 최초 실행은 기존 동작과 호환되는 safe-write를 사용한다.
26
+ return resolvePermissions(DEFAULT_MCP_PERMISSION_PROFILE, false, null);
27
+ }
28
+ throw error;
29
+ }
30
+ let parsed;
31
+ try {
32
+ parsed = JSON.parse(content);
33
+ }
34
+ catch {
35
+ throw invalidConfigError(filePath, "file is not valid JSON");
36
+ }
37
+ const config = parsePermissionConfig(parsed, filePath);
38
+ return resolvePermissions(config.profile, true, config.updatedAt);
39
+ }
40
+ export async function writeMcpPermissionProfile(profile) {
41
+ const filePath = getMcpConfigFilePath();
42
+ const config = {
43
+ version: 1,
44
+ profile,
45
+ updatedAt: new Date().toISOString(),
46
+ };
47
+ await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
48
+ await writeFile(filePath, `${JSON.stringify(config, null, 2)}\n`, {
49
+ mode: 0o600,
50
+ });
51
+ // 기존 파일을 덮어쓸 때도 소유자만 읽고 쓸 수 있도록 권한을 다시 고정한다.
52
+ await chmod(filePath, 0o600);
53
+ return resolvePermissions(profile, true, config.updatedAt);
54
+ }
55
+ export async function resetMcpPermissions() {
56
+ await rm(getMcpConfigFilePath(), { force: true });
57
+ return resolvePermissions(DEFAULT_MCP_PERMISSION_PROFILE, false, null);
58
+ }
59
+ export function isMcpPermissionProfile(value) {
60
+ return MCP_PERMISSION_PROFILES.some((profile) => profile === value);
61
+ }
62
+ export function hasMcpCapability(permissions, capability) {
63
+ return permissions.capabilities.includes(capability);
64
+ }
65
+ function resolvePermissions(profile, configured, updatedAt) {
66
+ return {
67
+ profile,
68
+ capabilities: [...PROFILE_CAPABILITIES[profile]],
69
+ configured,
70
+ updatedAt,
71
+ };
72
+ }
73
+ function parsePermissionConfig(value, filePath) {
74
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
75
+ throw invalidConfigError(filePath, "root value must be an object");
76
+ }
77
+ const config = value;
78
+ if (config.version !== 1) {
79
+ throw invalidConfigError(filePath, "version must be 1");
80
+ }
81
+ if (typeof config.profile !== "string" ||
82
+ !isMcpPermissionProfile(config.profile)) {
83
+ throw invalidConfigError(filePath, `profile must be one of: ${MCP_PERMISSION_PROFILES.join(", ")}`);
84
+ }
85
+ if (typeof config.updatedAt !== "string" ||
86
+ !Number.isFinite(Date.parse(config.updatedAt))) {
87
+ throw invalidConfigError(filePath, "updatedAt must be an ISO date-time");
88
+ }
89
+ return {
90
+ version: 1,
91
+ profile: config.profile,
92
+ updatedAt: config.updatedAt,
93
+ };
94
+ }
95
+ function invalidConfigError(filePath, reason) {
96
+ return new Error(`Invalid OpenLog MCP config at ${filePath}: ${reason}. Run \`openlog mcp permissions reset\` to restore the safe-write default.`);
97
+ }
98
+ function hasErrorCode(error, code) {
99
+ return Boolean(error &&
100
+ typeof error === "object" &&
101
+ "code" in error &&
102
+ error.code === code);
103
+ }