@akanjs/devkit 2.1.1-rc.1 → 2.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,68 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ parseTypescriptFileBlocks,
4
+ preserveTypescriptResponseContent,
5
+ } from "./aiEditor";
6
+
7
+ describe("parseTypescriptFileBlocks", () => {
8
+ test("parses TypeScript file blocks with common fence variants", () => {
9
+ const writes = parseTypescriptFileBlocks(`
10
+ \`\`\`ts
11
+
12
+ // File: lib/car/car.constant.ts
13
+
14
+ export const car = "car";
15
+ \`\`\`
16
+
17
+ \`\`\`tsx
18
+ // File: lib/car/Car.Unit.tsx
19
+ export const CarUnit = () => null;
20
+ \`\`\`
21
+ `);
22
+
23
+ expect(writes).toEqual([
24
+ {
25
+ filePath: "lib/car/car.constant.ts",
26
+ content: 'export const car = "car";',
27
+ },
28
+ {
29
+ filePath: "lib/car/Car.Unit.tsx",
30
+ content: "export const CarUnit = () => null;",
31
+ },
32
+ ]);
33
+ });
34
+
35
+ test("keeps previous code response when validation responds with prose only", () => {
36
+ const previousContent = `
37
+ \`\`\`typescript
38
+ // File: lib/car/car.constant.ts
39
+ export const car = "car";
40
+ \`\`\`
41
+ `;
42
+ const nextContent =
43
+ "The generated file meets all specified requirements. No rewrite is necessary.";
44
+
45
+ expect(
46
+ preserveTypescriptResponseContent(previousContent, nextContent),
47
+ ).toBe(previousContent);
48
+ });
49
+
50
+ test("uses next code response when validation rewrites with parseable files", () => {
51
+ const previousContent = `
52
+ \`\`\`typescript
53
+ // File: lib/car/car.constant.ts
54
+ export const car = "car";
55
+ \`\`\`
56
+ `;
57
+ const nextContent = `
58
+ \`\`\`typescript
59
+ // File: lib/car/car.constant.ts
60
+ export const car = "updated";
61
+ \`\`\`
62
+ `;
63
+
64
+ expect(
65
+ preserveTypescriptResponseContent(previousContent, nextContent),
66
+ ).toBe(nextContent);
67
+ });
68
+ });
package/aiEditor.ts CHANGED
@@ -12,14 +12,17 @@ import { ChatOpenAI } from "@langchain/openai";
12
12
  import { Logger } from "akanjs/common";
13
13
  import chalk from "chalk";
14
14
 
15
- import { getAkanGlobalConfig, setAkanGlobalConfig } from "./auth";
15
+ import { GlobalConfig } from "./cloud";
16
16
  import type { Executor, WorkspaceExecutor } from "./executors";
17
17
  import { Spinner } from "./spinner";
18
18
  import type { FileContent } from "./types";
19
19
 
20
20
  const MAX_ASK_TRY = 300;
21
21
 
22
- export const supportedLlmModels = ["deepseek-chat", "deepseek-reasoner"] as const;
22
+ export const supportedLlmModels = [
23
+ "deepseek-chat",
24
+ "deepseek-reasoner",
25
+ ] as const;
23
26
  export type SupportedLlmModel = (typeof supportedLlmModels)[number];
24
27
 
25
28
  interface EditOptions {
@@ -28,20 +31,68 @@ interface EditOptions {
28
31
  maxTry?: number;
29
32
  validate?: string[];
30
33
  approve?: boolean;
34
+ fallbackToPreviousTypescript?: boolean;
31
35
  }
32
36
 
37
+ export const parseTypescriptFileBlocks = (text: string): FileContent[] => {
38
+ const fileBlocks: FileContent[] = [];
39
+ const codeBlockRegex = /```(?:typescript|ts|tsx)\s*\n([\s\S]*?)```/gi;
40
+ const filePathRegex = /^\s*\/\/\s*File:\s*(.+?)\s*$/im;
41
+
42
+ for (const codeBlock of text.matchAll(codeBlockRegex)) {
43
+ const content = codeBlock[1]?.trim();
44
+ if (!content) continue;
45
+
46
+ const filePath = filePathRegex.exec(content)?.[1]?.trim();
47
+ if (!filePath) continue;
48
+
49
+ fileBlocks.push({
50
+ filePath,
51
+ content: content.replace(filePathRegex, "").trim(),
52
+ });
53
+ }
54
+
55
+ return fileBlocks;
56
+ };
57
+
58
+ export const preserveTypescriptResponseContent = (
59
+ previousContent: string,
60
+ nextContent: string,
61
+ ) => {
62
+ const previousWrites = parseTypescriptFileBlocks(previousContent);
63
+ const nextWrites = parseTypescriptFileBlocks(nextContent);
64
+ if (previousWrites.length > 0 && nextWrites.length === 0)
65
+ return previousContent;
66
+ return nextContent;
67
+ };
68
+
33
69
  export class AiSession {
34
70
  static #cacheDir = "node_modules/.cache/akan/aiSession";
35
71
  static #chat: ChatDeepSeek | ChatOpenAI | null = null;
36
- static async init({ temperature = 0, useExisting = true }: { temperature?: number; useExisting?: boolean } = {}) {
72
+ static async init({
73
+ temperature = 0,
74
+ useExisting = true,
75
+ }: {
76
+ temperature?: number;
77
+ useExisting?: boolean;
78
+ } = {}) {
37
79
  if (useExisting) {
38
80
  const llmConfig = await AiSession.getLlmConfig();
39
81
  if (llmConfig) {
40
82
  AiSession.#setChatModel(llmConfig.model, llmConfig.apiKey);
41
- Logger.rawLog(chalk.dim(`🤖akan editor uses existing LLM config (${llmConfig.model})`));
83
+ Logger.rawLog(
84
+ chalk.dim(
85
+ `🤖akan editor uses existing LLM config (${llmConfig.model})`,
86
+ ),
87
+ );
42
88
  return AiSession;
43
89
  }
44
- } else Logger.rawLog(chalk.yellow("🤖akan-editor is not initialized. LLM configuration should be set first."));
90
+ } else
91
+ Logger.rawLog(
92
+ chalk.yellow(
93
+ "🤖akan-editor is not initialized. LLM configuration should be set first.",
94
+ ),
95
+ );
45
96
 
46
97
  const llmConfig = await AiSession.#requestLlmConfig();
47
98
  const { model, apiKey } = llmConfig;
@@ -51,7 +102,11 @@ export class AiSession {
51
102
  await session.setLlmConfig({ model, apiKey });
52
103
  return session;
53
104
  }
54
- static #setChatModel(model: SupportedLlmModel, apiKey: string, { temperature = 0 }: { temperature?: number } = {}) {
105
+ static #setChatModel(
106
+ model: SupportedLlmModel,
107
+ apiKey: string,
108
+ { temperature = 0 }: { temperature?: number } = {},
109
+ ) {
55
110
  AiSession.#chat = new ChatDeepSeek({
56
111
  modelName: model,
57
112
  temperature,
@@ -62,22 +117,26 @@ export class AiSession {
62
117
  return AiSession;
63
118
  }
64
119
  static async getLlmConfig() {
65
- const akanConfig = await getAkanGlobalConfig();
66
- return akanConfig.llm ?? null;
120
+ return await GlobalConfig.getLlmConfig();
67
121
  }
68
- static async setLlmConfig(llmConfig: { model: SupportedLlmModel; apiKey: string } | null) {
69
- const akanConfig = await getAkanGlobalConfig();
70
- akanConfig.llm = llmConfig;
71
- await setAkanGlobalConfig(akanConfig);
122
+ static async setLlmConfig(
123
+ llmConfig: { model: SupportedLlmModel; apiKey: string } | null,
124
+ ) {
125
+ await GlobalConfig.setLlmConfig(llmConfig);
72
126
  return AiSession;
73
127
  }
74
128
  static async #requestLlmConfig() {
75
- const model = await select<SupportedLlmModel>({ message: "Select a LLM model", choices: supportedLlmModels });
129
+ const model = await select<SupportedLlmModel>({
130
+ message: "Select a LLM model",
131
+ choices: supportedLlmModels,
132
+ });
76
133
  const apiKey = await input({ message: "Enter your API key" });
77
134
  return { model, apiKey };
78
135
  }
79
136
  static async #validateApiKey(modelName: SupportedLlmModel, apiKey: string) {
80
- const spinner = new Spinner("Validating LLM API key...", { prefix: `🤖akan-editor` }).start();
137
+ const spinner = new Spinner("Validating LLM API key...", {
138
+ prefix: `🤖akan-editor`,
139
+ }).start();
81
140
  const chat = new ChatOpenAI({
82
141
  modelName,
83
142
  temperature: 0,
@@ -107,7 +166,15 @@ export class AiSession {
107
166
  workspace: WorkspaceExecutor;
108
167
  constructor(
109
168
  type: string,
110
- { workspace, cacheKey, isContinued }: { workspace: WorkspaceExecutor; cacheKey?: string; isContinued?: boolean },
169
+ {
170
+ workspace,
171
+ cacheKey,
172
+ isContinued,
173
+ }: {
174
+ workspace: WorkspaceExecutor;
175
+ cacheKey?: string;
176
+ isContinued?: boolean;
177
+ },
111
178
  ) {
112
179
  this.workspace = workspace;
113
180
  this.sessionKey = `${type}${cacheKey ? `-${cacheKey}` : ""}`;
@@ -126,7 +193,10 @@ export class AiSession {
126
193
  }
127
194
  async #saveCache() {
128
195
  const cacheFilePath = `${AiSession.#cacheDir}/${this.sessionKey}.json`;
129
- await this.workspace.writeJson(cacheFilePath, mapChatMessagesToStoredMessages(this.messageHistory));
196
+ await this.workspace.writeJson(
197
+ cacheFilePath,
198
+ mapChatMessagesToStoredMessages(this.messageHistory),
199
+ );
130
200
  }
131
201
  async ask(
132
202
  question: string,
@@ -142,7 +212,8 @@ export class AiSession {
142
212
  if (!AiSession.#chat) await AiSession.init();
143
213
  if (this.#cacheLoadPromise) await this.#cacheLoadPromise;
144
214
 
145
- if (!AiSession.#chat) throw new Error("Failed to initialize the AI session");
215
+ if (!AiSession.#chat)
216
+ throw new Error("Failed to initialize the AI session");
146
217
  const loader = new Spinner(`${AiSession.#chat.model} is thinking...`, {
147
218
  prefix: `🤖akan-editor`,
148
219
  }).start();
@@ -151,13 +222,15 @@ export class AiSession {
151
222
  this.messageHistory.push(humanMessage);
152
223
  const stream = await AiSession.#chat.stream(this.messageHistory);
153
224
  let reasoningResponse = "",
154
- fullResponse = "",
155
- tokenIdx = 0;
225
+ fullResponse = "";
156
226
  for await (const chunk of stream) {
157
- if (loader.isSpinning()) loader.succeed(`${AiSession.#chat.model} responded`);
227
+ if (loader.isSpinning())
228
+ loader.succeed(`${AiSession.#chat.model} responded`);
158
229
 
159
230
  if (!fullResponse.length) {
160
- const reasoningContent = (chunk.additional_kwargs as { reasoning_content?: string }).reasoning_content ?? "";
231
+ const reasoningContent =
232
+ (chunk.additional_kwargs as { reasoning_content?: string })
233
+ .reasoning_content ?? "";
161
234
  if (reasoningContent.length) {
162
235
  reasoningResponse += reasoningContent;
163
236
  onReasoning(reasoningContent);
@@ -173,24 +246,45 @@ export class AiSession {
173
246
  fullResponse += content;
174
247
  onChunk(content); // Send individual chunks to callback
175
248
  }
176
- tokenIdx++;
177
249
  }
178
250
  fullResponse += "\n";
179
251
  onChunk("\n");
180
252
  this.messageHistory.push(new AIMessage(fullResponse));
181
253
  return { content: fullResponse, messageHistory: this.messageHistory };
182
- } catch (error) {
254
+ } catch {
183
255
  loader.fail(`${AiSession.#chat.model} failed to respond`);
184
256
  throw new Error("Failed to stream response");
185
257
  }
186
258
  }
187
- async edit(question: string, { onChunk, onReasoning, maxTry = MAX_ASK_TRY, validate, approve }: EditOptions = {}) {
259
+ async edit(
260
+ question: string,
261
+ {
262
+ onChunk,
263
+ onReasoning,
264
+ maxTry = MAX_ASK_TRY,
265
+ validate,
266
+ approve,
267
+ fallbackToPreviousTypescript,
268
+ }: EditOptions = {},
269
+ ) {
188
270
  for (let tryCount = 0; tryCount < maxTry; tryCount++) {
189
271
  let response = await this.ask(question, { onChunk, onReasoning });
190
272
  if (validate?.length && tryCount === 0) {
191
273
  const validateQuestion = `Double check if the response meets the requirements and conditions, and follow the instructions. If not, rewrite it.
192
274
  ${validate.map((v) => `- ${v}`).join("\n")}`;
193
- response = await this.ask(validateQuestion, { onChunk, onReasoning });
275
+ const validateResponse = await this.ask(validateQuestion, {
276
+ onChunk,
277
+ onReasoning,
278
+ });
279
+ response = {
280
+ ...validateResponse,
281
+ content: fallbackToPreviousTypescript
282
+ ? preserveTypescriptResponseContent(
283
+ response.content,
284
+ validateResponse.content,
285
+ )
286
+ : validateResponse.content,
287
+ };
194
288
  }
195
289
  const isConfirmed = approve
196
290
  ? true
@@ -226,35 +320,72 @@ ${validate.map((v) => `- ${v}`).join("\n")}`;
226
320
  // const toolMessages = messages.map(
227
321
  // (message) => new ToolMessage({ content: message.content, tool_call_id: message.type })
228
322
  // );
229
- const toolMessages = messages.map((message) => new HumanMessage(message.content));
323
+ const toolMessages = messages.map(
324
+ (message) => new HumanMessage(message.content),
325
+ );
230
326
  this.messageHistory.push(...toolMessages);
231
327
  return this;
232
328
  }
233
- async writeTypescripts(question: string, executor: Executor, options: EditOptions = {}) {
234
- const content = await this.edit(question, options);
329
+ async writeTypescripts(
330
+ question: string,
331
+ executor: Executor,
332
+ options: EditOptions = {},
333
+ ) {
334
+ const content = await this.edit(question, {
335
+ ...options,
336
+ fallbackToPreviousTypescript: true,
337
+ });
235
338
  const writes = this.#getTypescriptCodes(content);
236
- for (const write of writes) await executor.writeFile(write.filePath, write.content);
339
+ if (!writes.length)
340
+ throw new Error(
341
+ "No parseable TypeScript file blocks were found in the AI response. Include `// File: <path>` in each code block.",
342
+ );
343
+ for (const write of writes)
344
+ await executor.writeFile(write.filePath, write.content);
237
345
  return await this.#tryFixTypescripts(writes, executor, options);
238
346
  }
239
- async #editTypescripts(question: string, options: EditOptions = {}) {
240
- const content = await this.edit(question, options);
241
- return this.#getTypescriptCodes(content);
347
+ async #editTypescripts(
348
+ question: string,
349
+ options: EditOptions = {},
350
+ fallbackWrites?: FileContent[],
351
+ ) {
352
+ const content = await this.edit(question, {
353
+ ...options,
354
+ fallbackToPreviousTypescript: true,
355
+ });
356
+ const writes = this.#getTypescriptCodes(content);
357
+ if (!writes.length && fallbackWrites?.length) return fallbackWrites;
358
+ if (!writes.length)
359
+ throw new Error(
360
+ "No parseable TypeScript file blocks were found in the AI response. Include `// File: <path>` in each code block.",
361
+ );
362
+ return writes;
242
363
  }
243
- async #tryFixTypescripts(writes: FileContent[], executor: Executor, options: EditOptions = {}) {
364
+ async #tryFixTypescripts(
365
+ writes: FileContent[],
366
+ executor: Executor,
367
+ options: EditOptions = {},
368
+ ) {
244
369
  const MAX_EDIT_TRY = 5;
245
370
  for (let tryCount = 0; tryCount < MAX_EDIT_TRY; tryCount++) {
246
- const loader = new Spinner(`Type checking and linting...`, { prefix: `🤖akan-editor` }).start();
371
+ const loader = new Spinner(`Type checking and linting...`, {
372
+ prefix: `🤖akan-editor`,
373
+ }).start();
247
374
  const fileChecks = await Promise.all(
248
375
  writes.map(async ({ filePath }) => {
249
- const typeCheckResult = executor.typeCheck(filePath);
250
- const lintResult = await executor.lint(filePath);
251
- const needFix = !!typeCheckResult.fileErrors.length || !!lintResult.errors.length;
376
+ const lintResult = await executor.lint(filePath, { fix: true });
377
+ const typeCheckResult = await executor.typeCheckAsync(filePath);
378
+ const hasTypeErrors = typeCheckResult.fileErrors.length > 0;
379
+ const hasLintErrors = lintResult.errors.length > 0;
380
+ const needFix = hasTypeErrors || hasLintErrors;
252
381
  return { filePath, typeCheckResult, lintResult, needFix };
253
382
  }),
254
383
  );
255
- const needFix = fileChecks.some((fileCheck) => fileCheck.needFix);
256
- if (needFix) {
257
- loader.fail("Type checking and linting has some errors, try to fix them");
384
+ const hasAnyFix = fileChecks.some((fileCheck) => fileCheck.needFix);
385
+ if (hasAnyFix) {
386
+ loader.fail(
387
+ "Type checking and linting has some errors, try to fix them",
388
+ );
258
389
  fileChecks.forEach((fileCheck) => {
259
390
  Logger.rawLog(
260
391
  `TypeCheck Result \n${fileCheck.typeCheckResult.message}\nLint Result \n${fileCheck.lintResult.message}`,
@@ -264,12 +395,17 @@ ${validate.map((v) => `- ${v}`).join("\n")}`;
264
395
  { type: "eslint", content: fileCheck.lintResult.message },
265
396
  ]);
266
397
  });
267
- writes = await this.#editTypescripts("Fix the typescript and eslint errors", {
268
- ...options,
269
- validate: undefined,
270
- approve: true,
271
- });
272
- for (const write of writes) await executor.writeFile(write.filePath, write.content);
398
+ writes = await this.#editTypescripts(
399
+ "Fix the typescript and eslint errors",
400
+ {
401
+ ...options,
402
+ validate: undefined,
403
+ approve: true,
404
+ },
405
+ writes,
406
+ );
407
+ for (const write of writes)
408
+ await executor.writeFile(write.filePath, write.content);
273
409
  } else {
274
410
  loader.succeed("Type checking and linting has no errors");
275
411
  return writes;
@@ -278,17 +414,7 @@ ${validate.map((v) => `- ${v}`).join("\n")}`;
278
414
  throw new Error("Failed to create scalar");
279
415
  }
280
416
  #getTypescriptCodes(text: string): FileContent[] {
281
- const codes = text.match(/```(typescript|tsx)([\s\S]*?)```/g);
282
- if (!codes) return [];
283
- const result = codes.map((code) => {
284
- const content = /```(typescript|tsx)([\s\S]*?)```/.exec(code)?.[2];
285
- if (!content) return null;
286
- const filePath = /\/\/ File: (.*?)(?:\n|$)/.exec(content)?.[1]?.trim();
287
- if (!filePath) return null;
288
- const contentWithoutFilepath = content.replace(`// File: ${filePath}\n`, "").trim();
289
- return { filePath, content: contentWithoutFilepath };
290
- });
291
- return result.filter((code) => code !== null) as FileContent[];
417
+ return parseTypescriptFileBlocks(text);
292
418
  }
293
419
  async editMarkdown(request: string, options: EditOptions = {}) {
294
420
  const content = await this.edit(request, options);
package/cloud/cloudApi.ts CHANGED
@@ -1,30 +1,45 @@
1
- import dayjs, { type Dayjs } from "dayjs";
2
-
3
- interface AccessTokenDto {
4
- jwt: string;
5
- refreshToken: string | null;
6
- expiresAt: string | null;
7
- }
8
- interface AccessToken {
9
- jwt: string;
10
- refreshToken: string | null;
11
- expiresAt: Dayjs | null;
12
- }
1
+ import {
2
+ type AccessToken,
3
+ type AccessTokenDto,
4
+ akanCloudHost,
5
+ type HostConfig,
6
+ } from "./constants";
7
+ import { GlobalConfig } from "./globalConfig";
13
8
 
14
9
  class HttpClient {
15
10
  readonly baseUrl: string;
16
- constructor(baseUrl: string) {
11
+ readonly headers: Record<string, string> = {};
12
+ constructor(baseUrl: string, headers: Record<string, string> = {}) {
17
13
  this.baseUrl = baseUrl;
14
+ this.headers = headers;
18
15
  }
19
16
  async get<T>(
20
17
  url: string,
21
18
  { headers }: { headers?: Record<string, string> } = {},
22
19
  ): Promise<T> {
23
20
  const response = await fetch(`${this.baseUrl}${url}`, {
24
- headers: { "Content-Type": "application/json", ...headers },
21
+ headers: {
22
+ "Content-Type": "application/json",
23
+ ...this.headers,
24
+ ...headers,
25
+ },
25
26
  });
26
27
  return response.json();
27
28
  }
29
+ async getFile(
30
+ url: string,
31
+ localPath: string,
32
+ headers?: Record<string, string>,
33
+ ): Promise<void> {
34
+ const response = await fetch(`${this.baseUrl}${url}`, {
35
+ headers: { ...this.headers, ...headers },
36
+ });
37
+ if (!response.ok)
38
+ throw new Error(
39
+ `Failed to download file: ${response.status} ${response.statusText}`,
40
+ );
41
+ await Bun.write(localPath, response);
42
+ }
28
43
  async post<T>(
29
44
  url: string,
30
45
  data: unknown,
@@ -35,68 +50,86 @@ class HttpClient {
35
50
  method: "POST",
36
51
  body: isFormData ? data : JSON.stringify(data),
37
52
  headers: isFormData
38
- ? headers
39
- : { "Content-Type": "application/json", ...headers },
53
+ ? { ...this.headers, ...headers }
54
+ : { "Content-Type": "application/json", ...this.headers, ...headers },
40
55
  });
41
56
  return response.json();
42
57
  }
58
+ setHeaders(headers: Record<string, string>) {
59
+ Object.assign(this.headers, headers);
60
+ return this;
61
+ }
43
62
  }
44
63
 
45
64
  export class CloudApi {
46
- readonly api: HttpClient;
65
+ readonly #api: HttpClient;
47
66
  #accessToken: AccessToken | null = null;
48
- constructor(
49
- host: string,
50
- { accessToken }: { accessToken?: AccessToken } = {},
51
- ) {
52
- this.api = new HttpClient(`${host}/api`);
53
- this.#accessToken = accessToken ?? null;
67
+
68
+ static async fromHost(host?: string) {
69
+ const hostConfig = await GlobalConfig.getHostConfig(host);
70
+ return new CloudApi(hostConfig);
71
+ }
72
+ constructor(hostConfig: HostConfig) {
73
+ const host = akanCloudHost;
74
+ this.#api = new HttpClient(`${host}/api`);
75
+ this.#accessToken = hostConfig.auth?.accessToken ?? null;
76
+ if (this.#accessToken && !GlobalConfig.needRefreshToken(this.#accessToken))
77
+ this.#api.setHeaders({
78
+ Authorization: `Bearer ${this.#accessToken.jwt}`,
79
+ });
54
80
  }
55
81
 
56
82
  async uploadEnv(devProjectId: string, file: File): Promise<boolean> {
57
83
  const formData = new FormData();
58
84
  formData.append("devProjectId", devProjectId);
59
85
  formData.append("file", file);
60
- const response = await this.api.post<{ success: boolean }>(
86
+ const data = await this.#api.post<boolean>(
61
87
  `/uploadEnv/${devProjectId}`,
62
88
  formData,
63
89
  );
64
- return response.success;
90
+ return data;
65
91
  }
66
- async downloadEnv(devProjectId: string): Promise<boolean> {
67
- const response = await this.api.get<{ success: boolean }>(
68
- `/downloadEnv/${devProjectId}`,
69
- );
70
- return response.success;
92
+ async downloadEnv(devProjectId: string, localPath: string): Promise<void> {
93
+ await this.#api.getFile(`/downloadEnv/${devProjectId}`, localPath);
71
94
  }
72
- async getRemoteAuthToken(remoteId: string): Promise<AccessToken> {
73
- if (this.#needRefreshToken()) return await this.refreshAuthToken();
74
- else if (this.#accessToken) return this.#accessToken;
75
- const accessToken = await this.api.get<AccessTokenDto>(
76
- `/getRemoteAuthToken/${remoteId}`,
77
- );
78
- this.#accessToken = {
79
- jwt: accessToken.jwt,
80
- refreshToken: accessToken.refreshToken,
81
- expiresAt: accessToken.expiresAt ? dayjs(accessToken.expiresAt) : null,
82
- };
83
- return this.#accessToken;
95
+ async getRemoteAuthToken(remoteId: string): Promise<AccessToken | null> {
96
+ try {
97
+ if (this.#accessToken) {
98
+ if (GlobalConfig.needRefreshToken(this.#accessToken))
99
+ return await this.refreshAuthToken();
100
+ else return await this.refreshAuthToken();
101
+ }
102
+ const accessToken = await this.#api.get<AccessTokenDto>(
103
+ `/getRemoteAuthToken/${remoteId}`,
104
+ );
105
+ this.#accessToken = GlobalConfig.toAccessToken(accessToken);
106
+ this.#api.setHeaders({
107
+ Authorization: `Bearer ${this.#accessToken.jwt}`,
108
+ });
109
+ return this.#accessToken;
110
+ } catch (_) {
111
+ return null;
112
+ }
84
113
  }
85
114
  async refreshAuthToken(): Promise<AccessToken> {
86
- const response = await this.api.post<AccessTokenDto>(
115
+ const response = await this.#api.post<AccessTokenDto>(
87
116
  `/refreshRemoteAuthToken`,
88
117
  {
89
118
  refreshToken: this.#accessToken?.refreshToken,
90
119
  },
91
120
  );
92
- this.#accessToken = {
93
- jwt: response.jwt,
94
- refreshToken: response.refreshToken,
95
- expiresAt: response.expiresAt ? dayjs(response.expiresAt) : null,
96
- };
121
+ this.#accessToken = GlobalConfig.toAccessToken(response);
122
+ this.#api.setHeaders({ Authorization: `Bearer ${this.#accessToken.jwt}` });
97
123
  return this.#accessToken;
98
124
  }
99
- #needRefreshToken(): boolean {
100
- return !!this.#accessToken?.expiresAt?.isBefore(dayjs().add(1, "hour"));
125
+ async getRemoteSelf(): Promise<{ id: string; nickname: string } | null> {
126
+ try {
127
+ const data = await this.#api.get<{ id: string; nickname: string }>(
128
+ `/getRemoteSelf`,
129
+ );
130
+ return data;
131
+ } catch {
132
+ return null;
133
+ }
101
134
  }
102
135
  }
@@ -0,0 +1,48 @@
1
+ import type { Dayjs } from "dayjs";
2
+ import type { SupportedLlmModel } from "../aiEditor";
3
+
4
+ export const basePath = `${Bun.env.HOME ?? Bun.env.USERPROFILE}/.akan`;
5
+ export const configPath = `${basePath}/config.json`;
6
+ export const akanCloudHost =
7
+ process.env.AKAN_PUBLIC_OPERATION_MODE === "local" ? "http://localhost" : "https://cloud.akanjs.com";
8
+ export const akanCloudUrl = `${akanCloudHost}${process.env.AKAN_PUBLIC_OPERATION_MODE === "local" ? ":8282" : ""}/api`;
9
+
10
+ export interface HostConfig {
11
+ auth?: {
12
+ accessToken?: AccessToken;
13
+ self?: { id: string; nickname: string };
14
+ };
15
+ }
16
+ export interface HostConfigDto {
17
+ auth?: {
18
+ accessToken?: AccessTokenDto;
19
+ self?: { id: string; nickname: string };
20
+ };
21
+ }
22
+ export const defaultHostConfig: HostConfig = {};
23
+ export interface RemoteEnvServerConfig {
24
+ host: string;
25
+ username?: string;
26
+ port?: number;
27
+ }
28
+ export interface AkanGlobalConfig {
29
+ cloudHost: { [key: string]: HostConfigDto };
30
+ remoteEnvServers: Record<string, RemoteEnvServerConfig>;
31
+ llm: { model: SupportedLlmModel; apiKey: string } | null;
32
+ }
33
+ export const defaultAkanGlobalConfig: AkanGlobalConfig = {
34
+ cloudHost: {},
35
+ remoteEnvServers: {},
36
+ llm: null,
37
+ };
38
+
39
+ export interface AccessTokenDto {
40
+ jwt: string;
41
+ refreshToken: string | null;
42
+ expiresAt: string | null;
43
+ }
44
+ export interface AccessToken {
45
+ jwt: string;
46
+ refreshToken: string | null;
47
+ expiresAt: Dayjs | null;
48
+ }