@akanjs/devkit 2.3.11-rc.6 → 2.3.11-rc.8

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.
@@ -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", `cd "${cursorWorkspaceFolder}" && akan mcp --mode ${mode}`],
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",
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>com.apple.developer.associated-domains</key>",
1141
- " <array>",
1142
- ...values.map((value) => ` <string>${value}</string>`),
1143
- " </array>",
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 writeFile(entitlementsPath, body);
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
- return this;
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.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";
@@ -1392,6 +1398,12 @@ export class AppExecutor extends SysExecutor {
1392
1398
  ...routeEnv,
1393
1399
  ...(devPort ? { PORT: devPort, AKAN_PUBLIC_CLIENT_PORT: devPort, AKAN_PUBLIC_SERVER_PORT: devPort } : {}),
1394
1400
  });
1401
+ // `start` spawns subprocesses that carry `env`, but `build` runs its phases in this same process and
1402
+ // reads `process.env` directly. Publish the resolved env here so SSR/CSR bundling (fed by getPublicEnv,
1403
+ // which filters to AKAN_PUBLIC_*) sees AKAN_PUBLIC_APP_NAME — otherwise SSR throws
1404
+ // "environment variable AKAN_PUBLIC_APP_NAME is required". Only AKAN_PUBLIC_* is baked into bundles, so
1405
+ // this does not leak non-public env.
1406
+ if (type === "build") Object.assign(process.env, env);
1395
1407
  return { env };
1396
1408
  }
1397
1409
  #publicEnv: Record<string, string> | null = null;
@@ -1497,8 +1509,28 @@ export class AppExecutor extends SysExecutor {
1497
1509
  writeLib: write,
1498
1510
  })) as AppInfo;
1499
1511
  if (write) await this.syncAssets(scanInfo.getScanResult().libDeps);
1512
+ if (write) await this.#syncFirebaseMessagingSw();
1500
1513
  return scanInfo;
1501
1514
  }
1515
+ //* firebase config 가 있으면 public/firebase-messaging-sw.js 를 1회 생성한다(있으면 스킵).
1516
+ //* 서버 동적 라우트를 대체하는 정적 파일. env.client 파생이라 gitignore 되고 env 별로 재생성된다.
1517
+ async #syncFirebaseMessagingSw() {
1518
+ const swRelPath = "public/firebase-messaging-sw.js";
1519
+ if (await FileSys.fileExists(this.getPath(swRelPath))) return;
1520
+ const envClientPath = path.join(this.cwdPath, "env", "env.client.ts");
1521
+ if (!(await FileSys.fileExists(envClientPath))) return;
1522
+ let firebaseConfig: FirebaseClientEnvConfig | null = null;
1523
+ try {
1524
+ const envUrl = pathToFileURL(envClientPath);
1525
+ envUrl.searchParams.set("t", String(Date.now()));
1526
+ const envModule = (await import(envUrl.href)) as { env?: { firebase?: unknown } };
1527
+ firebaseConfig = normalizeFirebaseClientConfig(envModule.env?.firebase);
1528
+ } catch {
1529
+ return;
1530
+ }
1531
+ if (!firebaseConfig) return;
1532
+ await this.writeFile(swRelPath, createFirebaseMessagingServiceWorker(firebaseConfig), { overwrite: false });
1533
+ }
1502
1534
  async increaseBuildNum() {
1503
1535
  await increaseBuildNum(this);
1504
1536
  }
@@ -0,0 +1,66 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ createFirebaseMessagingServiceWorker,
4
+ normalizeFirebaseClientConfig,
5
+ } from "./firebaseMessagingSw";
6
+
7
+ describe("normalizeFirebaseClientConfig", () => {
8
+ test("whitelists client fields and drops secrets / vapidKey", () => {
9
+ const normalized = normalizeFirebaseClientConfig({
10
+ apiKey: "public-api-key",
11
+ authDomain: "example.firebaseapp.com",
12
+ projectId: "public-project",
13
+ storageBucket: "public-project.appspot.com",
14
+ messagingSenderId: "1234567890",
15
+ appId: "public-app-id",
16
+ vapidKey: "public-vapid-key",
17
+ private_key: "SERVER_PRIVATE_KEY_MUST_NOT_LEAK",
18
+ });
19
+ expect(normalized).toEqual({
20
+ apiKey: "public-api-key",
21
+ authDomain: "example.firebaseapp.com",
22
+ projectId: "public-project",
23
+ storageBucket: "public-project.appspot.com",
24
+ messagingSenderId: "1234567890",
25
+ appId: "public-app-id",
26
+ });
27
+ expect(normalized).not.toHaveProperty("vapidKey");
28
+ expect(normalized).not.toHaveProperty("private_key");
29
+ });
30
+
31
+ test("returns null when required fields are missing or input is not an object", () => {
32
+ expect(normalizeFirebaseClientConfig(undefined)).toBe(null);
33
+ expect(normalizeFirebaseClientConfig(null)).toBe(null);
34
+ expect(normalizeFirebaseClientConfig("nope")).toBe(null);
35
+ expect(normalizeFirebaseClientConfig({ apiKey: "only-key" })).toBe(null);
36
+ });
37
+ });
38
+
39
+ describe("createFirebaseMessagingServiceWorker", () => {
40
+ test("inlines client config and imports firebase compat SDK, without leaking secrets", () => {
41
+ const config = normalizeFirebaseClientConfig({
42
+ apiKey: "public-api-key",
43
+ projectId: "public-project",
44
+ messagingSenderId: "1234567890",
45
+ appId: "public-app-id",
46
+ private_key: "SERVER_PRIVATE_KEY_MUST_NOT_LEAK",
47
+ });
48
+ const body = createFirebaseMessagingServiceWorker(config);
49
+ expect(body).toContain("public-api-key");
50
+ expect(body).toContain("public-project");
51
+ expect(body).toContain("firebase-messaging-compat.js");
52
+ expect(body).toContain("onBackgroundMessage");
53
+ expect(body).toContain("notificationclick");
54
+ expect(body).not.toContain("SERVER_PRIVATE_KEY_MUST_NOT_LEAK");
55
+ expect(body).not.toContain("private_key");
56
+ });
57
+
58
+ test("produces a no-op worker (config null) that still registers notificationclick", () => {
59
+ const body = createFirebaseMessagingServiceWorker(null);
60
+ // config is null → the importScripts/onBackgroundMessage block is gated by `if (firebaseConfig)` at runtime,
61
+ // but notificationclick is always registered.
62
+ expect(body).toContain("const firebaseConfig = null");
63
+ expect(body).toContain("notificationclick");
64
+ expect(body).toContain("if (firebaseConfig)");
65
+ });
66
+ });
@@ -0,0 +1,74 @@
1
+ const FIREBASE_WEB_SDK_VERSION = "12.13.0";
2
+
3
+ export interface FirebaseClientEnvConfig {
4
+ apiKey: string;
5
+ authDomain?: string;
6
+ projectId: string;
7
+ storageBucket?: string;
8
+ messagingSenderId: string;
9
+ appId: string;
10
+ vapidKey?: string;
11
+ }
12
+
13
+ //* env.client 의 firebase 설정을 검증하고, 서비스워커에 필요한 필드만 화이트리스트한다.
14
+ //* vapidKey/서버 시크릿 등 나머지 필드는 의도적으로 제외한다(SW 본문에 유출 방지).
15
+ export function normalizeFirebaseClientConfig(config: unknown): FirebaseClientEnvConfig | null {
16
+ if (!config || typeof config !== "object") return null;
17
+ const value = config as Partial<Record<keyof FirebaseClientEnvConfig, unknown>>;
18
+ if (
19
+ typeof value.apiKey !== "string" ||
20
+ typeof value.projectId !== "string" ||
21
+ typeof value.messagingSenderId !== "string" ||
22
+ typeof value.appId !== "string"
23
+ ) {
24
+ return null;
25
+ }
26
+ return {
27
+ apiKey: value.apiKey,
28
+ ...(typeof value.authDomain === "string" ? { authDomain: value.authDomain } : {}),
29
+ projectId: value.projectId,
30
+ ...(typeof value.storageBucket === "string" ? { storageBucket: value.storageBucket } : {}),
31
+ messagingSenderId: value.messagingSenderId,
32
+ appId: value.appId,
33
+ };
34
+ }
35
+
36
+ //* firebase-messaging-sw.js 생성 코드
37
+ export function createFirebaseMessagingServiceWorker(config: FirebaseClientEnvConfig | null): string {
38
+ const configJson = JSON.stringify(config);
39
+ return `/* Generated by Akan.js. Do not edit. */
40
+ const firebaseConfig = ${configJson};
41
+
42
+ if (firebaseConfig) {
43
+ importScripts("https://www.gstatic.com/firebasejs/${FIREBASE_WEB_SDK_VERSION}/firebase-app-compat.js");
44
+ importScripts("https://www.gstatic.com/firebasejs/${FIREBASE_WEB_SDK_VERSION}/firebase-messaging-compat.js");
45
+
46
+ firebase.initializeApp(firebaseConfig);
47
+ const messaging = firebase.messaging();
48
+
49
+ const notificationUrl = (payload) =>
50
+ payload?.data?.url || payload?.fcmOptions?.link || payload?.notification?.click_action;
51
+
52
+ messaging.onBackgroundMessage((payload) => {
53
+ const title = payload?.notification?.title || "";
54
+ const options = {
55
+ body: payload?.notification?.body,
56
+ icon: payload?.notification?.icon,
57
+ image: payload?.notification?.image,
58
+ data: {
59
+ url: notificationUrl(payload),
60
+ FCM_MSG: payload,
61
+ },
62
+ };
63
+ self.registration.showNotification(title, options);
64
+ });
65
+ }
66
+
67
+ self.addEventListener("notificationclick", (event) => {
68
+ const url = event.notification?.data?.url || event.notification?.data?.FCM_MSG?.data?.url;
69
+ event.notification?.close();
70
+ if (!url) return;
71
+ event.waitUntil(clients.openWindow(url));
72
+ });
73
+ `;
74
+ }
package/index.ts CHANGED
@@ -18,6 +18,7 @@ export * from "./dependencyScanner";
18
18
  export * from "./executors";
19
19
  export * from "./extractDeps";
20
20
  export * from "./fileSys";
21
+ export * from "./firebaseMessagingSw";
21
22
  export * from "./frontendBuild";
22
23
  export * from "./getCredentials";
23
24
  export * from "./getDirname";
@@ -2,8 +2,10 @@ engine biome(1.0)
2
2
  language js(typescript, jsx)
3
3
 
4
4
  // Every model already gets auto-generated CRUD endpoints in its slice fetch
5
- // contract (see pkgs/akanjs/fetch/fetchType/sliceFetch.type.ts):
6
- // <refName>, light<Model>, create<Model>, update<Model>, remove<Model>.
5
+ // contract (see pkgs/akanjs/fetch/fetchType/sliceFetch.type.ts and
6
+ // pkgs/akanjs/fetch/client/fetchClient.ts):
7
+ // <refName>, light<Model>, create<Model>, update<Model>, remove<Model>,
8
+ // view<Model>, edit<Model>, merge<Model>.
7
9
  // Re-declaring one of them inside the `endpoint(...)` block of a
8
10
  // `<model>.signal.ts` file shadows the framework contract and can pass
9
11
  // sync/typecheck/build while failing at runtime, so flag it here.
@@ -15,12 +17,12 @@ language js(typescript, jsx)
15
17
  $filename <: r".*/([a-z][a-zA-Z0-9]*)\.signal\.ts"($model),
16
18
  or {
17
19
  $key <: $model,
18
- $key <: r"(?:light|create|update|remove)([A-Z][a-zA-Z0-9]*)"($keyCap) where {
20
+ $key <: r"(?:light|create|update|remove|view|edit|merge)([A-Z][a-zA-Z0-9]*)"($keyCap) where {
19
21
  $keyCap <: $Cap
20
22
  }
21
23
  },
22
24
  register_diagnostic(
23
25
  span = $key,
24
- message = "This endpoint name collides with an auto-generated CRUD endpoint (<model>, light/create/update/remove<Model>). Rename it or move the logic into the service; do not re-declare predefined endpoints."
26
+ message = "This endpoint name collides with an auto-generated CRUD endpoint (<model>, light/create/update/remove/view/edit/merge<Model>). Rename it or move the logic into the service; do not re-declare predefined endpoints."
25
27
  )
26
28
  }
@@ -0,0 +1,40 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // Domain code in apps/ and libs/ must not throw a raw `Error`. Throw the typed
5
+ // `Err` instead, which is generated per app/lib by `makeTrans`
6
+ // (pkgs/akanjs/dictionary/trans.ts) and imported via `import { Err } from "../dict"`
7
+ // (or the app/lib client entrypoint). `Err` carries a translatable error key,
8
+ // a status code, and structured payload, so raw `Error` throws lose i18n and
9
+ // HTTP-status handling.
10
+ //
11
+ // The `$msg` metavariable in the argument position leniently matches any arity,
12
+ // so `throw new Error()`, `throw new Error("x")`, and
13
+ // `throw new Error("x", { cause })` are all caught, as is the `new`-less
14
+ // `throw Error(...)` call form. Only real throw statements match — a
15
+ // `new Error(...)` inside a string literal or one that is not thrown is ignored.
16
+ //
17
+ // No autofix: `Err`'s first argument must be a typed dictionary error key
18
+ // (e.g. throw new Err('model.errorKey')), so a mechanical rewrite of an
19
+ // arbitrary message string would not typecheck. Each site must be migrated to a
20
+ // real error key by hand.
21
+ //
22
+ // NOTE: the diagnostic messages below use single quotes in the example on
23
+ // purpose. Escaped double quotes (\") inside a GritQL string trigger a
24
+ // double-unescape bug in Biome 2.4.4 that corrupts the rendered message.
25
+ or {
26
+ `throw new Error($msg)` as $throw where {
27
+ register_diagnostic(
28
+ span = $throw,
29
+ message = "Do not throw a raw `Error`. Throw the typed `Err` instead (import { Err } from the app/lib dict), e.g. throw new Err('model.errorKey').",
30
+ severity = "error"
31
+ )
32
+ },
33
+ `throw Error($msg)` as $throw where {
34
+ register_diagnostic(
35
+ span = $throw,
36
+ message = "Do not throw a raw `Error`. Throw the typed `Err` instead (import { Err } from the app/lib dict), e.g. throw new Err('model.errorKey').",
37
+ severity = "error"
38
+ )
39
+ }
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.3.11-rc.6",
3
+ "version": "2.3.11-rc.8",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -32,7 +32,7 @@
32
32
  "@langchain/openai": "^1.4.6",
33
33
  "@tailwindcss/node": "^4.3.0",
34
34
  "@trapezedev/project": "^7.1.4",
35
- "akanjs": "2.3.11-rc.6",
35
+ "akanjs": "2.3.11-rc.8",
36
36
  "chalk": "^5.6.2",
37
37
  "commander": "^14.0.3",
38
38
  "daisyui": "5.5.23",
package/scanInfo.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { rm } from "node:fs/promises";
2
3
  import type {
3
4
  AppConfigResult,
4
5
  AppScanResult,
@@ -54,6 +55,7 @@ const appRootAllowedFiles = new Set([
54
55
  "tsconfig.json",
55
56
  "tsconfig.tsbuildinfo",
56
57
  ]);
58
+ const generatedRootCapacitorConfigFiles = ["capacitor.config.js", "capacitor.config.json"] as const;
57
59
  const appRootAllowedDirs = new Set([
58
60
  ".akan",
59
61
  "android",
@@ -100,12 +102,17 @@ const isAllowedLibRootFile = (filename: string) =>
100
102
  libRootAllowedFiles.has(filename) || rootSignalTestFilePattern.test(filename);
101
103
  const getScanPath = (exec: AppExecutor | LibExecutor, relativePath: string) =>
102
104
  path.posix.join(`${exec.type}s`, exec.name, relativePath.split(path.sep).join("/"));
105
+ async function clearGeneratedRootCapacitorConfigs(exec: AppExecutor | LibExecutor) {
106
+ if (exec.type !== "app") return;
107
+ await Promise.all(generatedRootCapacitorConfigFiles.map((filename) => rm(exec.getPath(filename), { force: true })));
108
+ }
103
109
  const getModuleNameFromPath = (kind: ModuleKind, modulePath: string) => {
104
110
  const dirname = path.basename(modulePath);
105
111
  return kind === "service" ? dirname.replace(/^_+/, "") : dirname;
106
112
  };
107
113
 
108
114
  async function assertScanConvention(exec: AppExecutor | LibExecutor, libRoot: { files: string[]; dirs: string[] }) {
115
+ await clearGeneratedRootCapacitorConfigs(exec);
109
116
  const violations: string[] = [];
110
117
  const addViolation = (relativePath: string, reason: string) => {
111
118
  violations.push(`${getScanPath(exec, relativePath)}: ${reason}`);
@@ -196,7 +196,6 @@ export const generatedFilePathsForTarget = (targetRoot: string, reason = "Genera
196
196
  [
197
197
  { path: `${targetRoot}/lib/cnst.ts`, action: "sync", reason },
198
198
  { path: `${targetRoot}/lib/dict.ts`, action: "sync", reason },
199
- { path: `${targetRoot}/lib/option.ts`, action: "sync", reason },
200
199
  { path: `${targetRoot}/lib/index.ts`, action: "sync", reason },
201
200
  ] satisfies PrimitiveGeneratedFile[];
202
201