@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.
- package/CHANGELOG.md +348 -0
- package/abstractDoc.ts +1 -1
- package/agentAssets.ts +2 -2
- package/akanConfig/akanConfig.ts +2 -2
- package/akanMcpContract.ts +2 -2
- package/apiReference.test.ts +37 -0
- package/apiReference.ts +240 -0
- package/applicationBuildReporter.test.ts +54 -0
- package/applicationBuildReporter.ts +12 -0
- package/applicationBuildRunner.test.ts +34 -14
- package/applicationBuildRunner.ts +77 -14
- package/artifact/implicitRootLayout.test.ts +42 -0
- package/artifact/implicitRootLayout.ts +5 -6
- package/artifact/storeBootstrap.ts +24 -0
- package/cloud/cloudApi.test.ts +70 -7
- package/cloud/cloudApi.ts +10 -4
- package/cloud/constants.ts +3 -0
- package/cloud/globalConfig.test.ts +36 -0
- package/cloud/globalConfig.ts +10 -1
- package/codeAgent/skills/akan-debug/SKILL.md +2 -1
- package/codeAgent/skills/akan-page/SKILL.md +30 -2
- package/codeAgent/skills/akan-service/SKILL.md +77 -1
- package/commandDecorators/command.ts +55 -9
- package/commandDecorators/commandDecorators.test.ts +131 -2
- package/declarationReader.ts +211 -0
- package/executors.test.ts +65 -8
- package/executors.ts +5 -3
- package/frameworkGraph.test.ts +121 -0
- package/frameworkGraph.ts +165 -0
- package/frontendBuild/allRoutesBuilder.test.ts +84 -0
- package/frontendBuild/allRoutesBuilder.ts +32 -12
- package/frontendBuild/buildRouteClient.test.ts +192 -2
- package/frontendBuild/bundleDefine.ts +4 -0
- package/frontendBuild/clientBuildTypes.ts +7 -1
- package/frontendBuild/clientEntriesBundler.test.ts +82 -0
- package/frontendBuild/clientEntriesBundler.ts +55 -4
- package/frontendBuild/csrArtifactBuilder.test.ts +23 -0
- package/frontendBuild/csrArtifactBuilder.ts +2 -1
- package/frontendBuild/fontOptimizer.test.ts +54 -0
- package/frontendBuild/fontOptimizer.ts +5 -4
- package/frontendBuild/index.ts +3 -0
- package/frontendBuild/routeClientBuilder.ts +6 -2
- package/frontendBuild/routeModulePreloads.test.ts +59 -0
- package/frontendBuild/routeModulePreloads.ts +52 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +35 -10
- package/frontendBuild/ssrDevBundler.test.ts +26 -0
- package/frontendBuild/ssrDevBundler.ts +2 -1
- package/frontendBuild/vendorImportScanner.test.ts +71 -0
- package/frontendBuild/vendorImportScanner.ts +80 -0
- package/frontendBuild/vendorNarrowing.test.ts +103 -0
- package/frontendBuild/vendorNarrowing.ts +64 -0
- package/frontendBuild/vendorSpecifiers.ts +13 -0
- package/gitRevision.test.ts +82 -0
- package/gitRevision.ts +128 -0
- package/incrementalBuilder/builderGraphPayloads.ts +60 -0
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +105 -6
- package/incrementalBuilder/incrementalBuilder.proc.ts +6 -10
- package/integration/devStability.integration.test.ts +1 -1
- package/integration/devStabilityHarness.test.ts +1 -1
- package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +4 -2
- package/lint/__fixtures__/no-js-private-class-method/bad.tsx +2 -0
- package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -2
- package/lint/no-daisyui-legacy-class.grit +9 -7
- package/lint/no-deep-internal-import.grit +1 -1
- package/lint/no-js-private-class-method.grit +9 -28
- package/maintainabilityBaseline.ts +7 -2
- package/maintainabilityContract.test.ts +183 -0
- package/maintainabilityContract.ts +145 -30
- package/maintainabilityExceptionScanner.test.ts +13 -2
- package/maintainabilityExceptionScanner.ts +16 -6
- package/maintainabilityLimits.ts +12 -0
- package/maintainabilityReport.test.ts +35 -0
- package/maintainabilityReport.ts +29 -8
- package/mobile/nativePublicEnv.test.ts +5 -1
- package/package.json +3 -3
- package/routeJsBudget.test.ts +177 -0
- package/routeJsBudget.ts +222 -0
- package/structureScanner.test.ts +167 -0
- package/structureScanner.ts +164 -0
- package/transforms/barrelAnalyzer.ts +10 -3
- package/transforms/barrelImportsPlugin.ts +13 -7
- package/transforms/serverEnvPlugin.test.ts +1 -1
- package/transforms/serverEnvPlugin.ts +1 -1
- package/transforms/transforms.test.ts +64 -12
- package/transforms/tsconfigPackageResolver.test.ts +12 -0
- package/typeChecker.test.ts +100 -0
- package/typeChecker.ts +14 -2
- package/workflow/plan.ts +79 -17
- package/workflow/types.ts +1 -0
- package/workspaceGraph.test.ts +121 -0
- package/workspaceGraph.ts +149 -16
- package/workspaceLayout.ts +1 -0
- /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
|
-
|
|
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
|
|
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
|
-
${
|
|
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
|
-
|
|
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
|
+
}
|
package/cloud/cloudApi.test.ts
CHANGED
|
@@ -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() +
|
|
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 (
|
|
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: {
|
|
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 {
|
|
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([
|
|
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([
|
|
181
|
-
expect(await output(await cliRun(home))).toEqual([
|
|
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
|
|
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.
|
|
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
|
}
|
package/cloud/constants.ts
CHANGED
|
@@ -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");
|
package/cloud/globalConfig.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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
|
|
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
|
-
|
|
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;
|