@kitae9999/openlog-cli 0.2.0 → 1.0.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
@@ -17,6 +17,7 @@ MCP tools:
17
17
  - `list_my_liked_posts`
18
18
  - `upload_post_image`
19
19
  - `publish_post`
20
+ - `push_working_brief`
20
21
  - `get_post_detail`
21
22
 
22
23
  MCP client configuration:
@@ -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,13 @@ 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
+ }
29
41
  async postNoContent(path, body) {
30
42
  await this.request(path, {
31
43
  method: "POST",
@@ -40,7 +52,7 @@ export class OpenLogApiClient {
40
52
  body: body ? JSON.stringify(body) : undefined,
41
53
  });
42
54
  }
43
- async request(path, init) {
55
+ async request(path, init, allowRefresh = true) {
44
56
  const url = `${this.apiBaseUrl}${path}`;
45
57
  const response = await fetch(url, {
46
58
  ...init,
@@ -51,6 +63,10 @@ export class OpenLogApiClient {
51
63
  : {}),
52
64
  },
53
65
  });
66
+ if (response.status === 401 && allowRefresh && this.refreshToken) {
67
+ await this.refreshAccessToken();
68
+ return this.request(path, init, false);
69
+ }
54
70
  if (!response.ok) {
55
71
  throw new ApiError(response.status, url, await readErrorMessage(response));
56
72
  }
@@ -59,6 +75,37 @@ export class OpenLogApiClient {
59
75
  }
60
76
  return (await response.json());
61
77
  }
78
+ async refreshAccessToken() {
79
+ if (!this.refreshPromise) {
80
+ this.refreshPromise = this.performTokenRefresh().finally(() => {
81
+ this.refreshPromise = undefined;
82
+ });
83
+ }
84
+ await this.refreshPromise;
85
+ }
86
+ async performTokenRefresh() {
87
+ const response = await fetch(`${this.apiBaseUrl}/auth/device/refresh`, {
88
+ method: "POST",
89
+ headers: { "content-type": "application/json" },
90
+ body: JSON.stringify({ refreshToken: this.refreshToken }),
91
+ });
92
+ if (!response.ok) {
93
+ throw new ApiError(response.status, `${this.apiBaseUrl}/auth/device/refresh`, await readErrorMessage(response));
94
+ }
95
+ const tokens = (await response.json());
96
+ if (!isRefreshedTokens(tokens)) {
97
+ throw new Error("OpenLog refresh response is incomplete.");
98
+ }
99
+ this.accessToken = tokens.accessToken;
100
+ this.refreshToken = tokens.refreshToken;
101
+ await this.onTokenRefresh?.(tokens);
102
+ }
103
+ }
104
+ function isRefreshedTokens(value) {
105
+ return Boolean(value.accessToken &&
106
+ value.refreshToken &&
107
+ Number.isFinite(value.expiresIn) &&
108
+ Number.isFinite(value.refreshExpiresIn));
62
109
  }
63
110
  async function readErrorMessage(response) {
64
111
  try {
@@ -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/index.js CHANGED
@@ -4,6 +4,7 @@ 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";
7
8
  const command = process.argv[2] ?? "help";
8
9
  const subcommand = process.argv[3];
9
10
  try {
@@ -11,8 +12,7 @@ try {
11
12
  await login();
12
13
  }
13
14
  else if (command === "logout") {
14
- await deleteAuthFile();
15
- console.log("Logged out.");
15
+ await logout();
16
16
  }
17
17
  else if (command === "whoami") {
18
18
  await whoami();
@@ -56,17 +56,28 @@ async function installMcpCommand() {
56
56
  });
57
57
  }
58
58
  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
- });
59
+ const apiClient = await createAuthenticatedApiClient();
67
60
  const me = await apiClient.get("/auth/me");
68
61
  console.log(JSON.stringify(me, null, 2));
69
62
  }
63
+ async function logout() {
64
+ const authFile = await readAuthFile();
65
+ if (authFile?.refreshToken) {
66
+ try {
67
+ const apiClient = new OpenLogApiClient({
68
+ apiBaseUrl: authFile.apiBaseUrl,
69
+ });
70
+ await apiClient.postNoContent("/auth/device/revoke", {
71
+ refreshToken: authFile.refreshToken,
72
+ });
73
+ }
74
+ catch (error) {
75
+ console.error(`Warning: remote session revoke failed: ${formatCliError(error)}`);
76
+ }
77
+ }
78
+ await deleteAuthFile();
79
+ console.log("Logged out.");
80
+ }
70
81
  function printMcpStartupHint() {
71
82
  if (!process.stderr.isTTY) {
72
83
  return;
@@ -94,6 +105,7 @@ Available tools:
94
105
  list_my_liked_posts
95
106
  upload_post_image
96
107
  publish_post
108
+ push_working_brief
97
109
  get_post_detail
98
110
  `);
99
111
  }
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
  }
@@ -1,8 +1,8 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { z } from "zod";
4
- import { OpenLogApiClient } from "./api-client.js";
5
4
  import { readAuthFile } from "./auth-store.js";
5
+ import { createAuthenticatedApiClient } from "./authenticated-client.js";
6
6
  import { getApiBaseUrl, getWebBaseUrl } from "./config.js";
7
7
  import { uploadPostImage } from "./post-image-upload.js";
8
8
  const WRITE_TOOL_ANNOTATIONS = {
@@ -14,7 +14,7 @@ const WRITE_TOOL_ANNOTATIONS = {
14
14
  export async function runMcpServer() {
15
15
  const server = new McpServer({
16
16
  name: "openlog",
17
- version: "0.2.0",
17
+ version: "1.0.0",
18
18
  });
19
19
  server.registerTool("get_auth_status", {
20
20
  title: "Get OpenLog Auth Status",
@@ -31,7 +31,7 @@ export async function runMcpServer() {
31
31
  });
32
32
  }
33
33
  apiBaseUrl = authFile.apiBaseUrl;
34
- const me = await createAuthenticatedClient().then((client) => client.get("/auth/me"));
34
+ const me = await createAuthenticatedApiClient().then((client) => client.get("/auth/me"));
35
35
  return textResult({
36
36
  authenticated: true,
37
37
  apiBaseUrl: authFile.apiBaseUrl,
@@ -153,6 +153,26 @@ export async function runMcpServer() {
153
153
  url: new URL(postPath, `${getWebBaseUrl()}/`).toString(),
154
154
  };
155
155
  }));
156
+ server.registerTool("push_working_brief", {
157
+ title: "Push OpenLog Working Brief",
158
+ description: "Overwrite the workspace Now Working brief with a short status update: what was done, what is still open, and optional task/branch context. Latest write wins.",
159
+ annotations: {
160
+ ...WRITE_TOOL_ANNOTATIONS,
161
+ idempotentHint: true,
162
+ },
163
+ inputSchema: {
164
+ workspaceId: z.number().int().positive(),
165
+ title: z.string().min(1).max(255),
166
+ prose: z.string().min(1),
167
+ taskId: z.number().int().positive().optional(),
168
+ branch: z.string().max(255).optional(),
169
+ },
170
+ }, async ({ workspaceId, title, prose, taskId, branch }) => withAuthenticatedClient((client) => client.put(`/workspaces/${workspaceId}/working-brief`, {
171
+ title,
172
+ prose,
173
+ taskId: taskId ?? null,
174
+ branch: branch ?? null,
175
+ })));
156
176
  server.registerTool("get_post_detail", {
157
177
  title: "Get OpenLog Post Detail",
158
178
  description: "Return a public OpenLog post detail by author username and post slug.",
@@ -212,19 +232,9 @@ function createContentPreview(content) {
212
232
  function buildPublicPostPath(username, slug) {
213
233
  return `/@${encodeURIComponent(username)}/posts/${encodeURIComponent(slug)}`;
214
234
  }
215
- async function createAuthenticatedClient() {
216
- const authFile = await readAuthFile();
217
- if (!authFile) {
218
- throw new Error("Run `openlog login` before using OpenLog MCP tools.");
219
- }
220
- return new OpenLogApiClient({
221
- accessToken: authFile.accessToken,
222
- apiBaseUrl: authFile.apiBaseUrl,
223
- });
224
- }
225
235
  async function withAuthenticatedClient(callback) {
226
236
  try {
227
- const client = await createAuthenticatedClient();
237
+ const client = await createAuthenticatedApiClient();
228
238
  const result = await callback(client);
229
239
  return textResult(result);
230
240
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitae9999/openlog-cli",
3
- "version": "0.2.0",
3
+ "version": "1.0.0",
4
4
  "private": false,
5
5
  "description": "Official OpenLog CLI and MCP server.",
6
6
  "type": "module",
@@ -16,7 +16,8 @@
16
16
  "build": "tsc && chmod 755 dist/index.js",
17
17
  "dev": "tsx src/index.ts",
18
18
  "prepack": "npm run build",
19
- "start": "node dist/index.js"
19
+ "start": "node dist/index.js",
20
+ "test": "tsx --test test/**/*.test.ts"
20
21
  },
21
22
  "files": [
22
23
  "dist",