@akanjs/devkit 3.1.2-rc.3 → 3.2.0-rc.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/abstractDoc.ts +1 -1
  3. package/agentAssets.ts +2 -2
  4. package/akanConfig/akanConfig.ts +2 -2
  5. package/akanMcpContract.ts +2 -2
  6. package/apiReference.test.ts +37 -0
  7. package/apiReference.ts +240 -0
  8. package/applicationBuildReporter.test.ts +54 -0
  9. package/applicationBuildReporter.ts +12 -0
  10. package/applicationBuildRunner.test.ts +34 -14
  11. package/applicationBuildRunner.ts +77 -14
  12. package/artifact/implicitRootLayout.test.ts +42 -0
  13. package/artifact/implicitRootLayout.ts +5 -6
  14. package/artifact/storeBootstrap.ts +24 -0
  15. package/cloud/cloudApi.test.ts +70 -7
  16. package/cloud/cloudApi.ts +10 -4
  17. package/cloud/constants.ts +3 -0
  18. package/cloud/globalConfig.test.ts +36 -0
  19. package/cloud/globalConfig.ts +10 -1
  20. package/codeAgent/skills/akan-debug/SKILL.md +2 -1
  21. package/codeAgent/skills/akan-page/SKILL.md +30 -2
  22. package/codeAgent/skills/akan-service/SKILL.md +77 -1
  23. package/commandDecorators/command.ts +55 -9
  24. package/commandDecorators/commandDecorators.test.ts +131 -2
  25. package/declarationReader.ts +211 -0
  26. package/executors.test.ts +65 -8
  27. package/executors.ts +5 -3
  28. package/frameworkGraph.test.ts +121 -0
  29. package/frameworkGraph.ts +165 -0
  30. package/frontendBuild/allRoutesBuilder.test.ts +84 -0
  31. package/frontendBuild/allRoutesBuilder.ts +32 -12
  32. package/frontendBuild/buildRouteClient.test.ts +192 -2
  33. package/frontendBuild/bundleDefine.ts +4 -0
  34. package/frontendBuild/clientBuildTypes.ts +7 -1
  35. package/frontendBuild/clientEntriesBundler.test.ts +82 -0
  36. package/frontendBuild/clientEntriesBundler.ts +55 -4
  37. package/frontendBuild/csrArtifactBuilder.test.ts +23 -0
  38. package/frontendBuild/csrArtifactBuilder.ts +2 -1
  39. package/frontendBuild/fontOptimizer.test.ts +54 -0
  40. package/frontendBuild/fontOptimizer.ts +5 -4
  41. package/frontendBuild/index.ts +3 -0
  42. package/frontendBuild/routeClientBuilder.ts +6 -2
  43. package/frontendBuild/routeModulePreloads.test.ts +59 -0
  44. package/frontendBuild/routeModulePreloads.ts +52 -0
  45. package/frontendBuild/ssrBaseArtifactBuilder.ts +35 -10
  46. package/frontendBuild/ssrDevBundler.test.ts +26 -0
  47. package/frontendBuild/ssrDevBundler.ts +2 -1
  48. package/frontendBuild/vendorImportScanner.test.ts +71 -0
  49. package/frontendBuild/vendorImportScanner.ts +80 -0
  50. package/frontendBuild/vendorNarrowing.test.ts +103 -0
  51. package/frontendBuild/vendorNarrowing.ts +64 -0
  52. package/frontendBuild/vendorSpecifiers.ts +13 -0
  53. package/gitRevision.test.ts +82 -0
  54. package/gitRevision.ts +128 -0
  55. package/incrementalBuilder/builderGraphPayloads.ts +60 -0
  56. package/incrementalBuilder/incrementalBuilder.proc.test.ts +105 -6
  57. package/incrementalBuilder/incrementalBuilder.proc.ts +6 -10
  58. package/integration/devStability.integration.test.ts +1 -1
  59. package/integration/devStabilityHarness.test.ts +1 -1
  60. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +4 -2
  61. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +2 -0
  62. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -2
  63. package/lint/no-daisyui-legacy-class.grit +9 -7
  64. package/lint/no-deep-internal-import.grit +1 -1
  65. package/lint/no-js-private-class-method.grit +9 -28
  66. package/maintainabilityBaseline.ts +7 -2
  67. package/maintainabilityContract.test.ts +183 -0
  68. package/maintainabilityContract.ts +145 -30
  69. package/maintainabilityExceptionScanner.test.ts +13 -2
  70. package/maintainabilityExceptionScanner.ts +16 -6
  71. package/maintainabilityLimits.ts +12 -0
  72. package/maintainabilityReport.test.ts +35 -0
  73. package/maintainabilityReport.ts +29 -8
  74. package/mobile/nativePublicEnv.test.ts +5 -1
  75. package/package.json +3 -3
  76. package/routeJsBudget.test.ts +177 -0
  77. package/routeJsBudget.ts +222 -0
  78. package/structureScanner.test.ts +167 -0
  79. package/structureScanner.ts +164 -0
  80. package/transforms/barrelAnalyzer.ts +10 -3
  81. package/transforms/barrelImportsPlugin.ts +13 -7
  82. package/transforms/serverEnvPlugin.test.ts +1 -1
  83. package/transforms/serverEnvPlugin.ts +1 -1
  84. package/transforms/transforms.test.ts +64 -12
  85. package/transforms/tsconfigPackageResolver.test.ts +12 -0
  86. package/typeChecker.test.ts +100 -0
  87. package/typeChecker.ts +14 -2
  88. package/workflow/plan.ts +79 -17
  89. package/workflow/types.ts +1 -0
  90. package/workspaceGraph.test.ts +121 -0
  91. package/workspaceGraph.ts +149 -16
  92. package/workspaceLayout.ts +1 -0
  93. /package/integration/{devStabilityHarness.ts → devStabilityHarness.fixture.ts} +0 -0
@@ -2,6 +2,7 @@ import { readdir, rm } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import type { App } from "../commandDecorators";
4
4
  import { AsyncDefaultExportDetector } from "../transforms/asyncDefaultExportDetector";
5
+ import { StoreBootstrap } from "./storeBootstrap";
5
6
 
6
7
  export interface PageEntry {
7
8
  key: string;
@@ -148,7 +149,7 @@ async function writeGeneratedRootLayoutFile(opts: {
148
149
  appName: string;
149
150
  boundary: RootBoundary;
150
151
  rootSourceAbsPath: string | null;
151
- includeStInit: boolean;
152
+ includeStoreBootstrap: boolean;
152
153
  includeSystemProvider: boolean;
153
154
  }): Promise<string> {
154
155
  await assertEnvClientConvention(opts.appCwdPath, opts.appName);
@@ -161,9 +162,7 @@ async function writeGeneratedRootLayoutFile(opts: {
161
162
  const inheritedSourceAbsPath =
162
163
  opts.rootSourceAbsPath && opts.rootSourceAbsPath !== opts.boundary.sourceAbsPath ? opts.rootSourceAbsPath : null;
163
164
  const inheritedSourceSpecifier = inheritedSourceAbsPath ? importSpecifier(absPath, inheritedSourceAbsPath) : null;
164
- const clientImport = opts.includeStInit
165
- ? `import { st } from "@apps/${opts.appName}/client";\nvoid st;\n`
166
- : `import "@apps/${opts.appName}/client";\n`;
165
+ const storeImport = opts.includeStoreBootstrap ? StoreBootstrap.source(opts.appName) : "";
167
166
  //? resolveRouteModule reads a rootLayout() chain and the legacy named exports alike.
168
167
  const inheritedLabel = inheritedSourceAbsPath ? posixRelative(opts.appCwdPath, inheritedSourceAbsPath) : "";
169
168
  const inheritedImport = inheritedSourceSpecifier
@@ -223,7 +222,7 @@ import { System } from "akanjs/ui/System/index.tsx";
223
222
  import type { McpAppRootProps } from "akanjs/webkit";
224
223
  import { env } from "@apps/${opts.appName}/env/env.client";
225
224
  import { allDictionary } from ${JSON.stringify(dictMacroSpecifier)};
226
- ${clientImport}${inheritedImport}${userImport}
225
+ ${storeImport}${inheritedImport}${userImport}
227
226
  const userFonts = userLayout.fonts ?? inheritedLayout.fonts ?? [];
228
227
  const defaultFonts = userFonts.filter((font) => font.default);
229
228
  if (defaultFonts.length > 1) throw new Error("[route-convention] only one default font is allowed per root layout");
@@ -323,7 +322,7 @@ export async function resolveSsrPageEntries(opts: {
323
322
  appName: opts.appName,
324
323
  boundary,
325
324
  rootSourceAbsPath,
326
- includeStInit: hasSt && boundary.segments.length === 0,
325
+ includeStoreBootstrap: hasSt,
327
326
  includeSystemProvider: !hasAncestorRootBoundary(boundary, rootBoundaries),
328
327
  }),
329
328
  seedAbsPaths: [...new Set([boundary.sourceAbsPath, rootSourceAbsPath].filter((absPath) => absPath !== null))],
@@ -0,0 +1,24 @@
1
+ import type { BarrelExportMap } from "../transforms/barrelAnalyzer";
2
+
3
+ //* Not a bare `@apps/<app>/client` import: it names nothing the barrel rewrite can narrow, so every module's UI ships.
4
+ export class StoreBootstrap {
5
+ static importLines(appName: string): string[] {
6
+ return StoreBootstrap.#lines([`@apps/${appName}/lib/useClient`, `@apps/${appName}/lib/st`]);
7
+ }
8
+
9
+ static source(appName: string): string {
10
+ return `${StoreBootstrap.importLines(appName).join("\n")}\n`;
11
+ }
12
+
13
+ static ofClientBarrel(specifier: string, exports: BarrelExportMap): string[] | null {
14
+ if (!/^@(?:apps|libs)\/[^/]+\/client$/.test(specifier)) return null;
15
+ const runtime = exports.get("usePage");
16
+ const store = exports.get("st");
17
+ if (!runtime || runtime.namespace || !store || store.namespace) return StoreBootstrap.#lines([specifier]);
18
+ return StoreBootstrap.#lines([...new Set([runtime.subpath, store.subpath])]);
19
+ }
20
+
21
+ static #lines(specifiers: string[]): string[] {
22
+ return specifiers.map((specifier) => `import ${JSON.stringify(specifier)};`);
23
+ }
24
+ }
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { readFile, rm } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import dayjs from "dayjs";
4
5
  import type { Workspace } from "../commandDecorators";
5
6
  import { tempDirs, writeJson, writeText } from "../testHelpers";
6
7
  import { CloudApi } from "./cloudApi";
@@ -9,8 +10,10 @@ import { CloudApi } from "./cloudApi";
9
10
  class FakeCloud {
10
11
  readonly server: ReturnType<typeof Bun.serve>;
11
12
  readonly refreshes: string[] = [];
13
+ readonly refreshBearers: (string | null)[] = [];
12
14
  reused = false;
13
15
  delayMs = 0;
16
+ lifetimeMs = 7 * 86_400_000;
14
17
  hang: boolean = false;
15
18
  readonly remoteAuthPolls: string[] = [];
16
19
  remoteAuthAnswers: (Response | { jwt: string; refreshToken?: string } | null)[] = [];
@@ -44,6 +47,7 @@ class FakeCloud {
44
47
  if (url.pathname !== "/api/refreshAuthToken") return new Response("not found", { status: 404 });
45
48
  const { refreshToken } = (await request.json()) as { refreshToken: string };
46
49
  this.refreshes.push(refreshToken);
50
+ this.refreshBearers.push(request.headers.get("authorization"));
47
51
  if (this.#spent.has(refreshToken)) this.reused = true;
48
52
  if (!this.#refreshTokens.delete(refreshToken)) return Response.json({ error: "invalid" }, { status: 401 });
49
53
  this.#spent.add(refreshToken);
@@ -53,7 +57,7 @@ class FakeCloud {
53
57
  this.#jwts.add(next.jwt);
54
58
  if (this.hang) await new Promise(() => undefined);
55
59
  await Bun.sleep(this.delayMs);
56
- return Response.json({ ...next, expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString() });
60
+ return Response.json({ ...next, expiresAt: new Date(Date.now() + this.lifetimeMs).toISOString() });
57
61
  }
58
62
  }
59
63
 
@@ -100,13 +104,21 @@ await GlobalConfig.setTestTargets({ linux: { cpus: 2 } });
100
104
  return { code: await proc.exited, stderr: await new Response(proc.stderr).text() };
101
105
  };
102
106
 
103
- const storeSession = async (home: string, { expiresInMs = 30 * 60_000, refreshToken = "refresh-0" } = {}) =>
107
+ const storeSession = async (
108
+ home: string,
109
+ { expiresInMs = 30 * 60_000, refreshToken = "refresh-0", issuedAgoMs = null as number | null } = {},
110
+ ) =>
104
111
  await writeJson(path.join(home, ".akan", "config.json"), {
105
112
  cloudHost: {
106
113
  [cloud.host]: {
107
114
  host: cloud.host,
108
115
  auth: {
109
- accessToken: { jwt: "jwt-0", refreshToken, expiresAt: new Date(Date.now() + expiresInMs).toISOString() },
116
+ accessToken: {
117
+ jwt: "jwt-0",
118
+ refreshToken,
119
+ expiresAt: new Date(Date.now() + expiresInMs).toISOString(),
120
+ issuedAt: issuedAgoMs === null ? null : new Date(Date.now() - issuedAgoMs).toISOString(),
121
+ },
110
122
  self: { id: "u1", nickname: "akan" },
111
123
  },
112
124
  },
@@ -114,9 +126,34 @@ const storeSession = async (home: string, { expiresInMs = 30 * 60_000, refreshTo
114
126
  });
115
127
  const storedToken = async (home: string) => {
116
128
  const config = JSON.parse(await readFile(path.join(home, ".akan", "config.json"), "utf8"));
117
- return config.cloudHost[cloud.host].auth.accessToken as { jwt: string; refreshToken: string | null };
129
+ return config.cloudHost[cloud.host].auth.accessToken as {
130
+ jwt: string;
131
+ refreshToken: string | null;
132
+ issuedAt?: string | null;
133
+ };
118
134
  };
119
135
 
136
+ describe("CloudApi.needsSignIn", () => {
137
+ const apiWith = (accessToken: { refreshToken: string | null; expiresInMs: number }) =>
138
+ new CloudApi({ workspaceRoot: "/nowhere" } as unknown as Workspace, {
139
+ host: cloud.host,
140
+ auth: {
141
+ accessToken: {
142
+ jwt: "jwt-0",
143
+ refreshToken: accessToken.refreshToken,
144
+ expiresAt: dayjs().add(accessToken.expiresInMs, "ms"),
145
+ },
146
+ },
147
+ });
148
+
149
+ test("asks for a sign-in only when a session due for refresh has no refresh token left", () => {
150
+ cloud = new FakeCloud();
151
+ expect(apiWith({ refreshToken: null, expiresInMs: 30 * 60_000 }).needsSignIn).toBe(true);
152
+ expect(apiWith({ refreshToken: "refresh-0", expiresInMs: 30 * 60_000 }).needsSignIn).toBe(false);
153
+ expect(apiWith({ refreshToken: null, expiresInMs: 3 * 3_600_000 }).needsSignIn).toBe(false);
154
+ });
155
+ });
156
+
120
157
  describe("CloudApi.fromHost token refresh", () => {
121
158
  test("two CLI processes with a session in its last hour make exactly one refresh, and both use its result", async () => {
122
159
  cloud = new FakeCloud();
@@ -165,20 +202,46 @@ describe("CloudApi.fromHost token refresh", () => {
165
202
  cloud.hang = false;
166
203
 
167
204
  const started = Date.now();
168
- expect(await output(await cliRun(home))).toEqual([null]);
205
+ expect(await output(await cliRun(home))).toEqual(["akan"]);
169
206
  expect(Date.now() - started).toBeLessThan(10_000);
170
207
  expect(cloud.refreshes).toEqual(["refresh-0"]);
171
208
  expect(cloud.reused).toBe(false);
172
209
  expect((await storedToken(home)).refreshToken).toBeNull();
173
210
  }, 30_000);
174
211
 
212
+ test("a cloud that issues one-hour tokens is refreshed once, and every command signs in with its token", async () => {
213
+ cloud = new FakeCloud();
214
+ cloud.lifetimeMs = 60 * 60_000;
215
+ const home = await makeHome();
216
+ await storeSession(home);
217
+
218
+ expect(await output(await cliRun(home))).toEqual(["akan"]);
219
+ expect(await output(await cliRun(home))).toEqual(["akan"]);
220
+ expect(cloud.refreshes).toEqual(["refresh-0"]);
221
+ expect(cloud.refreshBearers).toEqual([null]);
222
+ expect(await storedToken(home)).toMatchObject({ jwt: "jwt-1", refreshToken: "refresh-1" });
223
+ expect((await storedToken(home)).issuedAt).toBeString();
224
+ }, 30_000);
225
+
226
+ test("a one-hour token in the second half of its life is refreshed, and one in the first half is not", async () => {
227
+ cloud = new FakeCloud();
228
+ const home = await makeHome();
229
+ await storeSession(home, { expiresInMs: 35 * 60_000, issuedAgoMs: 25 * 60_000 });
230
+ expect(await output(await cliRun(home))).toEqual(["akan"]);
231
+ expect(cloud.refreshes).toEqual([]);
232
+
233
+ await storeSession(home, { expiresInMs: 25 * 60_000, issuedAgoMs: 35 * 60_000 });
234
+ expect(await output(await cliRun(home))).toEqual(["akan"]);
235
+ expect(cloud.refreshes).toEqual(["refresh-0"]);
236
+ }, 30_000);
237
+
175
238
  test("a refresh the cloud refuses is not presented again", async () => {
176
239
  cloud = new FakeCloud();
177
240
  const home = await makeHome();
178
241
  await storeSession(home, { refreshToken: "refresh-unknown" });
179
242
 
180
- expect(await output(await cliRun(home))).toEqual([null]);
181
- expect(await output(await cliRun(home))).toEqual([null]);
243
+ expect(await output(await cliRun(home))).toEqual(["akan"]);
244
+ expect(await output(await cliRun(home))).toEqual(["akan"]);
182
245
  expect(cloud.refreshes).toEqual(["refresh-unknown"]);
183
246
  expect((await storedToken(home)).refreshToken).toBeNull();
184
247
  }, 30_000);
package/cloud/cloudApi.ts CHANGED
@@ -144,9 +144,9 @@ export class CloudApi {
144
144
  static async fromHost(workspace: Workspace, host?: string) {
145
145
  const hostConfig = await GlobalConfig.getHostConfig(host);
146
146
  const accessToken = hostConfig.auth?.accessToken;
147
- // No refresh token on a session in its last hour can be another process's refresh in flight: wait on its lock.
147
+ // No refresh token on a session due for refresh can be another process's refresh in flight: wait on its lock.
148
148
  if (!accessToken || !GlobalConfig.needRefreshToken(accessToken)) return new CloudApi(workspace, hostConfig);
149
- const tokenless = new CloudApi(workspace, hostConfig);
149
+ const tokenless = new CloudApi(workspace, { host: hostConfig.host });
150
150
  const refreshed = await GlobalConfig.refreshHostAuth(hostConfig.host, (refreshToken) =>
151
151
  tokenless.refreshAuthToken(refreshToken),
152
152
  );
@@ -158,12 +158,18 @@ export class CloudApi {
158
158
  this.host = hostConfig.host;
159
159
  this.url = `${this.host}/api`;
160
160
  this.#api = new HttpClient(this.url);
161
- if (this.#accessToken && !GlobalConfig.needRefreshToken(this.#accessToken))
161
+ if (this.#accessToken && !GlobalConfig.isExpired(this.#accessToken))
162
162
  this.#api.setHeaders({ Authorization: `Bearer ${this.#accessToken.jwt}` });
163
163
  }
164
164
 
165
+ //? With no refresh token left, a session due for refresh ends at its expiry and only a new sign-in renews it.
166
+ get needsSignIn(): boolean {
167
+ const accessToken = this.#accessToken;
168
+ return !!accessToken && !accessToken.refreshToken && GlobalConfig.needRefreshToken(accessToken);
169
+ }
170
+
165
171
  #authorize(accessTokenDto: AccessTokenDto) {
166
- this.#accessToken = GlobalConfig.toAccessToken(accessTokenDto);
172
+ this.#accessToken = GlobalConfig.toAccessToken({ ...accessTokenDto, issuedAt: new Date().toISOString() });
167
173
  this.#api.setHeaders({ Authorization: `Bearer ${this.#accessToken.jwt}` });
168
174
  return this.#accessToken;
169
175
  }
@@ -61,9 +61,12 @@ export interface AccessTokenDto {
61
61
  jwt: string;
62
62
  refreshToken: string | null;
63
63
  expiresAt: string | null;
64
+ issuedAt?: string | null;
64
65
  }
65
66
  export interface AccessToken {
66
67
  jwt: string;
67
68
  refreshToken: string | null;
68
69
  expiresAt: Dayjs | null;
70
+ //? When this CLI received the token, on its own clock: the cloud's answer names only the expiry.
71
+ issuedAt?: Dayjs | null;
69
72
  }
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { mkdir, stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import dayjs from "dayjs";
4
5
  import { tempDirs } from "../testHelpers";
5
6
  import { GlobalConfig } from "./globalConfig";
6
7
 
@@ -22,6 +23,41 @@ const modeOf = async (target: string) => (await stat(target)).mode & 0o777;
22
23
  const workspace = (cloudHost: string | null) => ({ getConfig: async () => ({ cloudHost }) });
23
24
 
24
25
  describe("GlobalConfig", () => {
26
+ test("needRefreshToken asks for a refresh in a token's last hour, or in the second half of a shorter life", () => {
27
+ const token = (issuedAgoMin: number | null, expiresInMin: number) => ({
28
+ jwt: "jwt",
29
+ refreshToken: "refresh",
30
+ expiresAt: dayjs().add(expiresInMin, "minute"),
31
+ issuedAt: issuedAgoMin === null ? null : dayjs().subtract(issuedAgoMin, "minute"),
32
+ });
33
+ const week = 7 * 24 * 60;
34
+ expect(GlobalConfig.needRefreshToken(token(0, 60))).toBe(false);
35
+ expect(GlobalConfig.needRefreshToken(token(25, 35))).toBe(false);
36
+ expect(GlobalConfig.needRefreshToken(token(35, 25))).toBe(true);
37
+ expect(GlobalConfig.needRefreshToken(token(0, 10))).toBe(false);
38
+ expect(GlobalConfig.needRefreshToken(token(week - 61, 61))).toBe(false);
39
+ expect(GlobalConfig.needRefreshToken(token(week - 59, 59))).toBe(true);
40
+ expect(GlobalConfig.needRefreshToken(token(null, 61))).toBe(false);
41
+ expect(GlobalConfig.needRefreshToken(token(null, 59))).toBe(true);
42
+ expect(GlobalConfig.isExpired(token(59, 1))).toBe(false);
43
+ expect(GlobalConfig.isExpired(token(61, -1))).toBe(true);
44
+ expect(GlobalConfig.needRefreshToken({ ...token(0, 59), issuedAt: dayjs("not a date") })).toBe(true);
45
+ });
46
+
47
+ test("keeps a token's issuedAt through config.json, and leaves the key out when there is none", () => {
48
+ const issuedAt = dayjs().subtract(10, "minute");
49
+ const token = { jwt: "jwt", refreshToken: "refresh", expiresAt: dayjs().add(50, "minute"), issuedAt };
50
+ const read = GlobalConfig.toAccessToken(
51
+ JSON.parse(JSON.stringify(GlobalConfig.toAccessTokenDto(token))) as ReturnType<
52
+ typeof GlobalConfig.toAccessTokenDto
53
+ >,
54
+ );
55
+ expect(Math.abs(read.issuedAt?.diff(issuedAt) ?? Number.NaN)).toBeLessThan(1_000);
56
+ expect(GlobalConfig.needRefreshToken(read)).toBe(false);
57
+ const legacy = { jwt: "jwt", refreshToken: "refresh", expiresAt: null };
58
+ expect(GlobalConfig.toAccessTokenDto(GlobalConfig.toAccessToken(legacy))).toEqual(legacy);
59
+ });
60
+
25
61
  test("hostFor spells --host the way the workspace's cloud.host is spelled, so one login serves both", async () => {
26
62
  for (const given of ["https://cloud.example.com/", "https://Cloud.Example.com:443", "https://cloud.example.com//"])
27
63
  expect(await GlobalConfig.hostFor(workspace(null), given)).toBe("https://cloud.example.com");
@@ -129,14 +129,22 @@ export class GlobalConfig {
129
129
  testTargets: { ...akanConfig.testTargets, ...testTargets },
130
130
  }));
131
131
  }
132
+ static readonly #refreshMarginMs = 60 * 60_000;
132
133
  static needRefreshToken(accessToken: AccessToken): boolean {
133
- return !!accessToken?.expiresAt?.isBefore(dayjs().add(1, "hour"));
134
+ const { expiresAt, issuedAt } = accessToken;
135
+ if (!expiresAt) return false;
136
+ const lifetimeMs = issuedAt?.isValid() ? expiresAt.diff(issuedAt) : Number.POSITIVE_INFINITY;
137
+ return expiresAt.isBefore(dayjs().add(Math.min(GlobalConfig.#refreshMarginMs, lifetimeMs / 2), "ms"));
138
+ }
139
+ static isExpired(accessToken: AccessToken): boolean {
140
+ return !!accessToken.expiresAt && !accessToken.expiresAt.isAfter(dayjs());
134
141
  }
135
142
  static toAccessToken(accessToken: AccessTokenDto): AccessToken {
136
143
  return {
137
144
  jwt: accessToken.jwt,
138
145
  refreshToken: accessToken.refreshToken ?? null,
139
146
  expiresAt: accessToken.expiresAt ? dayjs(accessToken.expiresAt) : null,
147
+ ...(accessToken.issuedAt ? { issuedAt: dayjs(accessToken.issuedAt) } : {}),
140
148
  };
141
149
  }
142
150
  static toAccessTokenDto(accessToken: AccessToken): AccessTokenDto {
@@ -144,6 +152,7 @@ export class GlobalConfig {
144
152
  jwt: accessToken.jwt,
145
153
  refreshToken: accessToken.refreshToken ?? null,
146
154
  expiresAt: accessToken.expiresAt?.toString() ?? null,
155
+ ...(accessToken.issuedAt ? { issuedAt: accessToken.issuedAt.toString() } : {}),
147
156
  };
148
157
  }
149
158
  static toHostConfigDto(hostConfig: HostConfig): HostConfigDto {
@@ -15,7 +15,8 @@ the server errors logged meanwhile, console errors and warnings, and layout smel
15
15
  stops it after; `--keep` leaves it up for the next preview. It never starts a second server beside a
16
16
  running one.
17
17
  - Paths are comma-separated (`akan preview <app> /board,/board/new`) and get the default locale when they lack
18
- one, unless the app sets `localePrefix: "as-needed"` and `--csr` is off.
18
+ one, unless the app sets `localePrefix: "as-needed"` and `--csr` is off. A `/_akan` route such as Studio
19
+ (`/_akan/studio?view=runtime`), a signal route or a file opens as written.
19
20
  `--changed` adds the pages your uncommitted page and layout edits reach. A dynamic route needs a concrete
20
21
  path (`/memo/1`); `akan context --app <app>` lists every route with its params and search names.
21
22
  - The viewport is a 390x844 phone; `--desktop` is 1280x800. `--csr` loads each page in the CSR boot the native
@@ -8,6 +8,11 @@ description: "Add or change a route under page/: the one page()/layout() chain,
8
8
  A route file exports one chain and nothing else. Every setting is a stage of it.
9
9
 
10
10
  ```tsx
11
+ import { fetch, Task, usePage } from "@apps/<app>/client";
12
+ import { signedIn } from "@libs/shared/webkit";
13
+ import { ID } from "akanjs/base";
14
+ import { page } from "akanjs/client";
15
+
11
16
  export default page()
12
17
  .param("orgId", ID)
13
18
  .guard(() => signedIn())
@@ -26,7 +31,8 @@ export default page()
26
31
  2. Name every `[x]` segment with `.param("x", Type)` and read a query key only with `.search("k", Type)`. Values
27
32
  arrive typed; `lang` reaches every stage and is never declared.
28
33
  3. Gate with `.guard(() => signedIn())` on the `_layout.tsx` (a real redirect before anything renders), and repeat
29
- a stricter guard on a page that needs more. The endpoint guards are still the real gate.
34
+ a stricter guard on a page that needs more. The endpoint guards are still the real gate. `signedIn` comes from
35
+ `@libs/shared/webkit`, and a new workspace has that lib only with `create-workspace --libs true`.
30
36
  4. In `.render()`: `usePage()`, then fetch, then return. Hand `fetch.init*` / `view*` / `edit*` fields to sections
31
37
  unawaited; await only what the shell reads, in one `Promise.all`.
32
38
  5. `.head()` takes JSX (or a function returning it), never a metadata object. A canonical link comes only from the
@@ -34,6 +40,27 @@ export default page()
34
40
  6. Re-skin an `akanjs/ui` component for a subtree with `_overrides.tsx`:
35
41
  `export default override({ Slot: AppComponent })`, no logic.
36
42
 
43
+ ## API at a glance
44
+
45
+ `page`, `layout` and `router` come from `akanjs/client`, `Link` from `akanjs/ui`, `usePageLocation` from
46
+ `akanjs/webkit`, and `fetch`, `usePage` and the model namespaces from `@apps/<app>/client`. A stage receives the
47
+ declared params and search keys plus `lang`.
48
+
49
+ - Data-driven title: `.head(async ({ id }) => <title>{((await fetch.book(id)) as Book | null)?.title}</title>)`,
50
+ where `Book` is the `common/` interface for the query's `Any` return (`akan-service`).
51
+ - Async page: `.render(async ({ id }) => { const book = (await fetch.book(id)) as Book | null; if (!book) return router.notFound(); … })`;
52
+ the same query in `.head()` and `.render()` is one request.
53
+ - `router.notFound()` from `.guard()`, `.head()` or `.render()` answers 404 with the nearest layout's `.notFound()`
54
+ above it (an unmatched URL: the deepest layout covering it), else the built-in 404.
55
+ - A layout's own 404 and error views: `.notFound(({ pathname }) => …)` and `.error(({ error }) => …)` on `layout()`
56
+ or `rootLayout()`.
57
+ - `<Link href="/books/OL45804W">`: write the path without the locale; Link adds it, or leaves it off for the default
58
+ locale under `i18n.localePrefix: "as-needed"`. `activeClassName` marks the current one.
59
+ - From a handler: `router.push(href)` / `router.replace(href)`; in a server stage, `router.redirect(href)`.
60
+ - A client filter kept in the URL without reloading the page: ``router.replace(`/books?q=${q}`, { shallow: true,
61
+ scrollToTop: false })`` in a `"use client"` component, read back with `usePageLocation().searchParams`. Shallow
62
+ holds only while the path stays the same; a server `.search()` sees the value at the next full navigation.
63
+
37
64
  ## Never
38
65
 
39
66
  - A `fetch.<mutation>()` while rendering — it throws `base.error.mutationInRender`, because opening a link would
@@ -41,4 +68,5 @@ export default page()
41
68
  - `useState`, `useEffect`, comments, a named export beside the chain, or the legacy `export default async function`.
42
69
  - Editing a lib page through its link in `page/(libs)/` — edit the lib's own `page/` source.
43
70
 
44
- Full detail: `get_guideline` with `pageRule`, or `ssrRule` for what may render on the server.
71
+ Full detail: `get_guideline` with `pageRule`, or `ssrRule` for what may render on the server; every stage's
72
+ signature, the router's methods, Link's props and every import path: `akanApi`.
@@ -20,6 +20,10 @@ dictionary is a `serviceDictionary`.
20
20
  explicitly `void`-ed.
21
21
  - Return `null` / `false` for "not allowed" or "not found" and let the signal decide whether that is an
22
22
  error.
23
+ - `serve(name, { serverMode: "batch" }, …)` mounts the module, endpoints included, only in a `batch` or `all`
24
+ process; a `federation` replica, the one serving requests in a split deployment, skips it. The app's own
25
+ `lib/_<app>/` service is scaffolded with it for cron and queue work. Leave it off a service a page calls, as
26
+ `akan create-service` does.
23
27
 
24
28
  ## Reaching other things
25
29
 
@@ -31,4 +35,76 @@ registered under `<refName>`, and the `Service` suffix is required.
31
35
  An external API or piece of infrastructure is an `adapt()` class in `srvkit/`, injected with `plug`. It
32
36
  self-registers, so do **not** add it to `lib/option.ts`.
33
37
 
34
- Full detail: `get_guideline` with `modelService`.
38
+ ## Recipe: pages backed by an external HTTP API
39
+
40
+ `akan create-service openlibrary <app>` scaffolds `lib/_openlibrary/`; the adapter beside it is yours.
41
+
42
+ ```ts
43
+ // srvkit/bookApi.ts — read at boot, so the built server takes API_BASE_URL from the host that runs it
44
+ import { adapt } from "akanjs/service";
45
+
46
+ export class BookApi extends adapt("bookApi" as const, ({ env }) => ({
47
+ baseUrl: env(() => process.env.API_BASE_URL ?? "http://localhost:9000"),
48
+ })) {
49
+ async get<T>(path: string): Promise<T | null> {
50
+ try {
51
+ const res = await fetch(`${this.baseUrl}/${path}`, { signal: AbortSignal.timeout(20_000) });
52
+ if (res.ok) return (await res.json()) as T;
53
+ if (res.status !== 404) this.logger.warn(`${path} answered ${res.status}`);
54
+ } catch (error) {
55
+ this.logger.warn(`${path} failed: ${String(error)}`);
56
+ }
57
+ return null;
58
+ }
59
+ }
60
+
61
+ // lib/_openlibrary/openlibrary.service.ts — shape the upstream JSON here, not in the page
62
+ import { BookApi } from "@apps/<app>/srvkit";
63
+ import { serve } from "akanjs/service";
64
+
65
+ interface ApiWork {
66
+ key: string;
67
+ title: string;
68
+ }
69
+
70
+ export class OpenlibraryService extends serve("openlibrary" as const, ({ plug }) => ({ bookApi: plug(BookApi) })) {
71
+ async getBook(id: string) {
72
+ const work = await this.bookApi.get<ApiWork>(`works/${id}.json`);
73
+ return work && { id, title: work.title };
74
+ }
75
+ }
76
+
77
+ // lib/_openlibrary/openlibrary.signal.ts — Any answers plain JSON; nullable lets it answer null for "not found"
78
+ import { Any } from "akanjs/base";
79
+ import { endpoint, internal, Public } from "akanjs/signal";
80
+
81
+ import * as srv from "../srv";
82
+
83
+ export class OpenlibraryInternal extends internal(srv.openlibrary, () => ({})) {}
84
+
85
+ export class OpenlibraryEndpoint extends endpoint(srv.openlibrary, ({ query }) => ({
86
+ book: query(Any, { guards: [Public], nullable: true })
87
+ .param("id", String)
88
+ .exec(async function (id) {
89
+ return await this.openlibraryService.getBook(id);
90
+ }),
91
+ })) {}
92
+
93
+ // lib/_openlibrary/openlibrary.dictionary.ts — typecheck asks a label for every endpoint
94
+ import { serviceDictionary } from "akanjs/dictionary";
95
+
96
+ import type { OpenlibraryEndpoint } from "../sig";
97
+
98
+ export const dictionary = serviceDictionary(["en", "ko"])
99
+ .endpoint<OpenlibraryEndpoint>((fn) => ({ book: fn(["Book", "책"]) }))
100
+ .error({})
101
+ .translate({});
102
+ ```
103
+
104
+ The page calls it as `await fetch.book(id)` (`fetch` from `@apps/<app>/client`) and ends with
105
+ `return router.notFound()` on `null`. An `Any` return reaches the page as `unknown`: type it with an interface in
106
+ `common/`, or return a scalar (`akan create-scalar`) for a typed wire shape. A query that answers `null` without
107
+ `nullable: true` fails the render, naming the endpoint.
108
+
109
+ Full detail: `get_guideline` with `modelService`; every import path and the exact signatures of serve, adapt,
110
+ endpoint and serviceDictionary: `akanApi`.
@@ -229,6 +229,28 @@ const assertCurrentDirectoryIsWorkspaceRoot = async () => {
229
229
  );
230
230
  };
231
231
 
232
+ export type RerunOf = (target: string) => string;
233
+
234
+ const shellWordOf = (word: string) => (/^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`);
235
+
236
+ //? Commander fills operands left to right, so every operand before an empty slot was given.
237
+ export const rerunCommandOf =
238
+ (argv: readonly string[], operands: readonly unknown[], slot: number): RerunOf =>
239
+ (target) => {
240
+ const [commandName = "", ...words] = argv;
241
+ let at = 0;
242
+ for (const operand of operands.slice(0, slot)) if (typeof operand === "string") at = words.indexOf(operand, at) + 1;
243
+ const given = operands[slot];
244
+ const givenAt = typeof given === "string" ? words.indexOf(given, at) : -1;
245
+ const rerun = words.map(shellWordOf);
246
+ if (givenAt >= 0) rerun.splice(givenAt, 1, target);
247
+ else rerun.splice(at, 0, target);
248
+ return ["akan", commandName, ...rerun].join(" ");
249
+ };
250
+
251
+ const rerunRemedyOf = (rerun: RerunOf | undefined, placeholder: string) =>
252
+ rerun ? { remedy: `rerun it as \`${rerun(placeholder)}\`` } : {};
253
+
232
254
  const parseAppNameList = (value: string | string[] | undefined): string[] =>
233
255
  (Array.isArray(value) ? value : value === undefined ? [] : [value])
234
256
  .flatMap((entry) => entry.split(","))
@@ -239,6 +261,7 @@ const parseAppNameList = (value: string | string[] | undefined): string[] =>
239
261
  export const getAppsArgumentValue = async (
240
262
  value: string | string[] | undefined,
241
263
  workspace: WorkspaceExecutor,
264
+ rerun?: RerunOf,
242
265
  ): Promise<AppExecutor[]> => {
243
266
  const appNames = await workspace.getApps();
244
267
  if (appNames.length === 0) throw new Error("No apps found in this workspace (apps/<appName>/akan.config.ts)");
@@ -253,7 +276,7 @@ export const getAppsArgumentValue = async (
253
276
  return [...new Set(requested)].map((name) => AppExecutor.from(workspace, name));
254
277
  }
255
278
  if (appNames.length === 1 && appNames[0]) return [AppExecutor.from(workspace, appNames[0])];
256
- assertPromptable("Missing apps", { choices: [...appNames, "all"] });
279
+ assertPromptable("Missing apps", { choices: [...appNames, "all"], ...rerunRemedyOf(rerun, "<apps...>") });
257
280
  const remembered = new Set(await AppSelectionMemory.read(workspace.workspaceRoot));
258
281
  const picked = await checkbox<string>({
259
282
  message: "Select the apps to run (space to toggle, enter to confirm)",
@@ -269,6 +292,7 @@ export const getInternalArgumentValue = async (
269
292
  argMeta: InternalArgMeta,
270
293
  value: string | undefined,
271
294
  workspace: WorkspaceExecutor,
295
+ rerun?: RerunOf,
272
296
  ) => {
273
297
  if (argMeta.type === "Workspace") return workspace;
274
298
  const sysType = argMeta.type.toLowerCase();
@@ -286,12 +310,24 @@ export const getInternalArgumentValue = async (
286
310
  if (found) return found;
287
311
  if (!value && argMeta.option?.onlyApp && appNames.length === 1 && appNames[0])
288
312
  return AppExecutor.from(workspace, appNames[0]);
289
- assertPromptable(problemOf(sysType === "exec" ? "app, lib or pkg" : "app or lib"), {
290
- choices: [...appNames, ...libNames, ...pkgChoices],
291
- });
313
+ const [kind, placeholder] =
314
+ sysType === "exec" ? ["app, lib or pkg", "<app-lib-or-pkg>"] : ["app or lib", "<app-or-lib>"];
315
+ const choices = [...appNames, ...libNames, ...pkgChoices];
316
+ const only = choices.length === 1 ? execOf(choices[0] ?? "") : null;
317
+ //? Not for Lib, Pkg or Module, which name what a command acts on: `akan remove-library` would delete the only one.
318
+ if (!value && only && !process.stdin.isTTY) {
319
+ const how = rerun ? `: ${rerun(only.name)}` : "";
320
+ Logger.rawLog(
321
+ `No ${kind} named, so this runs on '${only.name}', the only one in this workspace${how}`,
322
+ undefined,
323
+ "error",
324
+ );
325
+ return only;
326
+ }
327
+ assertPromptable(problemOf(kind), { choices, ...rerunRemedyOf(rerun, placeholder) });
292
328
  const name = await select<string>({
293
329
  message: sysType === "exec" ? `Select the App or Lib or Pkg name` : `Select the App or Lib name`,
294
- choices: [...appNames, ...libNames, ...pkgChoices],
330
+ choices,
295
331
  });
296
332
  const picked = execOf(name);
297
333
  if (!picked) throw new Error(`Invalid system name: ${name}`);
@@ -299,18 +335,18 @@ export const getInternalArgumentValue = async (
299
335
  } else if (sysType === "app") {
300
336
  if (value && appNames.includes(value)) return AppExecutor.from(workspace, value);
301
337
  if (!value && appNames.length === 1 && appNames[0]) return AppExecutor.from(workspace, appNames[0]);
302
- assertPromptable(problemOf("app"), { choices: appNames });
338
+ assertPromptable(problemOf("app"), { choices: appNames, ...rerunRemedyOf(rerun, "<app>") });
303
339
  const appName = await select<string>({ message: `Select the ${sysType} name`, choices: appNames });
304
340
  return AppExecutor.from(workspace, appName);
305
341
  } else if (sysType === "lib") {
306
342
  if (value && libNames.includes(value)) return LibExecutor.from(workspace, value);
307
- assertPromptable(problemOf("lib"), { choices: libNames });
343
+ assertPromptable(problemOf("lib"), { choices: libNames, ...rerunRemedyOf(rerun, "<lib>") });
308
344
  const libName = await select<string>({ message: `Select the ${sysType} name`, choices: libNames });
309
345
  return LibExecutor.from(workspace, libName);
310
346
  } else if (sysType === "pkg") {
311
347
  const pkgs = await workspace.getPkgs();
312
348
  if (value && pkgs.includes(value)) return PkgExecutor.from(workspace, value);
313
- assertPromptable(problemOf("pkg"), { choices: pkgs });
349
+ assertPromptable(problemOf("pkg"), { choices: pkgs, ...rerunRemedyOf(rerun, "<pkg>") });
314
350
  const pkgName = await select<string>({ message: `Select the ${sysType} name`, choices: pkgs });
315
351
  return PkgExecutor.from(workspace, pkgName);
316
352
  } else if (sysType === "module") {
@@ -328,7 +364,14 @@ export const getInternalArgumentValue = async (
328
364
  if (modules.includes(moduleName)) return ModuleExecutor.from(lib, moduleName);
329
365
  } else throw new Error(`Invalid system name: ${sysName}`);
330
366
  }
331
- assertPromptable(problemOf("module"));
367
+ const systems = [
368
+ ...appNames.map((name) => AppExecutor.from(workspace, name)),
369
+ ...libNames.map((name) => LibExecutor.from(workspace, name)),
370
+ ];
371
+ const choices = process.stdin.isTTY
372
+ ? []
373
+ : (await Promise.all(systems.map(async (sys) => (await sys.getModules()).map((m) => `${sys.name}:${m}`)))).flat();
374
+ assertPromptable(problemOf("module"), { choices, ...rerunRemedyOf(rerun, "<sys:module>") });
332
375
  const { type, name } = await select<{ type: "app" | "lib"; name: string }>({
333
376
  message: `select the App or Lib name`,
334
377
  choices: [
@@ -443,6 +486,7 @@ export const runCommands = async (...commands: CommandCls[]) => {
443
486
  const workspace = WorkspaceExecutor.fromRoot();
444
487
  const commandContext: CommandContext = { values: {} };
445
488
  for (const argMeta of allArgMetas) {
489
+ const rerun = rerunCommandOf(process.argv.slice(2), cmdArgs, argMeta.idx);
446
490
  if (argMeta.type === "Option")
447
491
  commandArgs[argMeta.idx] = await getOptionValue(argMeta, opt, commandContext);
448
492
  else if (argMeta.type === "Argument")
@@ -451,12 +495,14 @@ export const runCommands = async (...commands: CommandCls[]) => {
451
495
  commandArgs[argMeta.idx] = await getAppsArgumentValue(
452
496
  cmdArgs[argMeta.idx] as string | string[] | undefined,
453
497
  workspace,
498
+ rerun,
454
499
  );
455
500
  else
456
501
  commandArgs[argMeta.idx] = await getInternalArgumentValue(
457
502
  argMeta as InternalArgMeta,
458
503
  cmdArgs[argMeta.idx] as string,
459
504
  workspace,
505
+ rerun,
460
506
  );
461
507
  if (commandArgs[argMeta.idx] instanceof AppExecutor)
462
508
  process.env.AKAN_PUBLIC_APP_NAME = (commandArgs[argMeta.idx] as AppExecutor).name;