@akanjs/devkit 3.0.0-beta.16 → 3.0.0-beta.18

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.
Files changed (51) hide show
  1. package/abstractDoc.ts +1 -3
  2. package/applicationBuildRunner.ts +4 -3
  3. package/artifact/implicitRootLayout.test.ts +6 -11
  4. package/artifact/implicitRootLayout.ts +0 -15
  5. package/biome.base.json +6 -1
  6. package/capacitorApp.test.ts +26 -0
  7. package/capacitorApp.ts +25 -4
  8. package/codeAgent/agent/AkanCodeServices.ts +43 -27
  9. package/codeAgent/agent/AkanContextFiles.ts +117 -0
  10. package/codeAgent/agent/CodeAgent.ts +87 -19
  11. package/codeAgent/agent/CodeAgentStreamPrinter.ts +4 -0
  12. package/codeAgent/agent/SubagentPool.ts +55 -4
  13. package/codeAgent/agent/akanCodeModel.ts +8 -8
  14. package/codeAgent/agent/akanCodePaths.ts +23 -3
  15. package/codeAgent/agent/akanContextFiles.test.ts +115 -0
  16. package/codeAgent/agent/akanSystemPrompt.ts +5 -0
  17. package/codeAgent/index.ts +0 -1
  18. package/codeAgent/skills/akan-code/SKILL.md +122 -0
  19. package/codeAgent/skills/akan-module/SKILL.md +2 -2
  20. package/codeAgent/skills/akan-store/SKILL.md +6 -2
  21. package/codeAgent/tools/AkanCodePlugins.ts +26 -3
  22. package/codeAgent/tools/AkanEnvKeys.ts +5 -3
  23. package/codeAgent/tools/AskToolPack.ts +111 -0
  24. package/codeAgent/tools/McpClient.ts +51 -5
  25. package/codeAgent/tools/McpOAuth.ts +268 -0
  26. package/codeAgent/tools/McpOAuthLoopback.ts +77 -0
  27. package/codeAgent/tools/McpServerConfig.ts +148 -0
  28. package/codeAgent/tools/McpSignIn.ts +161 -0
  29. package/codeAgent/tools/McpTokenStore.ts +92 -0
  30. package/codeAgent/tools/McpToolPack.ts +42 -49
  31. package/codeAgent/tools/akanCodePlugins.test.ts +36 -3
  32. package/codeAgent/tools/askToolPack.test.ts +119 -0
  33. package/codeAgent/tools/mcpOAuth.test.ts +320 -0
  34. package/codeAgent/tools/mcpServerConfig.test.ts +205 -0
  35. package/commandDecorators/command.ts +2 -0
  36. package/commandDecorators/commandDecorators.test.ts +1 -1
  37. package/commandDecorators/helpFormatter.ts +2 -1
  38. package/executors.test.ts +10 -46
  39. package/index.ts +0 -1
  40. package/package.json +8 -6
  41. package/piDependencyPins.test.ts +4 -2
  42. package/prompter.ts +0 -7
  43. package/qualityScanner.test.ts +3 -2
  44. package/qualityScanner.ts +1 -1
  45. package/routeSourceValidator.test.ts +0 -1
  46. package/routeSourceValidator.ts +0 -12
  47. package/scanInfo.ts +5 -2
  48. package/workflow/executor.ts +52 -0
  49. package/workflow/source.ts +19 -13
  50. package/codeAgent/agent/AkanAuthStorageBackend.ts +0 -105
  51. package/guideline.ts +0 -19
package/executors.test.ts CHANGED
@@ -727,7 +727,7 @@ describe("Workspace and app executor environment contracts", () => {
727
727
  });
728
728
  });
729
729
 
730
- test("accepts metadata route exports during page key discovery", async () => {
730
+ test("accepts head route exports during page key discovery", async () => {
731
731
  const root = await makeTempRoot();
732
732
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";
733
733
  process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
@@ -737,24 +737,19 @@ describe("Workspace and app executor environment contracts", () => {
737
737
  await writeFile(path.join(root, "apps/demo/akan.config.ts"), "export default {};\n");
738
738
  await writeFile(
739
739
  path.join(root, "apps/demo/page/_layout.tsx"),
740
- [
741
- "export const head = null;",
742
- "export const metadata = { title: 'Root' };",
743
- "export default function Layout({ children }) { return children; }",
744
- "",
745
- ].join("\n"),
740
+ ["export const head = null;", "export default function Layout({ children }) { return children; }", ""].join("\n"),
746
741
  );
747
742
  await writeFile(
748
743
  path.join(root, "apps/demo/page/docs/_layout.tsx"),
749
744
  [
750
- "export async function generateMetadata() { return { title: 'Docs' }; }",
745
+ "export async function generateHead() { return null; }",
751
746
  "export default function Layout({ children }) { return children; }",
752
747
  "",
753
748
  ].join("\n"),
754
749
  );
755
750
  await writeFile(
756
751
  path.join(root, "apps/demo/page/docs/intro.tsx"),
757
- ["export const metadata = { title: 'Intro' };", "export default function Page() { return null; }", ""].join("\n"),
752
+ ["export const head = null;", "export default function Page() { return null; }", ""].join("\n"),
758
753
  );
759
754
 
760
755
  const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
@@ -767,7 +762,7 @@ describe("Workspace and app executor environment contracts", () => {
767
762
  ]);
768
763
  });
769
764
 
770
- test("rejects conflicting metadata route exports during page key discovery", async () => {
765
+ test("rejects a metadata route export during page key discovery", async () => {
771
766
  const root = await makeTempRoot();
772
767
  process.env.AKAN_PUBLIC_REPO_NAME = "repo";
773
768
  process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
@@ -776,47 +771,16 @@ describe("Workspace and app executor environment contracts", () => {
776
771
  await mkdir(path.join(root, "apps/demo/page"), { recursive: true });
777
772
  await writeFile(path.join(root, "apps/demo/akan.config.ts"), "export default {};\n");
778
773
  await writeFile(
779
- path.join(root, "apps/demo/page/conflict.tsx"),
780
- [
781
- "export const metadata = { title: 'Conflict' };",
782
- "export function generateMetadata() { return { title: 'Conflict' }; }",
783
- "export default function Page() { return null; }",
784
- "",
785
- ].join("\n"),
774
+ path.join(root, "apps/demo/page/legacy.tsx"),
775
+ ["export const metadata = { title: 'Legacy' };", "export default function Page() { return null; }", ""].join(
776
+ "\n",
777
+ ),
786
778
  );
787
779
 
788
780
  const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
789
781
  const app = AppExecutor.from(workspace, "demo");
790
782
 
791
- await expect(app.getPageKeys({ refresh: true })).rejects.toThrow(
792
- "metadata and generateMetadata cannot both be exported",
793
- );
794
- });
795
-
796
- test("rejects mixed head and metadata route export channels during page key discovery", async () => {
797
- const root = await makeTempRoot();
798
- process.env.AKAN_PUBLIC_REPO_NAME = "repo";
799
- process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
800
- process.env.AKAN_PUBLIC_ENV = "local";
801
- await writeJson(path.join(root, "package.json"), rootPackageJson());
802
- await mkdir(path.join(root, "apps/demo/page"), { recursive: true });
803
- await writeFile(path.join(root, "apps/demo/akan.config.ts"), "export default {};\n");
804
- await writeFile(
805
- path.join(root, "apps/demo/page/mixed.tsx"),
806
- [
807
- "export const head = null;",
808
- "export const metadata = { title: 'Mixed' };",
809
- "export default function Page() { return null; }",
810
- "",
811
- ].join("\n"),
812
- );
813
-
814
- const workspace = new WorkspaceExecutor({ workspaceRoot: root, repoName: "repo" });
815
- const app = AppExecutor.from(workspace, "demo");
816
-
817
- await expect(app.getPageKeys({ refresh: true })).rejects.toThrow(
818
- "head/generateHead and metadata/generateMetadata cannot both be exported",
819
- );
783
+ await expect(app.getPageKeys({ refresh: true })).rejects.toThrow('unsupported export "metadata"');
820
784
  });
821
785
 
822
786
  test("assigns start command ports from sorted app order", async () => {
package/index.ts CHANGED
@@ -32,7 +32,6 @@ export type * from "./fileSys";
32
32
  export type * from "./frontendBuild";
33
33
  export type * from "./getDirname";
34
34
  export type * from "./getRelatedCnsts";
35
- export type * from "./guideline";
36
35
  export type * from "./incrementalBuilder";
37
36
  export type * from "./mobile";
38
37
  export type * from "./packageExportsMap";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-beta.16",
3
+ "version": "3.0.0-beta.18",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -39,14 +39,16 @@
39
39
  "./*": "./*.ts"
40
40
  },
41
41
  "dependencies": {
42
- "@earendil-works/pi-agent-core": "0.80.6",
43
- "@earendil-works/pi-ai": "0.80.6",
44
- "@earendil-works/pi-coding-agent": "0.80.6",
45
- "@earendil-works/pi-tui": "0.80.6",
42
+ "@earendil-works/chord": "0.86.1",
43
+ "@earendil-works/pi-agent-core": "0.86.1",
44
+ "@earendil-works/pi-ai": "0.86.1",
45
+ "@earendil-works/pi-coding-agent": "0.86.1",
46
+ "@earendil-works/pi-telemetry": "0.86.1",
47
+ "@earendil-works/pi-tui": "0.86.1",
46
48
  "@inquirer/prompts": "^8.4.3",
47
49
  "@tailwindcss/node": "^4.3.0",
48
50
  "@trapezedev/project": "^7.1.4",
49
- "akanjs": "3.0.0-beta.16",
51
+ "akanjs": "3.0.0-beta.18",
50
52
  "chalk": "^5.6.2",
51
53
  "commander": "^14.0.3",
52
54
  "dayjs": "^1.11.20",
@@ -28,9 +28,11 @@ const readJson = async <T>(file: string) => (await Bun.file(file).json()) as T;
28
28
 
29
29
  const shrinkwrappedPins = async () => {
30
30
  const { packages } = await readJson<Shrinkwrap>(`${piDir}/npm-shrinkwrap.json`);
31
+ // Top-level entries only: a key with a second `node_modules/` in it is a *nested placement* of some other
32
+ // package's dependency, which a consumer's resolver reaches on its own and we have no business pinning.
31
33
  const entries = Object.entries(packages)
32
- .map(([key, entry]) => [key.replace(/^node_modules\//, ""), entry.version ?? ""] as const)
33
- .filter(([name]) => name.startsWith("@earendil-works/"));
34
+ .filter(([key]) => /^node_modules\/@earendil-works\/[^/]+$/.test(key))
35
+ .map(([key, entry]) => [key.replace(/^node_modules\//, ""), entry.version ?? ""] as const);
34
36
  return Object.fromEntries(entries);
35
37
  };
36
38
 
package/prompter.ts CHANGED
@@ -2,7 +2,6 @@ import fsPromise from "node:fs/promises";
2
2
  import { input, select } from "@inquirer/prompts";
3
3
 
4
4
  import { getDirname } from "./getDirname";
5
- import type { GuideGenerateJson } from "./guideline";
6
5
 
7
6
  interface FileUpdateRequestProps {
8
7
  context: string;
@@ -34,12 +33,6 @@ export class Prompter {
34
33
  const guidelineRoot = await Prompter.#getGuidelineRoot();
35
34
  return (await fsPromise.readdir(guidelineRoot)).filter((name) => !name.startsWith("_")).sort();
36
35
  }
37
- static async getGuideJson(guideName: string): Promise<GuideGenerateJson> {
38
- const guidelineRoot = await Prompter.#getGuidelineRoot();
39
- const filePath = `${guidelineRoot}/${guideName}/${guideName}.generate.json`;
40
- const guideJson = await fsPromise.readFile(filePath, "utf-8");
41
- return JSON.parse(guideJson) as GuideGenerateJson;
42
- }
43
36
  /**
44
37
  * The name is checked against the directory listing rather than interpolated straight into a path:
45
38
  * it arrives from an MCP tool argument, and an unknown one has to name the valid set instead of
@@ -26,7 +26,7 @@ afterEach(async () => {
26
26
  });
27
27
 
28
28
  describe("AkanQualityScanner abstract rule", () => {
29
- test("warns on an abstract over the line limit and points at akan compact", async () => {
29
+ test("warns on an abstract over the line limit and says how to trim it", async () => {
30
30
  const root = await makeWorkspace({
31
31
  "apps/demo/lib/post/post.abstract.md": abstractOf(AbstractDoc.maxLines + 1),
32
32
  "apps/demo/lib/post/post.constant.ts": "export class Post {}\n",
@@ -41,7 +41,8 @@ describe("AkanQualityScanner abstract rule", () => {
41
41
  expect(warnings).toHaveLength(1);
42
42
  expect(warnings[0]?.file).toBe("apps/demo/lib/post/post.abstract.md");
43
43
  expect(warnings[0]?.message).toContain(`${AbstractDoc.maxLines + 1} lines`);
44
- expect(warnings[0]?.fix).toContain("akan compact");
44
+ expect(warnings[0]?.fix).toContain("## Rules");
45
+ expect(warnings[0]?.fix).not.toContain("akan compact");
45
46
  });
46
47
  });
47
48
 
package/qualityScanner.ts CHANGED
@@ -114,7 +114,7 @@ const RULE_FIXES: Record<string, string> = {
114
114
  "Split the file by responsibility — move Zones, Utils, or subcomponents into sibling files.",
115
115
  "akan.file.max-lines": "Break the file into smaller focused modules; keep one primary responsibility per file.",
116
116
  "akan.file.abstract-max-lines":
117
- "Run `akan compact <app-or-lib>` to rewrite the abstract with the AI editor, keeping only the invariants and workflows the source files cannot show.",
117
+ "Trim the abstract by hand to a title, one sentence naming what the module owns, a `## Rules` list of the invariants the source cannot show, and an optional workflow chain.",
118
118
  "akan.file.placeholder-export":
119
119
  "Remove the placeholder export; generated indexes should only re-export real modules.",
120
120
  "akan.file.bang-comment-in-client":
@@ -15,7 +15,6 @@ describe("RouteSourceValidator", () => {
15
15
  "export const reconnect = true;",
16
16
  "export const wsConnect = true;",
17
17
  "export const layoutStyle = {};",
18
- 'export const gaTrackingId = "G-1";',
19
18
  ].join("\n");
20
19
 
21
20
  expect(() => validate(source, "layout", true)).not.toThrow();
@@ -86,18 +86,6 @@ export class RouteSourceValidator {
86
86
  if (exported.has("head") && exported.has("generateHead")) {
87
87
  throw new Error(`[route-convention] head and generateHead cannot both be exported in ${filePath}`);
88
88
  }
89
- if (
90
- !options.rootLayout &&
91
- (exported.has("head") || exported.has("generateHead")) &&
92
- (exported.has("metadata") || exported.has("generateMetadata"))
93
- ) {
94
- throw new Error(
95
- `[route-convention] head/generateHead and metadata/generateMetadata cannot both be exported in ${filePath}`,
96
- );
97
- }
98
- if (exported.has("metadata") && exported.has("generateMetadata")) {
99
- throw new Error(`[route-convention] metadata and generateMetadata cannot both be exported in ${filePath}`);
100
- }
101
89
  const chain = RouteSourceValidator.#readChain(sourceFile, filePath);
102
90
  if (!chain) return { devOnly: RouteSourceValidator.#readDevOnly(sourceFile, filePath) };
103
91
  const named = [...exported].filter((name) => name !== "default");
package/scanInfo.ts CHANGED
@@ -524,16 +524,19 @@ export class PkgInfo {
524
524
  const [tsconfig, rootPackageJson] = await Promise.all([exec.getTsConfig(), exec.workspace.getPackageJson()]);
525
525
  const scanner = await createDependencyScanner(exec);
526
526
  const npmSet = new Set(Object.keys({ ...rootPackageJson.dependencies, ...rootPackageJson.devDependencies }));
527
+ const workspacePathOf = (resolve: string) => resolve.replace(/^\.\//, "");
527
528
  const pkgPathSet = new Set(
528
529
  Object.keys(tsconfig.compilerOptions.paths ?? {})
529
- .filter((path) => tsconfig.compilerOptions.paths?.[path]?.some((resolve) => resolve.startsWith("pkgs/")))
530
+ .filter((path) =>
531
+ tsconfig.compilerOptions.paths?.[path]?.some((resolve) => workspacePathOf(resolve).startsWith("pkgs/")),
532
+ )
530
533
  .map((path) => path.replace("/*", "")),
531
534
  );
532
535
  const [npmDepSet, pkgPathDepSet] = await scanner.getImportSets([npmSet, pkgPathSet]);
533
536
  const pkgDeps = [...pkgPathDepSet]
534
537
  .map((path) => {
535
538
  const pathSplitLength = path.split("/").length;
536
- return (tsconfig.compilerOptions.paths?.[path]?.[0] ?? "*")
539
+ return workspacePathOf(tsconfig.compilerOptions.paths?.[path]?.[0] ?? "*")
537
540
  .split("/")
538
541
  .slice(1, 1 + pathSplitLength)
539
542
  .join("/");
@@ -1,3 +1,4 @@
1
+ import { capitalize } from "akanjs/common";
1
2
  import ts from "typescript";
2
3
  import type { AkanModuleContext } from "../akanContext";
3
4
  import type { Sys, Workspace } from "../commandDecorators";
@@ -396,6 +397,54 @@ const addFieldUiSurfaceInspection = (plan: WorkflowPlan): WorkflowStepResult =>
396
397
  };
397
398
  };
398
399
 
400
+ const addMutationActionSurfaceInspection = (plan: WorkflowPlan): WorkflowStepResult => {
401
+ const module = workflowStringInput(plan.inputs.module) ?? "<module>";
402
+ const mutation = workflowStringInput(plan.inputs.mutation) ?? "<mutation>";
403
+ const moduleClassName = moduleComponentName(module);
404
+ return {
405
+ recommendations: [
406
+ {
407
+ code: "add-mutation-action-surface-review",
408
+ kind: "manual-action",
409
+ target: `*/${moduleSourcePaths(module).store}`,
410
+ action: `Name the endpoint's guards and add its dictionary .endpoint() entry with a .desc(). After sync, fetch.${mutation} exists; write a store action only for a toast, an optimistic update, or a multi-field write, and put the control that calls it in ${moduleClassName}.Util.tsx.`,
411
+ confidence: "medium",
412
+ message: `Workflow apply does not write store or UI code for ${module}.${mutation}; review whether a screen should call it.`,
413
+ },
414
+ ],
415
+ nextActions: [
416
+ {
417
+ command: `akan workflow explain ${plan.workflow}`,
418
+ reason: "Review action surface guidance before manually editing store or UI files.",
419
+ },
420
+ ],
421
+ };
422
+ };
423
+
424
+ const addSliceViewSurfaceInspection = (plan: WorkflowPlan): WorkflowStepResult => {
425
+ const module = workflowStringInput(plan.inputs.module) ?? "<module>";
426
+ const slice = workflowStringInput(plan.inputs.slice) ?? "<slice>";
427
+ const moduleClassName = moduleComponentName(module);
428
+ return {
429
+ recommendations: [
430
+ {
431
+ code: "add-slice-view-surface-review",
432
+ kind: "manual-action",
433
+ target: `*/${moduleSourcePaths(module).zone}`,
434
+ action: `Fill in the service query stub and add the dictionary .slice() entry. After sync, load fetch.init${moduleClassName}${capitalize(slice)}() in the page and pass the result to a ${moduleClassName}.Zone as an init prop.`,
435
+ confidence: "medium",
436
+ message: `Workflow apply does not write page or Zone code for ${module}.${slice}; review where the list should render.`,
437
+ },
438
+ ],
439
+ nextActions: [
440
+ {
441
+ command: `akan workflow explain ${plan.workflow}`,
442
+ reason: "Review view surface guidance before manually editing Zone or page files.",
443
+ },
444
+ ],
445
+ };
446
+ };
447
+
399
448
  export const createWorkflowStepRegistry = ({
400
449
  workspace,
401
450
  createModule,
@@ -494,6 +543,8 @@ export const createWorkflowStepRegistry = ({
494
543
  }),
495
544
  ),
496
545
  [workflowStepKey("add-mutation", "update-signal")]: inspect,
546
+ [workflowStepKey("add-mutation", "update-action-surfaces")]: async (_step, plan) =>
547
+ addMutationActionSurfaceInspection(plan),
497
548
  [workflowStepKey("add-slice", "update-service-query")]: async (_step, plan) =>
498
549
  primitiveReportToWorkflowStepResult(
499
550
  await addSlice({
@@ -503,6 +554,7 @@ export const createWorkflowStepRegistry = ({
503
554
  }),
504
555
  ),
505
556
  [workflowStepKey("add-slice", "update-signal-slice")]: inspect,
557
+ [workflowStepKey("add-slice", "connect-view-surfaces")]: async (_step, plan) => addSliceViewSurfaceInspection(plan),
506
558
  };
507
559
  };
508
560
 
@@ -160,21 +160,27 @@ export const normalizeFieldType = (typeName: string) => {
160
160
  return normalizedTypes[typeName.toLowerCase()] ?? typeName;
161
161
  };
162
162
 
163
- export const ensureBaseTypeImport = (content: string, typeName: string) => {
164
- if (typeName !== "Int" && typeName !== "Float") return content;
165
- const source = sourceFileFor("constant.ts", content);
166
- const baseImport = findNamedImport(source, "akanjs/base");
167
- if (baseImport) {
168
- if (baseImport.names.includes(typeName)) return content;
169
- const nextNames = [...baseImport.names, typeName].sort();
163
+ export const ensureNamedImport = (content: string, moduleSpecifier: string, name: string) => {
164
+ const source = sourceFileFor("source.ts", content);
165
+ const namedImport = findNamedImport(source, moduleSpecifier);
166
+ if (namedImport) {
167
+ if (namedImport.names.includes(name)) return content;
168
+ const nextNames = [...namedImport.names, name].sort();
170
169
  return spliceText(
171
170
  content,
172
- baseImport.namedBindingsStart,
173
- baseImport.namedBindingsEnd,
171
+ namedImport.namedBindingsStart,
172
+ namedImport.namedBindingsEnd,
174
173
  `{ ${nextNames.join(", ")} }`,
175
174
  );
176
175
  }
177
- return `import { ${typeName} } from "akanjs/base";\n${content}`;
176
+ return `import { ${name} } from "${moduleSpecifier}";\n${content}`;
177
+ };
178
+
179
+ export const ensureBaseImport = (content: string, name: string) => ensureNamedImport(content, "akanjs/base", name);
180
+
181
+ export const ensureBaseTypeImport = (content: string, typeName: string) => {
182
+ if (typeName !== "Int" && typeName !== "Float") return content;
183
+ return ensureBaseImport(content, typeName);
178
184
  };
179
185
 
180
186
  export type FieldDefaultValue = string | number | boolean | null;
@@ -200,12 +206,12 @@ const booleanDefault = (rawDefault: FieldDefaultValue): string | null => {
200
206
  };
201
207
 
202
208
  const dateDefault = (rawDefault: FieldDefaultValue): string | null => {
203
- if (typeof rawDefault === "number" && Number.isFinite(rawDefault)) return `new Date(${rawDefault})`;
209
+ if (typeof rawDefault === "number" && Number.isFinite(rawDefault)) return `() => dayjs(${rawDefault})`;
204
210
  if (typeof rawDefault !== "string") return null;
205
211
  const trimmed = rawDefault.trim();
206
212
  if (!trimmed) return null;
207
- if (trimmed === "now") return "new Date()";
208
- if (!Number.isNaN(Date.parse(trimmed))) return `new Date(${JSON.stringify(trimmed)})`;
213
+ if (trimmed === "now") return "() => dayjs()";
214
+ if (!Number.isNaN(Date.parse(trimmed))) return `() => dayjs(${JSON.stringify(trimmed)})`;
209
215
  return null;
210
216
  };
211
217
 
@@ -1,105 +0,0 @@
1
- import {
2
- closeSync,
3
- existsSync,
4
- mkdirSync,
5
- openSync,
6
- readFileSync,
7
- renameSync,
8
- unlinkSync,
9
- writeFileSync,
10
- } from "node:fs";
11
- import path from "node:path";
12
- import type { AuthStorageBackend } from "@earendil-works/pi-coding-agent";
13
-
14
- /**
15
- * Credential store for `akan code`, in akan's own file rather than the engine's.
16
- *
17
- * The engine only asks for a locked read-modify-write over one opaque string, so satisfying its interface with
18
- * our own file keeps every key under `~/.akan/` — nothing lands in `~/.pi/` and a user who removes akan takes
19
- * their credentials with them.
20
- *
21
- * The lock is an exclusive-create sentinel rather than an advisory flock: two `akan code` processes refreshing
22
- * an OAuth token at the same moment would otherwise each write a token the other invalidates.
23
- */
24
- export class AkanAuthStorageBackend implements AuthStorageBackend {
25
- readonly #authPath: string;
26
- readonly #lockPath: string;
27
-
28
- constructor(authPath: string) {
29
- this.#authPath = authPath;
30
- this.#lockPath = `${authPath}.lock`;
31
- }
32
-
33
- withLock<T>(fn: (current: string | undefined) => { result: T; next?: string }) {
34
- this.#acquire();
35
- try {
36
- const { result, next } = fn(this.#read());
37
- if (next !== undefined) this.#write(next);
38
- return result;
39
- } finally {
40
- this.#release();
41
- }
42
- }
43
-
44
- async withLockAsync<T>(fn: (current: string | undefined) => Promise<{ result: T; next?: string }>) {
45
- this.#acquire();
46
- try {
47
- const { result, next } = await fn(this.#read());
48
- if (next !== undefined) this.#write(next);
49
- return result;
50
- } finally {
51
- this.#release();
52
- }
53
- }
54
-
55
- #read() {
56
- if (!existsSync(this.#authPath)) return undefined;
57
- const text = readFileSync(this.#authPath, "utf8");
58
- return text.trim() ? text : undefined;
59
- }
60
-
61
- #write(next: string) {
62
- mkdirSync(path.dirname(this.#authPath), { recursive: true, mode: 0o700 });
63
- const temp = `${this.#authPath}.${process.pid}.tmp`;
64
- writeFileSync(temp, next, { mode: 0o600 });
65
- renameSync(temp, this.#authPath);
66
- }
67
-
68
- #acquire() {
69
- mkdirSync(path.dirname(this.#lockPath), { recursive: true, mode: 0o700 });
70
- const deadline = Date.now() + 5_000;
71
- for (;;) {
72
- try {
73
- closeSync(openSync(this.#lockPath, "wx"));
74
- return;
75
- } catch {
76
- // A lock left behind by a killed process would block every later run, so it expires.
77
- if (this.#lockIsStale() || Date.now() > deadline) {
78
- try {
79
- unlinkSync(this.#lockPath);
80
- } catch {
81
- // Another process won the race to clear it; retry the create.
82
- }
83
- continue;
84
- }
85
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
86
- }
87
- }
88
- }
89
-
90
- #lockIsStale() {
91
- try {
92
- return Date.now() - Bun.file(this.#lockPath).lastModified > 30_000;
93
- } catch {
94
- return true;
95
- }
96
- }
97
-
98
- #release() {
99
- try {
100
- unlinkSync(this.#lockPath);
101
- } catch {
102
- // Already gone — a stale-lock sweep by another process.
103
- }
104
- }
105
- }
package/guideline.ts DELETED
@@ -1,19 +0,0 @@
1
- export interface GuideScan {
2
- type: "example" | "source" | "usage" | (string & {});
3
- description: string;
4
- path: string;
5
- filterText?: string;
6
- sample?: number;
7
- }
8
- export interface GuideUpdate {
9
- filePath: string;
10
- contents: string[];
11
- rules: string[];
12
- }
13
- export interface GuideGenerateJson {
14
- title: string;
15
- description: string;
16
- scans: GuideScan[];
17
- update: GuideUpdate;
18
- page?: string;
19
- }