@akanjs/devkit 2.3.9 → 2.3.10-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.
@@ -281,11 +281,17 @@ describe("AkanAppConfig", () => {
281
281
  targets: {
282
282
  admin: {
283
283
  basePath: "admin",
284
+ indexPath: "/admin/home/",
284
285
  appName: "Portal Admin",
285
286
  appId: "com.portal.admin",
286
287
  buildNum: 8,
287
288
  permissions: ["camera"],
288
- links: { schemes: ["portal-admin"] },
289
+ deepLinks: {
290
+ schemes: ["portal-admin", "portal-admin"],
291
+ domains: ["https://Portal.Admin/"],
292
+ ios: { teamId: " TEAMID " },
293
+ android: { sha256CertFingerprints: ["AA:BB", "AA:BB"] },
294
+ },
289
295
  },
290
296
  },
291
297
  },
@@ -296,12 +302,18 @@ describe("AkanAppConfig", () => {
296
302
  expect(config.mobile.targets.admin).toMatchObject({
297
303
  name: "admin",
298
304
  basePath: "admin",
305
+ indexPath: "/admin/home",
299
306
  appName: "Portal Admin",
300
307
  appId: "com.portal.admin",
301
308
  version: "1.0.0",
302
309
  buildNum: 8,
303
310
  permissions: ["camera"],
304
- links: { schemes: ["portal-admin"] },
311
+ deepLinks: {
312
+ schemes: ["portal-admin"],
313
+ domains: ["portal.admin"],
314
+ ios: { teamId: "TEAMID" },
315
+ android: { sha256CertFingerprints: ["AA:BB"] },
316
+ },
305
317
  });
306
318
 
307
319
  expect(
@@ -76,6 +76,47 @@ const DEFAULT_AKAN_IMAGE_CONFIG: AkanImageConfig = {
76
76
  maxRemoteBytes: 25 * 1024 * 1024,
77
77
  };
78
78
 
79
+ const normalizeIndexPath = (indexPath: string | undefined): string | undefined => {
80
+ const normalized = indexPath?.trim();
81
+ if (!normalized) return undefined;
82
+ const path = `/${normalized.replace(/^\/+|\/+$/g, "")}`;
83
+ return path === "/" ? "/" : path;
84
+ };
85
+
86
+ const normalizeStringList = (values: string[] | undefined) => {
87
+ const normalized = values?.map((value) => value.trim()).filter(Boolean) ?? [];
88
+ return normalized.length > 0 ? [...new Set(normalized)] : undefined;
89
+ };
90
+
91
+ const normalizeDeepLinkDomain = (domain: string) => {
92
+ const normalized = domain.trim();
93
+ if (!normalized) return "";
94
+ try {
95
+ const url = new URL(normalized.includes("://") ? normalized : `https://${normalized}`);
96
+ return url.host.toLowerCase();
97
+ } catch {
98
+ return normalized
99
+ .replace(/^https?:\/\//, "")
100
+ .replace(/\/+$/g, "")
101
+ .toLowerCase();
102
+ }
103
+ };
104
+
105
+ const normalizeDeepLinks = (deepLinks: DeepPartial<AkanMobileTargetConfig["deepLinks"]> | undefined) => {
106
+ if (!deepLinks) return undefined;
107
+ const schemes = normalizeStringList(deepLinks.schemes as string[] | undefined);
108
+ const domains = normalizeStringList((deepLinks.domains as string[] | undefined)?.map(normalizeDeepLinkDomain));
109
+ const teamId = (deepLinks.ios?.teamId as string | undefined)?.trim();
110
+ const sha256CertFingerprints = normalizeStringList(deepLinks.android?.sha256CertFingerprints as string[] | undefined);
111
+ if (!schemes && !domains && !teamId && !sha256CertFingerprints) return undefined;
112
+ return {
113
+ ...(schemes ? { schemes } : {}),
114
+ ...(domains ? { domains } : {}),
115
+ ...(teamId ? { ios: { teamId } } : {}),
116
+ ...(sha256CertFingerprints ? { android: { sha256CertFingerprints } } : {}),
117
+ } satisfies AkanMobileTargetConfig["deepLinks"];
118
+ };
119
+
79
120
  export class AkanAppConfig implements AppConfigResult {
80
121
  app: App;
81
122
  rootPackageJson: PackageJson;
@@ -88,6 +129,7 @@ export class AkanAppConfig implements AppConfigResult {
88
129
  i18n: AkanI18nConfig;
89
130
  publicEnv: string[];
90
131
  mobile: AkanMobileConfig;
132
+ secrets: string[];
91
133
  baseDevEnv: BaseDevEnv;
92
134
  libs: string[];
93
135
  domains = new Set<string>();
@@ -120,11 +162,18 @@ export class AkanAppConfig implements AppConfigResult {
120
162
  process.env.AKAN_PUBLIC_DEFAULT_LOCALE = this.i18n.defaultLocale;
121
163
  process.env.AKAN_PUBLIC_LOCALES = this.i18n.locales.join(",");
122
164
  this.publicEnv = (config?.publicEnv as string[] | undefined) ?? ([] as string[]);
165
+ this.secrets = (config?.secrets as string[] | undefined) ?? ([] as string[]);
123
166
  this.mobile = this.#resolveMobileConfig(config.mobile);
124
167
  this.docker = this.#makeDockerContent(config?.docker ?? {});
125
168
  }
126
169
  #resolveMobileConfig(mobile: DeepPartial<AkanMobileConfig> | undefined): AkanMobileConfig {
127
- const { targets: rawTargets, ...rawMobile } = mobile ?? {};
170
+ const {
171
+ targets: rawTargets,
172
+ indexPath: _indexPath,
173
+ ...rawMobile
174
+ } = (mobile ?? {}) as DeepPartial<AkanMobileConfig> & {
175
+ indexPath?: unknown;
176
+ };
128
177
  const appName = rawMobile.appName ?? this.app.name;
129
178
  const appId = rawMobile.appId ?? `com.${this.app.name}.app`;
130
179
  const version = rawMobile.version ?? "0.0.1";
@@ -140,6 +189,8 @@ export class AkanAppConfig implements AppConfigResult {
140
189
  const target = rawTarget as DeepPartial<AkanMobileTargetConfig>;
141
190
  const fallbackBasePath = !rawTargets && this.basePaths.has(name) ? name : undefined;
142
191
  const basePath = (target.basePath ?? fallbackBasePath)?.replace(/^\/+|\/+$/g, "") || undefined;
192
+ const indexPath = normalizeIndexPath(target.indexPath as string | undefined);
193
+ const deepLinks = normalizeDeepLinks(target.deepLinks);
143
194
  if (basePath && !this.basePaths.has(basePath)) {
144
195
  throw new Error(
145
196
  `Mobile target '${name}' uses unknown basePath '${basePath}' in apps/${this.app.name}/akan.config.ts`,
@@ -150,6 +201,8 @@ export class AkanAppConfig implements AppConfigResult {
150
201
  ...target,
151
202
  name,
152
203
  basePath,
204
+ indexPath,
205
+ deepLinks,
153
206
  appName: target.appName ?? appName,
154
207
  appId: target.appId ?? appId,
155
208
  version: target.version ?? version,
@@ -34,9 +34,10 @@ const baseTarget: AkanMobileTargetConfig = {
34
34
  version: "1.2.3",
35
35
  buildNum: 7,
36
36
  basePath: "admin",
37
+ indexPath: "/dashboard",
37
38
  assets: { icon: "mobile/icon.png" },
38
39
  permissions: ["camera"],
39
- links: { schemes: ["minimal"] },
40
+ deepLinks: { schemes: ["minimal"], domains: ["minimal.app"] },
40
41
  files: { android: { "app/google-services.json": "private/google-services.json" } },
41
42
  };
42
43
 
@@ -74,11 +75,12 @@ describe("materializeCapacitorConfig", () => {
74
75
  });
75
76
  expect(config).not.toHaveProperty("name");
76
77
  expect(config).not.toHaveProperty("basePath");
78
+ expect(config).not.toHaveProperty("indexPath");
77
79
  expect(config).not.toHaveProperty("version");
78
80
  expect(config).not.toHaveProperty("buildNum");
79
81
  expect(config).not.toHaveProperty("assets");
80
82
  expect(config).not.toHaveProperty("permissions");
81
- expect(config).not.toHaveProperty("links");
83
+ expect(config).not.toHaveProperty("deepLinks");
82
84
  expect(config).not.toHaveProperty("files");
83
85
  expect(config).not.toHaveProperty("server");
84
86
  });
package/capacitorApp.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { cp, mkdir, rm } from "node:fs/promises";
1
+ import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import type { CapacitorConfig } from "@capacitor/cli";
@@ -463,11 +463,12 @@ export function materializeCapacitorConfig(
463
463
  const {
464
464
  name,
465
465
  basePath: _basePath,
466
+ indexPath: _indexPath,
466
467
  version: _version,
467
468
  buildNum: _buildNum,
468
469
  assets: _assets,
469
470
  permissions: _permissions,
470
- links: _links,
471
+ deepLinks: _deepLinks,
471
472
  files: _files,
472
473
  appId,
473
474
  appName,
@@ -587,7 +588,7 @@ export class CapacitorApp {
587
588
  await this.#prepareExternalFiles("ios");
588
589
  await this.#applyIosMetadata();
589
590
  await this.#applyPermissions();
590
- await this.#applyLinks();
591
+ await this.#applyDeepLinks("ios", { operation, env });
591
592
  await this.project.commit();
592
593
  await this.#generateAssets({ operation, env });
593
594
  this.app.verbose(`syncing iOS`);
@@ -749,12 +750,13 @@ export class CapacitorApp {
749
750
  await this.#prepareExternalFiles("android");
750
751
  await this.#applyAndroidMetadata();
751
752
  await this.#applyPermissions();
752
- await this.#applyLinks();
753
+ await this.#applyDeepLinks("android", { operation, env });
753
754
  await this.project.commit();
754
755
  await this.#generateAssets({ operation, env });
755
756
  await this.#ensureAndroidAssetsDir();
756
757
  await this.#ensureAndroidDebugKeystore();
757
758
  await this.#spawnMobile("npx", ["cap", "sync", "android"], { operation, env });
759
+ await this.#setDeepLinksInAndroid(this.target.deepLinks?.schemes ?? [], this.target.deepLinks?.domains ?? []);
758
760
  }
759
761
 
760
762
  async #updateAndroidBuildTypes() {
@@ -898,7 +900,11 @@ export class CapacitorApp {
898
900
  }
899
901
  #injectMobileTargetMeta(html: string) {
900
902
  const basePath = this.target.basePath?.replace(/^\/+|\/+$/g, "") ?? "";
901
- const script = `<script>window.__AKAN_MOBILE_TARGET__=${JSON.stringify({ name: this.target.name, basePath })};</script>`;
903
+ const script = `<script>window.__AKAN_MOBILE_TARGET__=${JSON.stringify({
904
+ name: this.target.name,
905
+ basePath,
906
+ indexPath: this.target.indexPath,
907
+ })};</script>`;
902
908
  if (html.includes("window.__AKAN_MOBILE_TARGET__")) return html;
903
909
  return html.replace(/<\/head\s*>/i, `${script}\n</head>`);
904
910
  }
@@ -977,14 +983,23 @@ export class CapacitorApp {
977
983
  else if (permission === "push") await this.addPush();
978
984
  }
979
985
  }
980
- async #applyLinks() {
981
- const links = this.target.links;
982
- if (!links) return;
983
- const schemes = links.schemes ?? [];
984
- if (schemes.length > 0) {
985
- await this.#setPermissionInIos({
986
- appTransportSecurity: "",
987
- });
986
+ async #applyDeepLinks(platform: "ios" | "android", { operation, env }: Pick<RunConfig, "operation" | "env">) {
987
+ const deepLinks = this.target.deepLinks;
988
+ if (!deepLinks) return;
989
+ const schemes = deepLinks.schemes ?? [];
990
+ const domains = deepLinks.domains ?? [];
991
+ if (domains.length > 0) this.#assertDeepLinkVerificationConfig(platform, { operation, env });
992
+ if (platform === "ios") {
993
+ if (schemes.length > 0) {
994
+ await this.#setPermissionInIos({
995
+ appTransportSecurity: "",
996
+ });
997
+ await this.#setUrlSchemesInIos(schemes);
998
+ }
999
+ if (domains.length > 0) await this.#setAssociatedDomainsInIos(domains);
1000
+ return;
1001
+ }
1002
+ if (platform === "android") {
988
1003
  for (const scheme of schemes) {
989
1004
  this.project.android
990
1005
  .getAndroidManifest()
@@ -993,18 +1008,30 @@ export class CapacitorApp {
993
1008
  `<intent-filter><action android:name="android.intent.action.VIEW" /><category android:name="android.intent.category.DEFAULT" /><category android:name="android.intent.category.BROWSABLE" /><data android:scheme="${scheme}" /></intent-filter>`,
994
1009
  );
995
1010
  }
1011
+ for (const domain of domains) {
1012
+ const pathPrefix = resolveMobilePath(this.target, "/");
1013
+ this.project.android
1014
+ .getAndroidManifest()
1015
+ .injectFragment(
1016
+ "activity",
1017
+ `<intent-filter android:autoVerify="true"><action android:name="android.intent.action.VIEW" /><category android:name="android.intent.category.DEFAULT" /><category android:name="android.intent.category.BROWSABLE" /><data android:scheme="https" android:host="${domain}" android:pathPrefix="${pathPrefix}" /></intent-filter>`,
1018
+ );
1019
+ }
1020
+ await this.#setDeepLinksInAndroid(schemes, domains);
996
1021
  }
997
- for (const domain of links.associatedDomains ?? []) {
998
- this.app.logger.info(`Configure iOS associated domain manually if needed: ${domain}`);
1022
+ }
1023
+ #assertDeepLinkVerificationConfig(platform: "ios" | "android", { operation, env }: Pick<RunConfig, "operation" | "env">) {
1024
+ const deepLinks = this.target.deepLinks;
1025
+ if (!deepLinks?.domains?.length) return;
1026
+ if (platform === "ios" && !deepLinks.ios?.teamId) {
1027
+ throw new Error(
1028
+ `Mobile target '${this.target.name}' uses deepLinks.domains but is missing deepLinks.ios.teamId in apps/${this.app.name}/akan.config.ts`,
1029
+ );
999
1030
  }
1000
- for (const host of links.androidHosts ?? []) {
1001
- const pathPrefix = resolveMobilePath(this.target, "/");
1002
- this.project.android
1003
- .getAndroidManifest()
1004
- .injectFragment(
1005
- "activity",
1006
- `<intent-filter android:autoVerify="true"><action android:name="android.intent.action.VIEW" /><category android:name="android.intent.category.DEFAULT" /><category android:name="android.intent.category.BROWSABLE" /><data android:scheme="https" android:host="${host}" android:pathPrefix="${pathPrefix}" /></intent-filter>`,
1007
- );
1031
+ if (platform === "android" && !deepLinks.android?.sha256CertFingerprints?.length) {
1032
+ const message = `Mobile target '${this.target.name}' uses deepLinks.domains but is missing deepLinks.android.sha256CertFingerprints in apps/${this.app.name}/akan.config.ts`;
1033
+ if (operation === "release" || env === "main") throw new Error(message);
1034
+ this.app.logger.warn(message);
1008
1035
  }
1009
1036
  }
1010
1037
  async #commandEnv(operation: "local" | "release", env: "local" | "debug" | "develop" | "main") {
@@ -1024,6 +1051,7 @@ export class CapacitorApp {
1024
1051
  const port = commandEnv.AKAN_PUBLIC_CLIENT_PORT ?? commandEnv.PORT ?? "8282";
1025
1052
  const params = new URLSearchParams({ csr: "true", akanMobileTarget: this.target.name });
1026
1053
  if (basePath) params.set("akanMobileBasePath", basePath);
1054
+ if (this.target.indexPath) params.set("akanMobileIndexPath", this.target.indexPath);
1027
1055
  return `http://${ip}:${port}/${pathname}?${params}`;
1028
1056
  }
1029
1057
  async #clearRootCapacitorConfigs() {
@@ -1090,6 +1118,96 @@ export class CapacitorApp {
1090
1118
  this.project.ios.updateInfoPlist(this.iosTargetName, "Release", updateNs),
1091
1119
  ]);
1092
1120
  }
1121
+ async #setUrlSchemesInIos(schemes: string[]) {
1122
+ const urlTypes = schemes.map((scheme) => ({
1123
+ CFBundleURLName: this.target.appId,
1124
+ CFBundleURLSchemes: [scheme],
1125
+ }));
1126
+ await Promise.all([
1127
+ this.project.ios.updateInfoPlist(this.iosTargetName, "Debug", { CFBundleURLTypes: urlTypes }),
1128
+ this.project.ios.updateInfoPlist(this.iosTargetName, "Release", { CFBundleURLTypes: urlTypes }),
1129
+ ]);
1130
+ }
1131
+ async #setAssociatedDomainsInIos(domains: string[]) {
1132
+ const entitlementsRelPath = "App/App.entitlements";
1133
+ const entitlementsPath = path.join(this.app.cwdPath, this.iosProjectPath, entitlementsRelPath);
1134
+ const values = domains.map((domain) => `applinks:${domain}`);
1135
+ const body = [
1136
+ '<?xml version="1.0" encoding="UTF-8"?>',
1137
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
1138
+ '<plist version="1.0">',
1139
+ "<dict>",
1140
+ " <key>com.apple.developer.associated-domains</key>",
1141
+ " <array>",
1142
+ ...values.map((value) => ` <string>${value}</string>`),
1143
+ " </array>",
1144
+ "</dict>",
1145
+ "</plist>",
1146
+ "",
1147
+ ].join("\n");
1148
+ await writeFile(entitlementsPath, body);
1149
+ await this.#setCodeSignEntitlementsInIos(entitlementsRelPath);
1150
+ }
1151
+ async #setCodeSignEntitlementsInIos(entitlementsRelPath: string) {
1152
+ const pbxprojPath = path.join(this.app.cwdPath, this.iosProjectPath, "App.xcodeproj/project.pbxproj");
1153
+ const lines = (await readFile(pbxprojPath, "utf8")).split("\n");
1154
+ let changed = false;
1155
+ for (let index = 0; index < lines.length; index++) {
1156
+ const line = lines[index] ?? "";
1157
+ if (!line.includes(`PRODUCT_BUNDLE_IDENTIFIER = ${this.target.appId};`)) continue;
1158
+ let start = index;
1159
+ while (start >= 0 && !lines[start]?.includes("buildSettings = {")) start--;
1160
+ let end = index;
1161
+ while (end < lines.length && !/^\s*\};\s*$/.test(lines[end] ?? "")) end++;
1162
+ const settings = lines.slice(start, end + 1);
1163
+ if (settings.some((setting) => setting.includes("CODE_SIGN_ENTITLEMENTS"))) continue;
1164
+ const indent = line.match(/^\s*/)?.[0] ?? "";
1165
+ lines.splice(index, 0, `${indent}CODE_SIGN_ENTITLEMENTS = ${entitlementsRelPath};`);
1166
+ index++;
1167
+ changed = true;
1168
+ }
1169
+ if (changed) await writeFile(pbxprojPath, lines.join("\n"));
1170
+ }
1171
+ async #setUrlSchemesInAndroid(schemes: string[]) {
1172
+ const manifestPath = path.join(this.app.cwdPath, this.androidRootPath, "app/src/main/AndroidManifest.xml");
1173
+ let manifest = await readFile(manifestPath, "utf8");
1174
+ let changed = false;
1175
+ for (const scheme of schemes) {
1176
+ if (manifest.includes(`android:scheme="${scheme}"`)) continue;
1177
+ const filter = [
1178
+ " <intent-filter>",
1179
+ ' <action android:name="android.intent.action.VIEW" />',
1180
+ ' <category android:name="android.intent.category.DEFAULT" />',
1181
+ ' <category android:name="android.intent.category.BROWSABLE" />',
1182
+ ` <data android:scheme="${scheme}" />`,
1183
+ " </intent-filter>",
1184
+ ].join("\n");
1185
+ manifest = manifest.replace(/(\s*<\/activity>)/, `\n${filter}$1`);
1186
+ changed = true;
1187
+ }
1188
+ if (changed) await writeFile(manifestPath, manifest);
1189
+ }
1190
+ async #setDeepLinksInAndroid(schemes: string[], domains: string[]) {
1191
+ await this.#setUrlSchemesInAndroid(schemes);
1192
+ const manifestPath = path.join(this.app.cwdPath, this.androidRootPath, "app/src/main/AndroidManifest.xml");
1193
+ let manifest = await readFile(manifestPath, "utf8");
1194
+ let changed = false;
1195
+ const pathPrefix = resolveMobilePath(this.target, "/");
1196
+ for (const domain of domains) {
1197
+ if (manifest.includes(`android:host="${domain}"`) && manifest.includes('android:scheme="https"')) continue;
1198
+ const filter = [
1199
+ ' <intent-filter android:autoVerify="true">',
1200
+ ' <action android:name="android.intent.action.VIEW" />',
1201
+ ' <category android:name="android.intent.category.DEFAULT" />',
1202
+ ' <category android:name="android.intent.category.BROWSABLE" />',
1203
+ ` <data android:scheme="https" android:host="${domain}" android:pathPrefix="${pathPrefix}" />`,
1204
+ " </intent-filter>",
1205
+ ].join("\n");
1206
+ manifest = manifest.replace(/(\s*<\/activity>)/, `\n${filter}$1`);
1207
+ changed = true;
1208
+ }
1209
+ if (changed) await writeFile(manifestPath, manifest);
1210
+ }
1093
1211
  #setFeaturesInAndroid(features: string[]) {
1094
1212
  for (const feature of features) {
1095
1213
  if (this.#hasFeatureInAndroid(feature)) {
@@ -77,6 +77,15 @@ export class SsrBaseArtifactBuilder {
77
77
  branches: [...akanConfig.branches],
78
78
  i18n: akanConfig.i18n,
79
79
  imageConfig: akanConfig.images,
80
+ deepLinkAssociations: Object.values(akanConfig.mobile.targets)
81
+ .filter((target) => (target.deepLinks?.domains?.length ?? 0) > 0)
82
+ .map((target) => ({
83
+ targetName: target.name,
84
+ appId: target.appId,
85
+ domains: target.deepLinks?.domains ?? [],
86
+ iosTeamId: target.deepLinks?.ios?.teamId,
87
+ androidSha256CertFingerprints: target.deepLinks?.android?.sha256CertFingerprints,
88
+ })),
80
89
  };
81
90
  await Bun.write(path.join(this.#absArtifactDir, "base-artifact.json"), `${JSON.stringify(artifact, null, 2)}\n`);
82
91
  this.#app.verbose(`[base-artifact] complete in ${Date.now() - this.#started}ms`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.3.9",
3
+ "version": "2.3.10-rc.0",
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.9",
35
+ "akanjs": "2.3.10-rc.0",
36
36
  "chalk": "^5.6.2",
37
37
  "commander": "^14.0.3",
38
38
  "daisyui": "5.5.23",