@akanjs/devkit 3.1.0-rc.1 → 3.1.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/akanConfig/akanConfig.test.ts +51 -5
  2. package/akanConfig/akanConfig.ts +31 -12
  3. package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
  4. package/akanConfig/akanWorkspaceConfig.ts +1 -1
  5. package/applicationBuildRunner.test.ts +33 -1
  6. package/applicationBuildRunner.ts +4 -5
  7. package/cloud/cloudApi.ts +22 -15
  8. package/cloud/cloudHttpError.test.ts +32 -0
  9. package/cloud/cloudHttpError.ts +54 -0
  10. package/cloud/cloudUpdates.ts +138 -0
  11. package/cloud/globalConfig.test.ts +17 -0
  12. package/cloud/globalConfig.ts +8 -2
  13. package/cloud/index.ts +3 -0
  14. package/cloud/pipelineSecrets.ts +114 -0
  15. package/cloud/updatePackUpload.test.ts +100 -45
  16. package/cloud/updatePackUpload.ts +38 -18
  17. package/commandDecorators/argMeta.ts +11 -2
  18. package/commandDecorators/command.ts +40 -6
  19. package/commandDecorators/commandBuilder.ts +6 -1
  20. package/commandDecorators/commandDecorators.test.ts +205 -4
  21. package/commandDecorators/exitCodeError.ts +15 -0
  22. package/commandDecorators/index.ts +1 -0
  23. package/executors.test.ts +46 -0
  24. package/executors.ts +26 -23
  25. package/frontendBuild/csrArtifactBuilder.ts +18 -1
  26. package/incrementalBuilder/buildBatch.proc.ts +12 -7
  27. package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
  28. package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
  29. package/mobile/desktopServerStage.test.ts +13 -0
  30. package/mobile/desktopServerStage.ts +5 -7
  31. package/mobile/index.ts +2 -0
  32. package/mobile/nativeApi.test.ts +26 -0
  33. package/mobile/nativeApi.ts +5 -2
  34. package/mobile/nativeApp.test.ts +135 -33
  35. package/mobile/nativeApp.ts +109 -73
  36. package/mobile/nativeConfig.ts +2 -1
  37. package/mobile/nativePublicEnv.test.ts +131 -0
  38. package/mobile/nativePublicEnv.ts +116 -0
  39. package/mobile/serverBundleSecrets.test.ts +79 -0
  40. package/mobile/serverBundleSecrets.ts +60 -16
  41. package/mobile/updatePackFolder.test.ts +98 -0
  42. package/mobile/updatePackFolder.ts +131 -0
  43. package/mobile/updateUploader.test.ts +114 -22
  44. package/mobile/updateUploader.ts +75 -8
  45. package/package.json +2 -2
  46. package/pipeline/PipelineCompiler.test.ts +157 -1
  47. package/pipeline/PipelineCompiler.ts +80 -46
  48. package/pipeline/PipelineDefinition.test.ts +9 -0
  49. package/pipeline/PipelineDefinition.ts +4 -3
  50. package/pipeline/PipelineDryRun.test.ts +27 -0
  51. package/pipeline/PipelineDryRun.ts +2 -2
  52. package/pipeline/PipelineInputReader.ts +157 -253
  53. package/pipeline/PipelineRules.ts +92 -0
  54. package/pipeline/index.ts +1 -0
  55. package/qualityScanner.test.ts +5 -3
  56. package/slicePlanner.ts +5 -3
  57. package/subspace.test.ts +90 -19
  58. package/subspace.ts +52 -500
  59. package/subspaceClone.ts +361 -0
  60. package/subspaceConfig.ts +1 -2
  61. package/subspaceReport.ts +162 -0
  62. package/transforms/serverEnvPlugin.test.ts +76 -0
  63. package/transforms/serverEnvPlugin.ts +9 -25
  64. package/transforms/serverEnvProfile.test.ts +189 -0
  65. package/transforms/serverEnvProfile.ts +324 -60
  66. package/typecheck/projectTypecheck.ts +6 -5
@@ -32,28 +32,10 @@ export const createServerEnvPlugin = ({
32
32
  const filter = new RegExp(`${folder}(?:${others.map(escapeRegExp).join("|")})\\.ts$`);
33
33
  const own = new RegExp(`${folder}${escapeRegExp(environment)}\\.ts$`);
34
34
  const libEnvs = new Map<string, Promise<OnLoadResult>>();
35
- //* A lib's `server.ts` hands its `env.server.testing.ts` to the server as defaults, so a profile swaps those as it
36
- //* swaps the app's: for the lib's `env.server.<name>.<profile>.ts`, else for the defaults without a secret in them.
37
35
  const libEnv = async (file: string): Promise<OnLoadResult> => {
38
- const profileFile = ServerEnvProfile.profileFileOf(file, profile ?? "");
39
- const libRoot = path.dirname(path.dirname(file));
40
- if (await Bun.file(profileFile).exists()) {
41
- const secrets = ServerEnvProfile.secretPaths(await ServerEnvProfile.evaluate(profileFile, libRoot));
42
- if (secrets.length)
43
- throw new Error(
44
- `${profileFile} holds secret-named values, which the "${profile}" bundle carries to every computer it runs on: ${secrets.join(", ")}.`,
45
- );
46
- onLibEnv?.(file, { profileFile: true, stripped: [] });
47
- return { loader: "ts", contents: await Bun.file(profileFile).text() };
48
- }
49
- const { exports } = new Bun.Transpiler({ loader: "ts" }).scan(await Bun.file(file).text());
50
- if (exports.some((name) => name !== "env"))
51
- throw new Error(
52
- `${file} exports ${exports.join(", ")}: a "${profile}" build carries only a lib env's \`env\`, so give the lib ${path.basename(profileFile)}.`,
53
- );
54
- const { value, stripped } = ServerEnvProfile.strip(await ServerEnvProfile.evaluate(file, libRoot));
55
- onLibEnv?.(file, { profileFile: false, stripped });
56
- return { loader: "js", contents: `export const env = ${JSON.stringify(value ?? {})};\n` };
36
+ const { loader, contents, profileFile, stripped } = await ServerEnvProfile.libEnvSource(file, profile ?? "");
37
+ onLibEnv?.(file, { profileFile, stripped });
38
+ return { loader, contents };
57
39
  };
58
40
  return {
59
41
  name: "akan-server-env",
@@ -62,9 +44,11 @@ export const createServerEnvPlugin = ({
62
44
  if (profile)
63
45
  build.onLoad({ filter: own }, async (args) => ({
64
46
  loader: "ts",
65
- contents: await Bun.file(
66
- path.join(path.dirname(args.path), `env.server.${environment}.${profile}.ts`),
67
- ).text(),
47
+ contents: await ServerEnvProfile.sourceInPlaceOf(
48
+ path.join(path.dirname(args.path), ServerEnvProfile.fileName(environment, profile)),
49
+ args.path,
50
+ profile,
51
+ ),
68
52
  }));
69
53
  if (others.length)
70
54
  build.onLoad({ filter }, async (args) => {
@@ -82,7 +66,7 @@ export const createServerEnvPlugin = ({
82
66
  });
83
67
  //? Last: the app's own env files above are taken by the handlers before it, which match them first.
84
68
  if (profile)
85
- build.onLoad({ filter: /[\\/]env[\\/]env\.server\.[A-Za-z0-9-]+\.ts$/ }, async (args) => {
69
+ build.onLoad({ filter: ServerEnvProfile.envFilePattern }, async (args) => {
86
70
  const name = path.basename(args.path, ".ts").slice("env.server.".length);
87
71
  if (dirs.has(path.dirname(args.path)) || name === "type" || name === "example")
88
72
  return { loader: "ts", contents: await Bun.file(args.path).text() };
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import path from "node:path";
3
+ import { Logger } from "akanjs/common";
3
4
  import { tempDirs, writeText as write } from "../testHelpers";
4
5
  import { ServerEnvProfile } from "./serverEnvProfile";
5
6
 
@@ -39,6 +40,136 @@ describe("ServerEnvProfile.secretPaths", () => {
39
40
  "iapVerify.google.client_email",
40
41
  ]);
41
42
  });
43
+
44
+ test("names a URL that carries a credential under any key, and the keys a credential goes by elsewhere", () => {
45
+ expect(
46
+ ServerEnvProfile.secretPaths({
47
+ database: {
48
+ postgres: { url: "postgres://app:pg-pass@db/app", insightUrl: "postgres://ro:ro-pass@db/app", host: "db" },
49
+ },
50
+ cache: { url: "redis://cache:6379" },
51
+ search: { endpoint: "https://search.example/v1?api_key=k" },
52
+ cloudOrigin: "https://cloud.example",
53
+ signing: { signingKey: "s", encryptionKey: "e", MASTER_KEY: "m", sessionKey: "k" },
54
+ sentry: { dsn: "d" },
55
+ SENTRY_DSN: "d",
56
+ db: { pwd: "p", connectionString: "c" },
57
+ enrollKey: "fleet",
58
+ publicKey: "pub",
59
+ adsNetwork: "n",
60
+ }),
61
+ ).toEqual([
62
+ "database.postgres.url",
63
+ "database.postgres.insightUrl",
64
+ "search.endpoint",
65
+ "signing.signingKey",
66
+ "signing.encryptionKey",
67
+ "signing.MASTER_KEY",
68
+ "signing.sessionKey",
69
+ "sentry.dsn",
70
+ "SENTRY_DSN",
71
+ "db.pwd",
72
+ "db.connectionString",
73
+ ]);
74
+ });
75
+ });
76
+
77
+ describe("the secret names a profile may not carry", () => {
78
+ test("are the names the logger masks, so the two cannot drift apart", () => {
79
+ for (const name of ["apiKey", "sentryDsn", "slackWebhook", "dbPassword", "serviceAccount", "enrollKey", "host"]) {
80
+ const named = ServerEnvProfile.secretPaths({ [name]: "a value" }).length > 0;
81
+ expect(named).toBe(Logger.isSecretKey(name));
82
+ expect(Logger.redactAttrs({ [name]: "a value" })[name] === "[redacted]").toBe(named);
83
+ }
84
+ });
85
+ });
86
+
87
+ describe("ServerEnvProfile.secretValues", () => {
88
+ test("looks for a secret, not for a token endpoint or a plain constant a credential block holds as library code does", () => {
89
+ const pem =
90
+ "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7\n-----END PRIVATE KEY-----\n";
91
+ expect(
92
+ ServerEnvProfile.secretValues({
93
+ iapVerify: {
94
+ google: { type: "service_account", token_uri: "https://oauth2.googleapis.com/token", private_key: pem },
95
+ },
96
+ security: { jwtSecret: "correct-horse-battery", tokenEndpoint: "https://auth.example.com/oauth2/token" },
97
+ calendar: { secretIcalUrl: "https://calendar.example.com/ical/team/private-0123456789abcdef0123/basic.ics" },
98
+ discord: { webhook: "https://discord.com/api/webhooks/123/abcdefgh" },
99
+ database: { postgres: { url: "postgres://app:pg-password-1@db/app", host: "db.internal.example" } },
100
+ mailer: { auth: { pass: "short" } },
101
+ }),
102
+ ).toEqual([
103
+ { path: "iapVerify.google.private_key", value: pem },
104
+ { path: "security.jwtSecret", value: "correct-horse-battery" },
105
+ {
106
+ path: "calendar.secretIcalUrl",
107
+ value: "https://calendar.example.com/ical/team/private-0123456789abcdef0123/basic.ics",
108
+ },
109
+ { path: "discord.webhook", value: "https://discord.com/api/webhooks/123/abcdefgh" },
110
+ { path: "database.postgres.url", value: "postgres://app:pg-password-1@db/app" },
111
+ { path: "database.postgres.url", value: "pg-password-1" },
112
+ ]);
113
+ });
114
+ });
115
+
116
+ describe("ServerEnvProfile.strip", () => {
117
+ test("leaves a stripped element out of an array whole, with no null or emptied object in its place", () => {
118
+ expect(
119
+ ServerEnvProfile.strip({
120
+ tokens: ["a", "b"],
121
+ clients: [{ secret: "x" }, { name: "n", secret: "y" }, {}],
122
+ ports: [1, "two", null],
123
+ replicas: ["postgres://u:p@a/db", "postgres://b/db"],
124
+ }),
125
+ ).toEqual({
126
+ value: { tokens: [], clients: [{ name: "n" }, {}], ports: [1, "two", null], replicas: ["postgres://b/db"] },
127
+ stripped: ["tokens.0", "tokens.1", "clients.0.secret", "clients.1.secret", "replicas.0"],
128
+ });
129
+ });
130
+ });
131
+
132
+ describe("ServerEnvProfile.evaluate", () => {
133
+ test("says why and where an env file could not be evaluated, and never a line or a value of it", async () => {
134
+ const root = await makeTempRoot();
135
+ const secret = "dummy-secret-0123456789";
136
+ const sources = {
137
+ syntax: `export const env = {\n jwtSecret: "${secret}" "again-${secret}",\n};\n`,
138
+ unterminated: `export const env = {\n jwtSecret: "${secret},\n};\n`,
139
+ evaluating: `export const env = { jwtSecret: "${secret}".foo.bar };\n`,
140
+ thrown: `const key = "${secret}";\nif (key) throw new Error("bad key: " + key);\nexport const env = {};\n`,
141
+ thrownString: `throw "${secret}";\nexport const env = {};\n`,
142
+ reference: `export const env = {\n jwtSecret: "${secret}",\n x: undefinedVar.y,\n};\n`,
143
+ };
144
+ const messages: Record<string, string> = {};
145
+ for (const [name, source] of Object.entries(sources)) {
146
+ const file = path.join(root, `env/env.server.${name}.ts`);
147
+ await write(file, source);
148
+ messages[name] = await ServerEnvProfile.evaluate(file, root).then(
149
+ () => "",
150
+ (error: Error) => error.message,
151
+ );
152
+ }
153
+ for (const message of Object.values(messages)) {
154
+ expect(message).toContain("could not be evaluated to check it for secrets");
155
+ expect(message).not.toContain(secret);
156
+ }
157
+ expect(messages.reference).toMatch(
158
+ /^env\.server\.reference\.ts could not be evaluated to check it for secrets: ReferenceError: undefinedVar is not defined \(at .*env\.server\.reference\.ts:3:\d+\)$/,
159
+ );
160
+ expect(messages.syntax).toMatch(/env\.server\.syntax\.ts:2:\d+\)$/);
161
+ });
162
+
163
+ test("reads an env file that writes to stdout as it loads", async () => {
164
+ const envDir = await writeProfile(
165
+ 'console.info("loading the edge env");\nprocess.stdout.write("{ not json");\nexport const env = { hostname: "edge" };\n',
166
+ );
167
+ expect(await ServerEnvProfile.evaluate(path.join(envDir, "env.server.main.edge.ts"), path.dirname(envDir))).toEqual(
168
+ {
169
+ hostname: "edge",
170
+ },
171
+ );
172
+ });
42
173
  });
43
174
 
44
175
  describe("ServerEnvProfile.assertShippable", () => {
@@ -70,6 +201,64 @@ describe("ServerEnvProfile.assertShippable", () => {
70
201
  );
71
202
  });
72
203
 
204
+ test("judges a profile that spreads the lib env defaults by what its bundle carries of them", async () => {
205
+ const root = await makeTempRoot();
206
+ const utilEnv = path.join(root, "libs/util/env/env.server.testing.ts");
207
+ await write(
208
+ utilEnv,
209
+ 'export const env = { hostname: "lib-host", security: { jwtSecret: "util-jwt" }, objectStorage: { secretAccessKey: "util-s3" } };\n',
210
+ );
211
+ await write(
212
+ path.join(root, "libs/shared/env/env.server.type.ts"),
213
+ `import { env as util } from ${JSON.stringify(utilEnv)};\nexport const libEnv = { ...util };\n`,
214
+ );
215
+ const sharedEnv = path.join(root, "libs/shared/env/env.server.testing.ts");
216
+ await write(
217
+ sharedEnv,
218
+ 'import { libEnv } from "./env.server.type";\nexport const env = { ...libEnv, rootAdminInfo: { password: "shared-root" } };\n',
219
+ );
220
+ const envDir = path.join(root, "apps/demo/env");
221
+ await write(
222
+ path.join(envDir, "env.server.type.ts"),
223
+ `import { env as shared } from ${JSON.stringify(sharedEnv)};\nexport const libEnv = { ...shared };\n`,
224
+ );
225
+ await write(
226
+ path.join(envDir, "env.server.main.edge.ts"),
227
+ 'import { libEnv } from "./env.server.type";\nexport const env = { ...libEnv, origin: "edge" };\n',
228
+ );
229
+ const shippable = () => ServerEnvProfile.assertShippable({ envDir, environment: "main", profile: "edge" });
230
+ await expect(shippable()).resolves.toBe(undefined);
231
+
232
+ const sharedEdge = path.join(root, "libs/shared/env/env.server.testing.edge.ts");
233
+ await write(
234
+ sharedEdge,
235
+ 'import { libEnv } from "./env.server.type";\nexport const env = { ...libEnv, sharedOrigin: "edge" };\n',
236
+ );
237
+ await expect(shippable()).resolves.toBe(undefined);
238
+
239
+ await write(sharedEdge, 'export const env = { mailer: { pass: "edge-pass" } };\n');
240
+ await expect(shippable()).rejects.toThrow(`${sharedEdge} holds secret-named values`);
241
+
242
+ await write(sharedEdge, 'export const env = { origin: "edge" };\n');
243
+ await write(
244
+ path.join(envDir, "env.server.main.edge.ts"),
245
+ 'import { libEnv } from "./env.server.type";\nexport const env = { ...libEnv, llm: { apiKey: "edge-key" } };\n',
246
+ );
247
+ await expect(shippable()).rejects.toThrow(
248
+ "holds secret-named values, which a profile's bundle carries to every computer it runs on: llm.apiKey.",
249
+ );
250
+ });
251
+
252
+ test("refuses a profile that imports the file it is bundled in place of, which it would then import as itself", async () => {
253
+ const envDir = await writeProfile(
254
+ 'import { env as main } from "./env.server.main";\nexport const env = { hostname: main.hostname };\n',
255
+ );
256
+ await write(path.join(envDir, "env.server.main.ts"), 'export const env = { hostname: "main-host" };\n');
257
+ await expect(ServerEnvProfile.assertShippable({ envDir, environment: "main", profile: "edge" })).rejects.toThrow(
258
+ 'env.server.main.edge.ts imports ./env.server.main, the file a "edge" build bundles it in place of, so it would import itself.',
259
+ );
260
+ });
261
+
73
262
  test("refuses a missing profile file and a malformed name", async () => {
74
263
  const envDir = await writeProfile("export const env = {};\n");
75
264
  await expect(ServerEnvProfile.assertShippable({ envDir, environment: "develop", profile: "edge" })).rejects.toThrow(