@akanjs/devkit 2.3.11-rc.1 → 2.3.11-rc.10
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/akanApp/akanApp.host.ts +38 -0
- package/akanConfig/akanConfig.ts +1 -3
- package/akanContext.ts +67 -2
- package/applicationTestPreload.ts +7 -2
- package/artifact/implicitRootLayout.ts +51 -6
- package/artifact/routeSeedIndex.ts +3 -1
- package/capacitorApp.ts +89 -14
- package/executors.test.ts +1 -1
- package/executors.ts +72 -1
- package/firebaseMessagingSw.test.ts +66 -0
- package/firebaseMessagingSw.ts +74 -0
- package/index.ts +1 -0
- package/lint/no-deep-internal-import.grit +10 -0
- package/lint/no-redeclare-predefined-endpoint.grit +28 -0
- package/lint/no-throw-raw-error.grit +40 -0
- package/package.json +2 -2
- package/qualityScanner.ts +245 -9
- package/scanInfo.ts +8 -0
- package/workflow/artifacts.ts +0 -1
- package/workflow/executor.ts +20 -0
- package/workflow/source.test.ts +114 -1
- package/workflow/source.ts +131 -0
- package/workflow/types.ts +10 -0
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -11,6 +11,7 @@ const BACKEND_RESTART_DEBOUNCE_MS = 120;
|
|
|
11
11
|
const BACKEND_GRACEFUL_TIMEOUT_MS = 3000;
|
|
12
12
|
const BACKEND_RECOVERY_BASE_DELAY_MS = 1_000;
|
|
13
13
|
const BACKEND_RECOVERY_MAX_DELAY_MS = 30_000;
|
|
14
|
+
const BACKEND_STDERR_TAIL_LIMIT = 40;
|
|
14
15
|
const BUILDER_READY_TIMEOUT_MS = 150000;
|
|
15
16
|
const BUILDER_START_MAX_ATTEMPTS = 3;
|
|
16
17
|
const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
@@ -270,6 +271,7 @@ export class AkanAppHost {
|
|
|
270
271
|
#pendingRestartReason: BackendRestartReason | null = null;
|
|
271
272
|
#backendStartStatus: { generation?: number; files: string[] } | null = null;
|
|
272
273
|
#backendBuildStatusGeneration = 0;
|
|
274
|
+
#backendStderrTail: string[] = [];
|
|
273
275
|
#lastGoodFrontend: LastGoodFrontendState = {};
|
|
274
276
|
#buildStatusByPhase = new Map<BuildPhase, DevBuildStatus>();
|
|
275
277
|
#pendingBuildStatusReplay: DevBuildStatus[] = [];
|
|
@@ -321,6 +323,7 @@ export class AkanAppHost {
|
|
|
321
323
|
this.#backendStartStatus = startStatus;
|
|
322
324
|
this.#setBackendLifecycleState("starting");
|
|
323
325
|
this.#backendReady = false;
|
|
326
|
+
this.#backendStderrTail = [];
|
|
324
327
|
const backend = Bun.spawn(["bun", `apps/${this.app.name}/main.ts`], {
|
|
325
328
|
cwd: this.app.workspace.workspaceRoot,
|
|
326
329
|
stdio: this.withInk ? ["ignore", "pipe", "pipe"] : ["inherit", "inherit", "inherit"],
|
|
@@ -351,6 +354,38 @@ export class AkanAppHost {
|
|
|
351
354
|
});
|
|
352
355
|
this.#backend = backend;
|
|
353
356
|
this.logger.verbose(`backend spawned pid=${backend.pid}`);
|
|
357
|
+
if (this.withInk) {
|
|
358
|
+
// Ink mode pipes backend stdio to keep the TUI clean; drain the pipes and surface
|
|
359
|
+
// them through the logger so runtime errors are not silently swallowed.
|
|
360
|
+
void this.#forwardBackendStream(backend.stderr as unknown as ReadableStream<Uint8Array> | undefined, "stderr");
|
|
361
|
+
void this.#forwardBackendStream(backend.stdout as unknown as ReadableStream<Uint8Array> | undefined, "stdout");
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
#recordBackendStderr(chunk: string) {
|
|
365
|
+
const lines = chunk.split(/\r?\n/).filter((line) => line.length > 0);
|
|
366
|
+
if (lines.length === 0) return;
|
|
367
|
+
this.#backendStderrTail.push(...lines);
|
|
368
|
+
if (this.#backendStderrTail.length > BACKEND_STDERR_TAIL_LIMIT) {
|
|
369
|
+
this.#backendStderrTail.splice(0, this.#backendStderrTail.length - BACKEND_STDERR_TAIL_LIMIT);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
async #forwardBackendStream(stream: ReadableStream<Uint8Array> | undefined | null, kind: "stdout" | "stderr") {
|
|
373
|
+
if (!stream) return;
|
|
374
|
+
const decoder = new TextDecoder();
|
|
375
|
+
try {
|
|
376
|
+
for await (const chunk of stream) {
|
|
377
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
378
|
+
if (!text.trim()) continue;
|
|
379
|
+
if (kind === "stderr") {
|
|
380
|
+
this.#recordBackendStderr(text);
|
|
381
|
+
this.logger.warn(`[backend] ${text.trimEnd()}`);
|
|
382
|
+
} else {
|
|
383
|
+
this.logger.verbose(`[backend] ${text.trimEnd()}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
} catch {
|
|
387
|
+
// The stream closes when the backend exits; nothing further to surface here.
|
|
388
|
+
}
|
|
354
389
|
}
|
|
355
390
|
#nextBackendBuildStatusGeneration(generation?: number): number {
|
|
356
391
|
if (typeof generation === "number") {
|
|
@@ -482,6 +517,9 @@ export class AkanAppHost {
|
|
|
482
517
|
this.logger.warn(
|
|
483
518
|
`[backend-recovery] backend exited unexpectedly (${reason}); restarting in ${delay}ms (attempt ${this.#backendRecoveryAttempts})`,
|
|
484
519
|
);
|
|
520
|
+
if (this.#backendStderrTail.length > 0) {
|
|
521
|
+
this.logger.warn(`[backend-recovery] recent backend stderr:\n${this.#backendStderrTail.join("\n")}`);
|
|
522
|
+
}
|
|
485
523
|
this.#backendRecoveryTimer = setTimeout(() => {
|
|
486
524
|
this.#backendRecoveryTimer = null;
|
|
487
525
|
if (this.#backend) return;
|
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -171,9 +171,7 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
171
171
|
targets: rawTargets,
|
|
172
172
|
indexPath: _indexPath,
|
|
173
173
|
...rawMobile
|
|
174
|
-
} = (mobile ?? {}) as DeepPartial<AkanMobileConfig> & {
|
|
175
|
-
indexPath?: unknown;
|
|
176
|
-
};
|
|
174
|
+
} = (mobile ?? {}) as DeepPartial<AkanMobileConfig> & { indexPath?: unknown };
|
|
177
175
|
const appName = rawMobile.appName ?? this.app.name;
|
|
178
176
|
const appId = rawMobile.appId ?? `com.${this.app.name}.app`;
|
|
179
177
|
const version = rawMobile.version ?? "0.0.1";
|
package/akanContext.ts
CHANGED
|
@@ -119,6 +119,10 @@ export type JsonRpcRequest = {
|
|
|
119
119
|
export type McpFraming = "content-length" | "newline";
|
|
120
120
|
export type AkanMcpMode = "readonly" | "plan" | "apply";
|
|
121
121
|
|
|
122
|
+
// Coding-agent tools that can host the Akan MCP server. Cursor and Claude Code both read a JSON
|
|
123
|
+
// `mcpServers` map; Codex reads a TOML `[mcp_servers.<name>]` table.
|
|
124
|
+
export type AkanMcpInstallTarget = "cursor" | "claude" | "codex";
|
|
125
|
+
|
|
122
126
|
export type CursorMcpConfig = {
|
|
123
127
|
mcpServers?: Record<string, unknown>;
|
|
124
128
|
};
|
|
@@ -133,17 +137,79 @@ export const resourceList = [
|
|
|
133
137
|
];
|
|
134
138
|
|
|
135
139
|
export const cursorMcpConfigPath = ".cursor/mcp.json";
|
|
140
|
+
// Claude Code reads project-scoped MCP servers from `.mcp.json` at the workspace root.
|
|
141
|
+
export const claudeMcpConfigPath = ".mcp.json";
|
|
142
|
+
// Codex reads project-scoped config (trusted projects) from `.codex/config.toml`.
|
|
143
|
+
export const codexMcpConfigPath = ".codex/config.toml";
|
|
144
|
+
|
|
145
|
+
export const akanMcpInstallTargets: AkanMcpInstallTarget[] = ["cursor", "claude", "codex"];
|
|
146
|
+
|
|
147
|
+
export const akanMcpInstallConfigPaths: Record<AkanMcpInstallTarget, string> = {
|
|
148
|
+
cursor: cursorMcpConfigPath,
|
|
149
|
+
claude: claudeMcpConfigPath,
|
|
150
|
+
codex: codexMcpConfigPath,
|
|
151
|
+
};
|
|
136
152
|
|
|
153
|
+
// `akan mcp` resolves the workspace from process.cwd(), so every launcher must run it from the
|
|
154
|
+
// workspace root. Cursor expands its own ${workspaceFolder} variable. Claude Code does not guarantee
|
|
155
|
+
// the server's cwd but sets CLAUDE_PROJECT_DIR in its environment, so we cd into that at runtime.
|
|
156
|
+
// Codex inherits its own launch cwd (it also discovers .codex/config.toml from cwd), so it runs the
|
|
157
|
+
// command directly and must be started from the workspace root.
|
|
137
158
|
const cursorWorkspaceFolder = "$" + "{workspaceFolder}";
|
|
159
|
+
const claudeProjectDir = "$CLAUDE_PROJECT_DIR";
|
|
160
|
+
|
|
161
|
+
const akanMcpCommand = (mode: AkanMcpMode, { cd }: { cd?: string } = {}) =>
|
|
162
|
+
cd ? `cd "${cd}" && akan mcp --mode ${mode}` : `akan mcp --mode ${mode}`;
|
|
138
163
|
|
|
139
164
|
export const createAkanCursorMcpServer = (mode: AkanMcpMode = "readonly") => ({
|
|
140
165
|
type: "stdio",
|
|
141
166
|
command: "bash",
|
|
142
|
-
args: ["-lc",
|
|
167
|
+
args: ["-lc", akanMcpCommand(mode, { cd: cursorWorkspaceFolder })],
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
export const createAkanClaudeMcpServer = (mode: AkanMcpMode = "readonly") => ({
|
|
171
|
+
type: "stdio",
|
|
172
|
+
command: "bash",
|
|
173
|
+
args: ["-lc", akanMcpCommand(mode, { cd: claudeProjectDir })],
|
|
143
174
|
});
|
|
144
175
|
|
|
176
|
+
// JSON-config targets (Cursor, Claude Code) share the same `mcpServers` entry shape.
|
|
177
|
+
export const createAkanMcpServer = (target: "cursor" | "claude", mode: AkanMcpMode = "readonly") =>
|
|
178
|
+
target === "cursor" ? createAkanCursorMcpServer(mode) : createAkanClaudeMcpServer(mode);
|
|
179
|
+
|
|
145
180
|
export const akanCursorMcpServer = createAkanCursorMcpServer();
|
|
146
181
|
|
|
182
|
+
// Codex config is TOML and we have no TOML serializer, so we build the `[mcp_servers.akan]` table as text.
|
|
183
|
+
export const codexMcpServerTableHeader = "[mcp_servers.akan]";
|
|
184
|
+
export const createAkanCodexMcpServerBlock = (mode: AkanMcpMode = "readonly") =>
|
|
185
|
+
`${codexMcpServerTableHeader}\ncommand = "bash"\nargs = ["-lc", "${akanMcpCommand(mode)}"]\n`;
|
|
186
|
+
|
|
187
|
+
// A TOML table runs from its header until the next top-level `[header]` or EOF. We upsert only the
|
|
188
|
+
// akan table and preserve everything else in the file, mirroring the JSON merge behavior.
|
|
189
|
+
const codexAkanTablePattern = /^\[mcp_servers\.akan\][^\n]*\n(?:(?!\[)[^\n]*(?:\n|$))*/m;
|
|
190
|
+
|
|
191
|
+
export const upsertCodexMcpServerBlock = (
|
|
192
|
+
existing: string,
|
|
193
|
+
block: string,
|
|
194
|
+
{ force = false }: { force?: boolean } = {},
|
|
195
|
+
) => {
|
|
196
|
+
const nextBlock = block.endsWith("\n") ? block : `${block}\n`;
|
|
197
|
+
const match = existing.match(codexAkanTablePattern);
|
|
198
|
+
if (!match) {
|
|
199
|
+
if (!existing.trim()) return nextBlock;
|
|
200
|
+
return `${existing.replace(/\s*$/, "")}\n\n${nextBlock}`;
|
|
201
|
+
}
|
|
202
|
+
if (match[0].trim() === nextBlock.trim()) return existing;
|
|
203
|
+
if (!force)
|
|
204
|
+
throw new Error(`${codexMcpConfigPath} already has an "akan" MCP server. Re-run with --force to overwrite it.`);
|
|
205
|
+
const start = match.index ?? 0;
|
|
206
|
+
const before = existing.slice(0, start);
|
|
207
|
+
const after = existing.slice(start + match[0].length);
|
|
208
|
+
// The matched table absorbed its trailing blank line, so re-insert one before any following table.
|
|
209
|
+
const separator = after && !after.startsWith("\n") ? "\n" : "";
|
|
210
|
+
return `${before}${nextBlock}${separator}${after}`;
|
|
211
|
+
};
|
|
212
|
+
|
|
147
213
|
export const renderDoctorText = (result: AkanDoctorResult) => {
|
|
148
214
|
const lines = [`Akan doctor status: ${result.status}`];
|
|
149
215
|
if (result.diagnostics.length === 0) {
|
|
@@ -189,7 +255,6 @@ const generatedFiles = [
|
|
|
189
255
|
"*/lib/cnst.ts",
|
|
190
256
|
"*/lib/db.ts",
|
|
191
257
|
"*/lib/dict.ts",
|
|
192
|
-
"*/lib/option.ts",
|
|
193
258
|
"*/lib/sig.ts",
|
|
194
259
|
"*/lib/srv.ts",
|
|
195
260
|
"*/lib/st.ts",
|
|
@@ -32,9 +32,14 @@ export async function resolveSignalTestPreloadPath(target: SignalTestPreloadTarg
|
|
|
32
32
|
path.resolve(import.meta.dir, "../../akanjs", SIGNAL_TEST_PRELOAD_PATH),
|
|
33
33
|
);
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
const uniqueCandidates = [...new Set(candidates)];
|
|
36
|
+
for (const candidate of uniqueCandidates) {
|
|
36
37
|
if (await Bun.file(candidate).exists()) return candidate;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
throw new Error(
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Failed to locate ${SIGNAL_TEST_PRELOAD_PATH} from ${target.cwdPath}.\nProbed paths:\n${uniqueCandidates
|
|
42
|
+
.map((candidate) => ` - ${candidate}`)
|
|
43
|
+
.join("\n")}`,
|
|
44
|
+
);
|
|
40
45
|
}
|
|
@@ -16,6 +16,38 @@ async function appHasStModule(appCwdPath: string): Promise<boolean> {
|
|
|
16
16
|
|
|
17
17
|
const IMPLICIT_LAYOUT_DIR = path.join(".akan", "generated", "root-layouts");
|
|
18
18
|
const IMPLICIT_DICT_DIR = path.join(".akan", "generated", "dict");
|
|
19
|
+
const IMPLICIT_OVERRIDES_DIR = path.join(".akan", "generated", "overrides");
|
|
20
|
+
const OVERRIDES_KEY_RE = /^\.\/(.+\/)?_overrides\.(tsx|ts|jsx|js)$/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A `_overrides.tsx` manifest is a plain, server-safe module (`export default override({ Modal: BrandModal })`)
|
|
24
|
+
* with no `"use client"` directive. The `UiOverrideProvider` that consumes it is a client component, so the
|
|
25
|
+
* build emits a `"use client"` wrapper layout that reads the manifest's default map and mounts the provider
|
|
26
|
+
* around the subtree. As a normal `"use client"` module the wrapper participates in client-entry discovery and
|
|
27
|
+
* the RSC client manifest, so the server (as a client reference) and the client (as the real component) both
|
|
28
|
+
* resolve it — and the author never writes `"use client"`.
|
|
29
|
+
*/
|
|
30
|
+
async function writeGeneratedOverridesLayoutFile(opts: {
|
|
31
|
+
appCwdPath: string;
|
|
32
|
+
key: string;
|
|
33
|
+
userAbsPath: string;
|
|
34
|
+
}): Promise<string> {
|
|
35
|
+
const filename = `${opts.key.replace(/^\.\//, "").replace(/[^a-zA-Z0-9]+/g, "_")}.tsx`;
|
|
36
|
+
const absPath = path.join(path.resolve(opts.appCwdPath), IMPLICIT_OVERRIDES_DIR, filename);
|
|
37
|
+
const userRel = path.relative(path.dirname(absPath), opts.userAbsPath).split(path.sep).join("/");
|
|
38
|
+
const userSpecifier = userRel.startsWith(".") ? userRel : `./${userRel}`;
|
|
39
|
+
const source = `"use client";
|
|
40
|
+
import { UiOverrideProvider } from "akanjs/ui";
|
|
41
|
+
import { createElement, type ReactNode } from "react";
|
|
42
|
+
import value from ${JSON.stringify(userSpecifier)};
|
|
43
|
+
|
|
44
|
+
export default function AkanUiOverridesLayout({ children }: { children?: ReactNode }) {
|
|
45
|
+
return createElement(UiOverrideProvider, { value }, children);
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
await Bun.write(absPath, source);
|
|
49
|
+
return absPath;
|
|
50
|
+
}
|
|
19
51
|
|
|
20
52
|
interface RootBoundary {
|
|
21
53
|
sourceKey: string | null;
|
|
@@ -261,12 +293,25 @@ export async function resolveSsrPageEntries(opts: {
|
|
|
261
293
|
return segments !== null && isRootBoundarySegments(segments, basePaths);
|
|
262
294
|
}),
|
|
263
295
|
);
|
|
264
|
-
const base =
|
|
265
|
-
.
|
|
266
|
-
|
|
267
|
-
key
|
|
268
|
-
|
|
269
|
-
|
|
296
|
+
const base = await Promise.all(
|
|
297
|
+
opts.pageKeys
|
|
298
|
+
.filter((key) => !rootLayoutKeys.has(key))
|
|
299
|
+
.map(async (key) => {
|
|
300
|
+
const userAbsPath = path.resolve(absPageDir, key);
|
|
301
|
+
// `_overrides.tsx` is served through a generated `"use client"` wrapper layout that mounts the provider,
|
|
302
|
+
// so the author writes no directive; the raw manifest becomes a build seed so its slot components (and
|
|
303
|
+
// the manifest itself) enter the client graph / RSC client manifest.
|
|
304
|
+
if (OVERRIDES_KEY_RE.test(key)) {
|
|
305
|
+
const moduleAbsPath = await writeGeneratedOverridesLayoutFile({
|
|
306
|
+
appCwdPath: opts.appCwdPath,
|
|
307
|
+
key,
|
|
308
|
+
userAbsPath,
|
|
309
|
+
});
|
|
310
|
+
return { key, moduleAbsPath, seedAbsPaths: [userAbsPath] };
|
|
311
|
+
}
|
|
312
|
+
return { key, moduleAbsPath: userAbsPath };
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
270
315
|
const generated = await Promise.all(
|
|
271
316
|
rootBoundaries.map(async (boundary) => ({
|
|
272
317
|
key: implicitRootLayoutKey(boundary.segments),
|
|
@@ -50,7 +50,9 @@ export function computeRouteSeedIndex(pageEntries: PageEntry[]): RouteSeedIndex
|
|
|
50
50
|
for (const { key, moduleAbsPath, seedAbsPaths } of pageEntries) {
|
|
51
51
|
const parsed = parseRouteModuleKey(key);
|
|
52
52
|
const files = [path.resolve(moduleAbsPath), ...(seedAbsPaths ?? []).map((seed) => path.resolve(seed))];
|
|
53
|
-
if (parsed.kind === "layout") {
|
|
53
|
+
if (parsed.kind === "layout" || parsed.kind === "overrides") {
|
|
54
|
+
// Overrides seed the client graph like layouts: every route under the prefix must pull the generated
|
|
55
|
+
// `"use client"` override wrapper (and its slot components) into the client bundle / RSC client manifest.
|
|
54
56
|
const prefix = parsed.routeSegments.join("/");
|
|
55
57
|
const prev = layoutsByPrefix.get(prefix) ?? [];
|
|
56
58
|
layoutsByPrefix.set(prefix, [...prev, ...files]);
|
package/capacitorApp.ts
CHANGED
|
@@ -26,6 +26,7 @@ interface RunIosConfig extends RunConfig {
|
|
|
26
26
|
interface PrepareConfig extends RunConfig {}
|
|
27
27
|
|
|
28
28
|
type MobileCommandEnv = Record<string, string | undefined>;
|
|
29
|
+
type IosApnsEnvironment = "development" | "production";
|
|
29
30
|
|
|
30
31
|
export type IosRunTargetKind = "device" | "simulator";
|
|
31
32
|
export interface IosRunTarget {
|
|
@@ -130,6 +131,9 @@ const asString = (value: unknown) => (typeof value === "string" ? value : undefi
|
|
|
130
131
|
|
|
131
132
|
const firstString = (...values: unknown[]) => values.find((value): value is string => typeof value === "string");
|
|
132
133
|
|
|
134
|
+
const resolveIosApnsEnvironment = ({ operation }: Pick<RunConfig, "operation" | "env">): IosApnsEnvironment =>
|
|
135
|
+
operation === "release" ? "production" : "development";
|
|
136
|
+
|
|
133
137
|
const scoreIosDeviceTarget = (target: IosRunTarget) => {
|
|
134
138
|
const state = target.state?.toLowerCase() ?? "";
|
|
135
139
|
return (state.includes("available") ? 4 : 0) + (state.includes("wired") ? 2 : 0) + (state.includes("paired") ? 1 : 0);
|
|
@@ -486,6 +490,10 @@ export function materializeCapacitorConfig(
|
|
|
486
490
|
const experimentalConfig = isRecord(experimental) ? experimental : undefined;
|
|
487
491
|
const pluginsConfig = isRecord(plugins) ? plugins : {};
|
|
488
492
|
const keyboardPluginConfig = isRecord(pluginsConfig.Keyboard) ? pluginsConfig.Keyboard : {};
|
|
493
|
+
const pushNotificationsPluginConfig = isRecord(pluginsConfig.PushNotifications)
|
|
494
|
+
? pluginsConfig.PushNotifications
|
|
495
|
+
: {};
|
|
496
|
+
const usesPushNotifications = target.permissions?.includes("push") ?? false;
|
|
489
497
|
const config: CapacitorConfig = {
|
|
490
498
|
...capacitorConfig,
|
|
491
499
|
appId,
|
|
@@ -494,6 +502,14 @@ export function materializeCapacitorConfig(
|
|
|
494
502
|
plugins: {
|
|
495
503
|
CapacitorCookies: { enabled: true },
|
|
496
504
|
...pluginsConfig,
|
|
505
|
+
...(usesPushNotifications || isRecord(pluginsConfig.PushNotifications)
|
|
506
|
+
? {
|
|
507
|
+
PushNotifications: {
|
|
508
|
+
...(usesPushNotifications ? { presentationOptions: ["badge", "sound", "alert"] } : {}),
|
|
509
|
+
...pushNotificationsPluginConfig,
|
|
510
|
+
},
|
|
511
|
+
}
|
|
512
|
+
: {}),
|
|
497
513
|
Keyboard: {
|
|
498
514
|
resize: "none",
|
|
499
515
|
...keyboardPluginConfig,
|
|
@@ -587,7 +603,7 @@ export class CapacitorApp {
|
|
|
587
603
|
await this.#prepareTargetAssets();
|
|
588
604
|
await this.#prepareExternalFiles("ios");
|
|
589
605
|
await this.#applyIosMetadata();
|
|
590
|
-
await this.#applyPermissions();
|
|
606
|
+
await this.#applyPermissions({ operation, env });
|
|
591
607
|
await this.#applyDeepLinks("ios", { operation, env });
|
|
592
608
|
await this.project.commit();
|
|
593
609
|
await this.#generateAssets({ operation, env });
|
|
@@ -749,7 +765,7 @@ export class CapacitorApp {
|
|
|
749
765
|
await this.#prepareTargetAssets();
|
|
750
766
|
await this.#prepareExternalFiles("android");
|
|
751
767
|
await this.#applyAndroidMetadata();
|
|
752
|
-
await this.#applyPermissions();
|
|
768
|
+
await this.#applyPermissions({ operation, env });
|
|
753
769
|
await this.#applyDeepLinks("android", { operation, env });
|
|
754
770
|
await this.project.commit();
|
|
755
771
|
await this.#generateAssets({ operation, env });
|
|
@@ -975,12 +991,12 @@ export class CapacitorApp {
|
|
|
975
991
|
await this.project.android.setVersionCode(this.target.buildNum);
|
|
976
992
|
await this.project.android.setAppName(this.target.appName);
|
|
977
993
|
}
|
|
978
|
-
async #applyPermissions() {
|
|
994
|
+
async #applyPermissions({ operation, env }: Pick<RunConfig, "operation" | "env">) {
|
|
979
995
|
for (const permission of this.target.permissions ?? []) {
|
|
980
996
|
if (permission === "camera") await this.addCamera();
|
|
981
997
|
else if (permission === "contacts") await this.addContact();
|
|
982
998
|
else if (permission === "location") await this.addLocation();
|
|
983
|
-
else if (permission === "push") await this.addPush();
|
|
999
|
+
else if (permission === "push") await this.addPush({ operation, env });
|
|
984
1000
|
}
|
|
985
1001
|
}
|
|
986
1002
|
async #applyDeepLinks(platform: "ios" | "android", { operation, env }: Pick<RunConfig, "operation" | "env">) {
|
|
@@ -996,7 +1012,7 @@ export class CapacitorApp {
|
|
|
996
1012
|
});
|
|
997
1013
|
await this.#setUrlSchemesInIos(schemes);
|
|
998
1014
|
}
|
|
999
|
-
if (domains.length > 0) await this.#setAssociatedDomainsInIos(domains);
|
|
1015
|
+
if (domains.length > 0) await this.#setAssociatedDomainsInIos(domains, { operation, env });
|
|
1000
1016
|
return;
|
|
1001
1017
|
}
|
|
1002
1018
|
if (platform === "android") {
|
|
@@ -1103,12 +1119,58 @@ export class CapacitorApp {
|
|
|
1103
1119
|
this.#setPermissionsInAndroid(["ACCESS_COARSE_LOCATION", "ACCESS_FINE_LOCATION"]);
|
|
1104
1120
|
this.#setFeaturesInAndroid(["android.hardware.location.gps"]);
|
|
1105
1121
|
}
|
|
1106
|
-
async addPush() {
|
|
1122
|
+
async addPush({ operation, env }: Pick<RunConfig, "operation" | "env">) {
|
|
1107
1123
|
await this.#setPermissionInIos({
|
|
1108
1124
|
userNotificationsUsageDescription: "$(PRODUCT_NAME) uses notifications to keep you updated.",
|
|
1109
1125
|
});
|
|
1126
|
+
await Promise.all([
|
|
1127
|
+
this.project.ios.updateInfoPlist(this.iosTargetName, "Debug", { UIBackgroundModes: ["remote-notification"] }),
|
|
1128
|
+
this.project.ios.updateInfoPlist(this.iosTargetName, "Release", { UIBackgroundModes: ["remote-notification"] }),
|
|
1129
|
+
this.#writeEntitlementsInIos(this.target.deepLinks?.domains ?? [], { operation, env }),
|
|
1130
|
+
this.#ensurePushAppDelegateInIos(),
|
|
1131
|
+
]);
|
|
1110
1132
|
this.#setPermissionsInAndroid(["POST_NOTIFICATIONS"]);
|
|
1111
1133
|
}
|
|
1134
|
+
async #ensurePushAppDelegateInIos() {
|
|
1135
|
+
const appDelegatePath = path.join(this.app.cwdPath, this.iosProjectPath, "App/AppDelegate.swift");
|
|
1136
|
+
if (!(await Bun.file(appDelegatePath).exists())) return;
|
|
1137
|
+
|
|
1138
|
+
const editor = await FileEditor.create(appDelegatePath);
|
|
1139
|
+
let content = editor.getContent();
|
|
1140
|
+
|
|
1141
|
+
if (!content.includes("import FirebaseCore")) {
|
|
1142
|
+
content = content.replace("import Capacitor", "import Capacitor\nimport FirebaseCore");
|
|
1143
|
+
}
|
|
1144
|
+
if (!content.includes("import FirebaseMessaging")) {
|
|
1145
|
+
content = content.replace("import FirebaseCore", "import FirebaseCore\nimport FirebaseMessaging");
|
|
1146
|
+
}
|
|
1147
|
+
if (!content.includes("FirebaseApp.configure()")) {
|
|
1148
|
+
content = content.replace(/\n(\s*)return true/, "\n$1FirebaseApp.configure()\n\n$1return true");
|
|
1149
|
+
}
|
|
1150
|
+
if (!content.includes("didRegisterForRemoteNotificationsWithDeviceToken")) {
|
|
1151
|
+
const delegateMethods = `
|
|
1152
|
+
func application(_ application: UIApplication,
|
|
1153
|
+
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
|
1154
|
+
Messaging.messaging().apnsToken = deviceToken
|
|
1155
|
+
NotificationCenter.default.post(
|
|
1156
|
+
name: .capacitorDidRegisterForRemoteNotifications,
|
|
1157
|
+
object: deviceToken
|
|
1158
|
+
)
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
func application(_ application: UIApplication,
|
|
1162
|
+
didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
|
1163
|
+
NotificationCenter.default.post(
|
|
1164
|
+
name: .capacitorDidFailToRegisterForRemoteNotifications,
|
|
1165
|
+
object: error
|
|
1166
|
+
)
|
|
1167
|
+
}
|
|
1168
|
+
`;
|
|
1169
|
+
content = content.replace("\n func applicationWillResignActive", `${delegateMethods}\n func applicationWillResignActive`);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (content !== editor.getContent()) await editor.setContent(content).save();
|
|
1173
|
+
}
|
|
1112
1174
|
async #setPermissionInIos(permissions: { [key: string]: string }) {
|
|
1113
1175
|
const updateNs = Object.fromEntries(
|
|
1114
1176
|
Object.entries(permissions).map(([key, value]) => [`NS${capitalize(key)}`, value]),
|
|
@@ -1128,24 +1190,37 @@ export class CapacitorApp {
|
|
|
1128
1190
|
this.project.ios.updateInfoPlist(this.iosTargetName, "Release", { CFBundleURLTypes: urlTypes }),
|
|
1129
1191
|
]);
|
|
1130
1192
|
}
|
|
1131
|
-
async #setAssociatedDomainsInIos(domains: string[]) {
|
|
1193
|
+
async #setAssociatedDomainsInIos(domains: string[], { operation, env }: Pick<RunConfig, "operation" | "env">) {
|
|
1194
|
+
await this.#writeEntitlementsInIos(domains, { operation, env });
|
|
1195
|
+
}
|
|
1196
|
+
async #writeEntitlementsInIos(domains: string[], runConfig: Pick<RunConfig, "operation" | "env">) {
|
|
1132
1197
|
const entitlementsRelPath = "App/App.entitlements";
|
|
1133
1198
|
const entitlementsPath = path.join(this.app.cwdPath, this.iosProjectPath, entitlementsRelPath);
|
|
1134
1199
|
const values = domains.map((domain) => `applinks:${domain}`);
|
|
1200
|
+
const usesPush = this.target.permissions?.includes("push") ?? false;
|
|
1201
|
+
const apsEnvironment = resolveIosApnsEnvironment(runConfig);
|
|
1135
1202
|
const body = [
|
|
1136
1203
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
1137
1204
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
1138
1205
|
'<plist version="1.0">',
|
|
1139
1206
|
"<dict>",
|
|
1140
|
-
" <key>
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1207
|
+
...(usesPush ? [" <key>aps-environment</key>", ` <string>${apsEnvironment}</string>`] : []),
|
|
1208
|
+
...(values.length > 0
|
|
1209
|
+
? [
|
|
1210
|
+
" <key>com.apple.developer.associated-domains</key>",
|
|
1211
|
+
" <array>",
|
|
1212
|
+
...values.map((value) => ` <string>${value}</string>`),
|
|
1213
|
+
" </array>",
|
|
1214
|
+
]
|
|
1215
|
+
: []),
|
|
1144
1216
|
"</dict>",
|
|
1145
1217
|
"</plist>",
|
|
1146
1218
|
"",
|
|
1147
1219
|
].join("\n");
|
|
1148
|
-
await
|
|
1220
|
+
const currentBody = (await Bun.file(entitlementsPath).exists())
|
|
1221
|
+
? await Bun.file(entitlementsPath).text()
|
|
1222
|
+
: undefined;
|
|
1223
|
+
if (currentBody !== body) await writeFile(entitlementsPath, body);
|
|
1149
1224
|
await this.#setCodeSignEntitlementsInIos(entitlementsRelPath);
|
|
1150
1225
|
}
|
|
1151
1226
|
async #setCodeSignEntitlementsInIos(entitlementsRelPath: string) {
|
|
@@ -1236,7 +1311,7 @@ export class CapacitorApp {
|
|
|
1236
1311
|
for (const permission of permissions) {
|
|
1237
1312
|
if (this.#hasPermissionInAndroid(permission)) {
|
|
1238
1313
|
this.app.logger.info(`${permission} already exists in android`);
|
|
1239
|
-
|
|
1314
|
+
continue;
|
|
1240
1315
|
}
|
|
1241
1316
|
this.app.logger.info(`Adding ${permission} to android`);
|
|
1242
1317
|
this.project.android
|
|
@@ -1253,6 +1328,6 @@ export class CapacitorApp {
|
|
|
1253
1328
|
return Array.from(usesPermission).map((permission) => permission.getAttribute("android:name"));
|
|
1254
1329
|
}
|
|
1255
1330
|
#hasPermissionInAndroid(permission: string) {
|
|
1256
|
-
return this.#getPermissionsInAndroid().includes(permission);
|
|
1331
|
+
return this.#getPermissionsInAndroid().includes(`android.permission.${permission}`);
|
|
1257
1332
|
}
|
|
1258
1333
|
}
|
package/executors.test.ts
CHANGED
|
@@ -140,7 +140,7 @@ describe("Executor filesystem helpers", () => {
|
|
|
140
140
|
expect(await readFile(path.join(root, "workspace/.env"), "utf8")).toContain("AKAN_PUBLIC_REPO_NAME");
|
|
141
141
|
expect(await readFile(path.join(root, "workspace/.vscode/settings.json"), "utf8")).toContain("typescript.tsdk");
|
|
142
142
|
expect(await readFile(path.join(root, "workspace/.cursor/rules/akan.mdc"), "utf8")).toContain(
|
|
143
|
-
"Akan
|
|
143
|
+
"Akan workspace agent guide",
|
|
144
144
|
);
|
|
145
145
|
expect(await readFile(path.join(root, "workspace/AGENTS.md"), "utf8")).toContain("sample Agent Guide");
|
|
146
146
|
expect(await readFile(path.join(root, "workspace/docs/AI-DEVELOPMENT.md"), "utf8")).toContain(
|
package/executors.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
11
|
import { copyFile, mkdir, readdir as readDirEntries, stat } from "node:fs/promises";
|
|
12
12
|
import path from "node:path";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
13
14
|
import {
|
|
14
15
|
capitalize,
|
|
15
16
|
isRouteSourceFile,
|
|
@@ -23,6 +24,11 @@ import chalk from "chalk";
|
|
|
23
24
|
import ts from "typescript";
|
|
24
25
|
import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
|
|
25
26
|
import { FileSys } from "./fileSys";
|
|
27
|
+
import {
|
|
28
|
+
createFirebaseMessagingServiceWorker,
|
|
29
|
+
type FirebaseClientEnvConfig,
|
|
30
|
+
normalizeFirebaseClientConfig,
|
|
31
|
+
} from "./firebaseMessagingSw";
|
|
26
32
|
import { getDirname } from "./getDirname";
|
|
27
33
|
import { Linter } from "./linter";
|
|
28
34
|
import { AppInfo, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
|
|
@@ -256,6 +262,43 @@ function validateRouteSourceExports(
|
|
|
256
262
|
}
|
|
257
263
|
}
|
|
258
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Statically enforces that a `_overrides.tsx` route file is a logic-free activation manifest: a plain module
|
|
267
|
+
* (no `"use client"` — the framework generates the client wrapper) that only imports components and binds them
|
|
268
|
+
* to slots through a single `export default override({ Modal: BrandModal })`. It must not declare components
|
|
269
|
+
* inline or run logic — that keeps the override contract a thin binding layer rather than a second place to
|
|
270
|
+
* author UI. Slot names and value types are validated at compile time by `override`.
|
|
271
|
+
*/
|
|
272
|
+
function validateOverridesSourceExports(source: string, filePath: string) {
|
|
273
|
+
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
274
|
+
const fail = (message: string): never => {
|
|
275
|
+
throw new Error(`[route-convention] ${message}: ${filePath}`);
|
|
276
|
+
};
|
|
277
|
+
let defaultOverride: ts.ExportAssignment | null = null;
|
|
278
|
+
for (const statement of sourceFile.statements) {
|
|
279
|
+
// A "use client" directive is unnecessary (the framework wraps the manifest) but harmless if present.
|
|
280
|
+
if (ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression)) continue;
|
|
281
|
+
// The manifest imports the app components it binds; imports and type-only decls carry no runtime logic.
|
|
282
|
+
if (ts.isImportDeclaration(statement)) continue;
|
|
283
|
+
if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) continue;
|
|
284
|
+
if (ts.isExportAssignment(statement) && !statement.isExportEquals) {
|
|
285
|
+
defaultOverride = statement;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
fail(`_overrides.tsx may only contain imports and a single "export default override({ ... })"`);
|
|
289
|
+
}
|
|
290
|
+
if (!defaultOverride) fail(`_overrides.tsx must "export default override({ ... })"`);
|
|
291
|
+
const expression = defaultOverride.expression;
|
|
292
|
+
if (
|
|
293
|
+
!ts.isCallExpression(expression) ||
|
|
294
|
+
!ts.isIdentifier(expression.expression) ||
|
|
295
|
+
expression.expression.text !== "override"
|
|
296
|
+
)
|
|
297
|
+
fail(
|
|
298
|
+
`_overrides.tsx default export must be a call to "override", e.g. "export default override({ Modal: BrandModal })"`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
259
302
|
export class Executor {
|
|
260
303
|
static verbose = false;
|
|
261
304
|
static setVerbose(verbose: boolean) {
|
|
@@ -1392,6 +1435,12 @@ export class AppExecutor extends SysExecutor {
|
|
|
1392
1435
|
...routeEnv,
|
|
1393
1436
|
...(devPort ? { PORT: devPort, AKAN_PUBLIC_CLIENT_PORT: devPort, AKAN_PUBLIC_SERVER_PORT: devPort } : {}),
|
|
1394
1437
|
});
|
|
1438
|
+
// `start` spawns subprocesses that carry `env`, but `build` runs its phases in this same process and
|
|
1439
|
+
// reads `process.env` directly. Publish the resolved env here so SSR/CSR bundling (fed by getPublicEnv,
|
|
1440
|
+
// which filters to AKAN_PUBLIC_*) sees AKAN_PUBLIC_APP_NAME — otherwise SSR throws
|
|
1441
|
+
// "environment variable AKAN_PUBLIC_APP_NAME is required". Only AKAN_PUBLIC_* is baked into bundles, so
|
|
1442
|
+
// this does not leak non-public env.
|
|
1443
|
+
if (type === "build") Object.assign(process.env, env);
|
|
1395
1444
|
return { env };
|
|
1396
1445
|
}
|
|
1397
1446
|
#publicEnv: Record<string, string> | null = null;
|
|
@@ -1454,7 +1503,9 @@ export class AppExecutor extends SysExecutor {
|
|
|
1454
1503
|
throw new Error(`[route-convention] __root_layout is reserved for Akan.js generated root layout: ${absPath}`);
|
|
1455
1504
|
}
|
|
1456
1505
|
const isRootLayout = parsed.kind === "layout" && parsed.moduleSegments.at(-1) === "_layout";
|
|
1457
|
-
|
|
1506
|
+
const routeSource = await Bun.file(absPath).text();
|
|
1507
|
+
if (parsed.kind === "overrides") validateOverridesSourceExports(routeSource, absPath);
|
|
1508
|
+
else validateRouteSourceExports(routeSource, absPath, parsed.kind, { rootLayout: isRootLayout });
|
|
1458
1509
|
pageKeys.push(key);
|
|
1459
1510
|
}
|
|
1460
1511
|
pageKeys.sort();
|
|
@@ -1497,8 +1548,28 @@ export class AppExecutor extends SysExecutor {
|
|
|
1497
1548
|
writeLib: write,
|
|
1498
1549
|
})) as AppInfo;
|
|
1499
1550
|
if (write) await this.syncAssets(scanInfo.getScanResult().libDeps);
|
|
1551
|
+
if (write) await this.#syncFirebaseMessagingSw();
|
|
1500
1552
|
return scanInfo;
|
|
1501
1553
|
}
|
|
1554
|
+
//* firebase config 가 있으면 public/firebase-messaging-sw.js 를 1회 생성한다(있으면 스킵).
|
|
1555
|
+
//* 서버 동적 라우트를 대체하는 정적 파일. env.client 파생이라 gitignore 되고 env 별로 재생성된다.
|
|
1556
|
+
async #syncFirebaseMessagingSw() {
|
|
1557
|
+
const swRelPath = "public/firebase-messaging-sw.js";
|
|
1558
|
+
if (await FileSys.fileExists(this.getPath(swRelPath))) return;
|
|
1559
|
+
const envClientPath = path.join(this.cwdPath, "env", "env.client.ts");
|
|
1560
|
+
if (!(await FileSys.fileExists(envClientPath))) return;
|
|
1561
|
+
let firebaseConfig: FirebaseClientEnvConfig | null = null;
|
|
1562
|
+
try {
|
|
1563
|
+
const envUrl = pathToFileURL(envClientPath);
|
|
1564
|
+
envUrl.searchParams.set("t", String(Date.now()));
|
|
1565
|
+
const envModule = (await import(envUrl.href)) as { env?: { firebase?: unknown } };
|
|
1566
|
+
firebaseConfig = normalizeFirebaseClientConfig(envModule.env?.firebase);
|
|
1567
|
+
} catch {
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
if (!firebaseConfig) return;
|
|
1571
|
+
await this.writeFile(swRelPath, createFirebaseMessagingServiceWorker(firebaseConfig), { overwrite: false });
|
|
1572
|
+
}
|
|
1502
1573
|
async increaseBuildNum() {
|
|
1503
1574
|
await increaseBuildNum(this);
|
|
1504
1575
|
}
|