@podosoft/podokit 1.6.1 → 1.6.3

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 (33) hide show
  1. package/dist/deploy-checks.d.ts +16 -5
  2. package/dist/deploy-checks.js +14 -6
  3. package/dist/deploy-render.js +28 -3
  4. package/dist/deploy.d.ts +9 -0
  5. package/dist/deploy.js +83 -17
  6. package/dist/index.js +9 -2
  7. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +50 -4
  8. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +1 -1
  9. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +3 -1
  10. package/dist/templates/fullstack/tests/README.md +11 -0
  11. package/dist/templates/fullstack/tests/helpers/paths.ts +13 -2
  12. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +96 -7
  13. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +11 -0
  14. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +5 -5
  15. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +61 -12
  16. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +2 -2
  17. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +2 -2
  18. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
  19. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +2 -2
  20. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +3 -3
  21. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +24 -2
  22. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +609 -0
  23. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +178 -42
  24. package/dist/templates/modules/audit-log/module.manifest.json +2 -2
  25. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -1
  26. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +13 -1
  27. package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.spec.ts +67 -0
  28. package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.ts +54 -0
  29. package/dist/templates/modules/auth/module.manifest.json +1 -0
  30. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +208 -62
  31. package/dist/update.d.ts +25 -5
  32. package/dist/update.js +86 -10
  33. package/package.json +1 -1
@@ -9,21 +9,124 @@ import {
9
9
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
10
10
  const origin = { origin: base };
11
11
 
12
- // rate-limit installs a global throttler guard (default RATE_LIMIT_MAX requests
13
- // per RATE_LIMIT_TTL seconds, per client). This module ships a global guard, so
14
- // it never joins the shared Outer smoke app (it would throttle every other
15
- // spec); this spec ships with the module and runs against a dedicated app.
12
+ // rate-limit installs a global guard that counts each request in one budget: the
13
+ // general one (RATE_LIMIT_MAX requests per RATE_LIMIT_TTL seconds, per client),
14
+ // the stricter authentication one for other /api/auth requests, or the runtime
15
+ // one for session reads. This module ships a global guard, so it never joins the
16
+ // shared Outer smoke app (it would throttle every other spec); this spec ships
17
+ // with the module and runs against a dedicated app.
16
18
  //
17
- // Honor RATE_LIMIT_MAX when the test runner has it (a dedicated app sets a small
18
- // value so the window is crossed in a handful of requests); otherwise fall back
19
- // to the module default and allow a little headroom.
20
- // A local stack that raises the limits to run the whole suite quickly sets
21
- // E2E_SKIP_RATE_LIMIT=true and runs this file separately against the defaults.
22
- test.skip(process.env.E2E_SKIP_RATE_LIMIT === "true", "assumes the configured limits; run it on its own");
19
+ // Every counted response names its budget's limit and what is left of it in the
20
+ // RateLimit-Limit and RateLimit-Remaining headers. The spec reads the limits from
21
+ // them, so it follows whatever limits the stack runs with. When the runner's
22
+ // environment sets RATE_LIMIT_MAX, RATE_LIMIT_AUTH_MAX, or RATE_LIMIT_RUNTIME_MAX,
23
+ // the spec also expects the stack to use that value. A test that sends a budget's
24
+ // worth of requests times the first ones and skips, with the reason, when the
25
+ // rest would not fit in its time, as with raised local limits. A run that should
26
+ // not send them at all sets E2E_SKIP_RATE_LIMIT=true to skip the file.
27
+ test.skip(process.env.E2E_SKIP_RATE_LIMIT === "true", "E2E_SKIP_RATE_LIMIT is true");
23
28
 
24
- const limit = Number(process.env.RATE_LIMIT_MAX ?? 300);
25
- const authLimit = Number(process.env.RATE_LIMIT_AUTH_MAX ?? 20);
26
- const runtimeLimit = Number(process.env.RATE_LIMIT_RUNTIME_MAX ?? 1000);
29
+ /** Requests a test times before it judges whether the rest fit in its time. */
30
+ const PACE_SAMPLE = 50;
31
+
32
+ let testStarted = Date.now();
33
+ test.beforeEach(() => {
34
+ testStarted = Date.now();
35
+ });
36
+
37
+ function seconds(ms: number): string {
38
+ return `${(ms / 1000).toFixed(1)} s`;
39
+ }
40
+
41
+ /**
42
+ * Why `left` more requests, sent at the pace of the `sent` ones since `since`,
43
+ * would not end within half of the time this test has left, or undefined when
44
+ * they would. The other half leaves room for a slower stretch.
45
+ */
46
+ function outOfTime(sent: number, since: number, left: number): string | undefined {
47
+ const { timeout } = test.info();
48
+ if (timeout === 0) return undefined;
49
+ const now = Date.now();
50
+ const perRequest = (now - since) / sent;
51
+ const needed = perRequest * left;
52
+ const available = timeout - (now - testStarted);
53
+ if (needed <= available / 2) return undefined;
54
+ const pace = Math.round(1000 / perRequest);
55
+ return `${left} more requests at ${pace} per second take about ${seconds(needed)}, more than half of the ${seconds(available)} this test has left`;
56
+ }
57
+
58
+ /** Requires a session, so a session's requests count under its user from the first one. */
59
+ const PROBE = "/api/account/me";
60
+ const HEALTH = ["/api/health", "/api/health/ready"];
61
+
62
+ interface Budget {
63
+ limit: number;
64
+ remaining: number;
65
+ }
66
+
67
+ /** The budget a response was counted in, from its RateLimit headers. */
68
+ function budget(response: APIResponse): Budget {
69
+ const headers = response.headers();
70
+ const limit = Number(headers["ratelimit-limit"]);
71
+ const remaining = Number(headers["ratelimit-remaining"]);
72
+ const counted = Number.isInteger(limit) && Number.isInteger(remaining);
73
+ expect(counted, `RateLimit headers of ${response.url()}`).toBe(true);
74
+ return { limit, remaining };
75
+ }
76
+
77
+ /** Expects `limit` to be the value the runner's environment sets for `name`, if it sets one. */
78
+ function expectConfigured(
79
+ name: "RATE_LIMIT_MAX" | "RATE_LIMIT_AUTH_MAX" | "RATE_LIMIT_RUNTIME_MAX",
80
+ limit: number,
81
+ ): void {
82
+ const configured = process.env[name];
83
+ if (configured) expect(limit, `${name} in the runner's environment`).toBe(Number(configured));
84
+ }
85
+
86
+ /**
87
+ * Runs a check that compares counts across several requests until it passes. A
88
+ * counter lasts its window from its first request, so with a short
89
+ * RATE_LIMIT_TTL a new window can start between two requests of the check and
90
+ * reset what it compares; the next attempt starts over in that window.
91
+ */
92
+ async function inOneWindow(check: () => Promise<void>): Promise<void> {
93
+ await expect(check).toPass({ timeout: 30_000 });
94
+ }
95
+
96
+ /**
97
+ * Requests the probe until the limiter answers 429, or returns why it stopped
98
+ * first: the general window kept ending first, or the requests left to the
99
+ * limit would not fit in the test's time. More remaining than on the request
100
+ * before means a new window started, which starts the count over.
101
+ */
102
+ async function untilLimited(ctx: APIRequestContext, limit: number): Promise<APIResponse | string> {
103
+ const started = Date.now();
104
+ let sent = 0;
105
+ let windows = 1;
106
+ let counted = 0;
107
+ let previous = Number.POSITIVE_INFINITY;
108
+ for (;;) {
109
+ const response = await ctx.get(PROBE);
110
+ if (response.status() === 429) return response;
111
+ expect(response.status(), `${PROBE} request ${counted + 1} of window ${windows}`).toBe(200);
112
+ const { remaining } = budget(response);
113
+ sent += 1;
114
+ if (remaining >= previous) {
115
+ windows += 1;
116
+ counted = 0;
117
+ if (windows > 3) return `the general window ends before this client sends ${limit} requests`;
118
+ }
119
+ previous = remaining;
120
+ counted += 1;
121
+ // A window holds at most `limit` served requests.
122
+ expect(counted, `requests served in one general window`).toBeLessThanOrEqual(limit);
123
+ // Judged in each window, since a new one needs the whole limit again.
124
+ if (counted === PACE_SAMPLE) {
125
+ const late = outOfTime(sent, started, remaining + 1);
126
+ if (late) return `reaching the general limit (${limit}): ${late}`;
127
+ }
128
+ }
129
+ }
27
130
 
28
131
  type Playwright = PlaywrightWorkerArgs["playwright"];
29
132
  const password = "Podokit3e-Str0ng!pw";
@@ -38,6 +141,8 @@ function uniqueEmail(): string {
38
141
  // anonymous: the later ones carry its session cookie, so the limiter counts them
39
142
  // against that session instead.
40
143
  let carrier: string | undefined;
144
+ /** The authentication budget's limit, as the latest sign-up or sign-in reported it. */
145
+ let authLimit = 0;
41
146
 
42
147
  /** Run one authentication write and return a context holding the session it set. */
43
148
  async function authenticated(
@@ -51,6 +156,7 @@ async function authenticated(
51
156
  });
52
157
  const response = await writer.post(path, { data });
53
158
  expect(response.ok(), `${path}: ${response.status()}`).toBeTruthy();
159
+ authLimit = budget(response).limit;
54
160
  const storageState = await writer.storageState();
55
161
  await writer.dispose();
56
162
  const token = storageState.cookies.find((cookie) => cookie.name.endsWith("session_token"));
@@ -67,34 +173,39 @@ test("rate limit: health probes stay available while ordinary routes return 429
67
173
  playwright,
68
174
  }) => {
69
175
  const ctx = await session(playwright);
70
- for (const path of ["/api/health", "/api/health/ready"]) {
71
- for (let i = 0; i < limit + 5; i++) {
72
- const response = await ctx.get(path);
73
- expect(response.status(), `${path} request ${i + 1}`).toBe(200);
176
+ const probe = await ctx.get(PROBE);
177
+ test.skip(probe.status() >= 500, "the limiter's storage is not reachable");
178
+ expect(probe.status()).toBe(200);
179
+ const { limit } = budget(probe);
180
+ expectConfigured("RATE_LIMIT_MAX", limit);
181
+
182
+ // Health probes are not counted: they carry no budget and leave the general one as it was.
183
+ await inOneWindow(async () => {
184
+ const before = budget(await ctx.get(PROBE)).remaining;
185
+ for (const path of HEALTH) {
186
+ for (let i = 0; i < 5; i++) {
187
+ const response = await ctx.get(path);
188
+ expect(response.status(), `${path} request ${i + 1}`).toBe(200);
189
+ expect(response.headers()["ratelimit-limit"], path).toBeUndefined();
190
+ }
74
191
  }
75
- }
192
+ expect(budget(await ctx.get(PROBE)).remaining).toBe(before - 1);
193
+ });
76
194
 
77
- // The redis module is added automatically and exposes a public demo cache
78
- // route, so it provides a stable ordinary endpoint for the global limit.
79
- const probe = await ctx.get("/api/cache/rate-limit-probe");
80
- test.skip(probe.status() >= 500, "throttler storage (redis) not reachable");
81
-
82
- let seen200 = probe.status() === 200;
83
- let limitedResponse: APIResponse | undefined;
84
- for (let i = 0; i < limit + 5 && !limitedResponse; i++) {
85
- const response = await ctx.get("/api/cache/rate-limit-probe");
86
- if (response.status() === 200) seen200 = true;
87
- if (response.status() === 429) limitedResponse = response;
195
+ // A window serves `limit` requests and rejects the next one.
196
+ const limited = await untilLimited(ctx, limit);
197
+ if (typeof limited === "string") {
198
+ test.skip(true, limited);
199
+ return;
88
200
  }
89
- // A fresh window serves the first requests (200) and rejects once the limit is
90
- // crossed (429).
91
- expect(seen200).toBe(true);
92
- expect(limitedResponse?.status()).toBe(429);
93
- expect(limitedResponse?.headers()["retry-after"]).toBeTruthy();
94
- expect(await limitedResponse?.json()).toMatchObject({
201
+ expect(limited.headers()["retry-after"]).toBeTruthy();
202
+ expect(await limited.json()).toMatchObject({
95
203
  success: false,
96
204
  error: { code: "RATE_LIMIT_EXCEEDED", statusCode: 429 },
97
205
  });
206
+ for (const path of HEALTH) {
207
+ expect((await ctx.get(path)).status(), `${path} while the session is limited`).toBe(200);
208
+ }
98
209
  await ctx.dispose();
99
210
  });
100
211
 
@@ -103,10 +214,10 @@ test("rate limit: a user's sessions share one counter and forged sessions do not
103
214
  }) => {
104
215
  const email = uniqueEmail();
105
216
  const first = await session(playwright, email);
106
- const probe = await first.get("/api/cache/rate-limit-probe");
107
- test.skip(probe.status() >= 500, "throttler storage (redis) not reachable");
108
- const remaining = (response: APIResponse): number => Number(response.headers()["ratelimit-remaining"]);
109
- const ceiling = Number(probe.headers()["ratelimit-limit"]);
217
+ const probe = await first.get(PROBE);
218
+ test.skip(probe.status() >= 500, "the limiter's storage is not reachable");
219
+ const remaining = (response: APIResponse): number => budget(response).remaining;
220
+ const { limit } = budget(probe);
110
221
 
111
222
  // A second sign-in of the same user, and the signed bearer form of the first
112
223
  // session. The probe requires a session, so each request below resolves it; a
@@ -119,22 +230,24 @@ test("rate limit: a user's sessions share one counter and forged sessions do not
119
230
  baseURL: base,
120
231
  extraHTTPHeaders: { ...origin, authorization: `Bearer ${cookie?.value ?? ""}` },
121
232
  });
122
- await second.get("/api/cache/rate-limit-probe");
233
+ await second.get(PROBE);
123
234
 
124
235
  // Both sessions now draw from one budget: each request lowers what the next
125
236
  // one, from either session, has left.
126
- const fromFirst = remaining(await first.get("/api/cache/rate-limit-probe"));
127
- expect(remaining(await second.get("/api/cache/rate-limit-probe"))).toBe(fromFirst - 1);
128
- expect(remaining(await bearer.get("/api/cache/rate-limit-probe"))).toBe(fromFirst - 2);
129
- expect(remaining(await first.get("/api/cache/rate-limit-probe"))).toBe(fromFirst - 3);
237
+ await inOneWindow(async () => {
238
+ const fromFirst = remaining(await first.get(PROBE));
239
+ expect(remaining(await second.get(PROBE))).toBe(fromFirst - 1);
240
+ expect(remaining(await bearer.get(PROBE))).toBe(fromFirst - 2);
241
+ expect(remaining(await first.get(PROBE))).toBe(fromFirst - 3);
242
+ });
130
243
 
131
244
  // Another user has a budget of their own.
132
245
  const other = await session(playwright);
133
- expect(remaining(await other.get("/api/cache/rate-limit-probe"))).toBe(ceiling - 1);
246
+ expect(remaining(await other.get(PROBE))).toBe(limit - 1);
134
247
 
135
248
  // A session cookie without a valid signature is counted with anonymous traffic
136
- // from the same client address instead of receiving a fresh counter. The cache
137
- // probe requires a session, so compare on a public route with the general limit.
249
+ // from the same client address instead of receiving a fresh counter. The probe
250
+ // requires a session, so compare on a public route with the general limit.
138
251
  const token = decodeURIComponent(cookie?.value ?? "").split(".")[0] ?? "";
139
252
  const forged = await playwright.request.newContext({
140
253
  baseURL: base,
@@ -154,11 +267,27 @@ test("rate limit: a user's sessions share one counter and forged sessions do not
154
267
  test("rate limit: session reads do not consume the strict authentication budget", async ({
155
268
  playwright,
156
269
  }) => {
157
- test.skip(authLimit > 1000, "the authentication budget is too large to exhaust here");
158
270
  const ctx = await session(playwright);
159
- for (let i = 0; i < authLimit + 5; i++) {
271
+ const read = await ctx.get("/api/auth/get-session");
272
+ test.skip(read.status() >= 500, "the limiter's storage is not reachable");
273
+ const runtimeLimit = budget(read).limit;
274
+ expectConfigured("RATE_LIMIT_RUNTIME_MAX", runtimeLimit);
275
+ expectConfigured("RATE_LIMIT_AUTH_MAX", authLimit);
276
+ // Reading past the authentication budget shows the reads are not counted in
277
+ // it, but only while those reads fit in the runtime budget.
278
+ const reads = authLimit + 5;
279
+ test.skip(
280
+ reads + 1 > runtimeLimit,
281
+ `the authentication budget (${authLimit}) is not far enough below the runtime budget (${runtimeLimit})`,
282
+ );
283
+ const started = Date.now();
284
+ for (let i = 1; i <= reads; i++) {
160
285
  const response = await ctx.get("/api/auth/get-session");
161
- expect(response.status(), `session request ${i + 1}`).toBe(200);
286
+ expect(response.status(), `session request ${i}`).toBe(200);
287
+ if (i === PACE_SAMPLE) {
288
+ const late = outOfTime(i, started, reads - i);
289
+ test.skip(late !== undefined, `reading past the authentication budget (${authLimit}): ${late}`);
290
+ }
162
291
  }
163
292
  await ctx.dispose();
164
293
  });
@@ -167,17 +296,34 @@ test("rate limit: each route is counted in its most specific registered budget",
167
296
  playwright,
168
297
  }) => {
169
298
  const ctx = await session(playwright);
170
- const probe = await ctx.get("/api/account/capabilities");
171
- test.skip(probe.status() >= 500, "throttler storage (redis) not reachable");
172
- const ceiling = (response: APIResponse): number => Number(response.headers()["ratelimit-limit"]);
173
-
174
- // Ordinary routes fall back to the general budget registered at "/".
175
- expect(ceiling(probe)).toBe(limit);
176
- // Longer GET prefixes inside "/api/auth" win over its authentication budget:
177
- // session reads use the runtime budget and administrator reads the general one.
178
- expect(ceiling(await ctx.get("/api/auth/get-session"))).toBe(runtimeLimit);
179
- expect(ceiling(await ctx.get("/api/auth/admin/list-users"))).toBe(limit);
180
- // Other "/api/auth" requests carry the strict authentication budget.
181
- expect(ceiling(await ctx.post("/api/auth/sign-out", { data: {} }))).toBe(authLimit);
299
+ const probe = await ctx.get(PROBE);
300
+ test.skip(probe.status() >= 500, "the limiter's storage is not reachable");
301
+ const generalLimit = budget(probe).limit;
302
+ expectConfigured("RATE_LIMIT_MAX", generalLimit);
303
+ expectConfigured("RATE_LIMIT_AUTH_MAX", authLimit);
304
+
305
+ await inOneWindow(async () => {
306
+ // Ordinary routes fall back to the general budget registered at "/".
307
+ const ordinary = budget(await ctx.get("/api/account/capabilities"));
308
+ // Longer GET prefixes inside "/api/auth" win over its authentication budget:
309
+ // administrator reads count in the general budget and session reads in the
310
+ // runtime one.
311
+ const adminRead = budget(await ctx.get("/api/auth/admin/list-users"));
312
+ const sessionRead = budget(await ctx.get("/api/auth/get-session"));
313
+ // Other "/api/auth" requests count in the strict authentication budget.
314
+ const authWrite = budget(await ctx.post("/api/auth/update-user", { data: { name: "Rate Limit" } }));
315
+ const sessionReadAgain = budget(await ctx.get("/api/auth/get-session"));
316
+ const ordinaryAgain = budget(await ctx.get("/api/account/capabilities"));
317
+
318
+ expect(ordinary.limit).toBe(generalLimit);
319
+ expect(adminRead).toEqual({ limit: generalLimit, remaining: ordinary.remaining - 1 });
320
+ // Neither the session reads nor the authentication write moved the general counter,
321
+ expect(ordinaryAgain).toEqual({ limit: generalLimit, remaining: ordinary.remaining - 2 });
322
+ // the authentication write did not move the runtime one,
323
+ expect(sessionReadAgain).toEqual({ limit: sessionRead.limit, remaining: sessionRead.remaining - 1 });
324
+ // and it was counted with sign-ups and sign-ins.
325
+ expect(authWrite.limit).toBe(authLimit);
326
+ expectConfigured("RATE_LIMIT_RUNTIME_MAX", sessionRead.limit);
327
+ });
182
328
  await ctx.dispose();
183
329
  });
package/dist/update.d.ts CHANGED
@@ -96,8 +96,14 @@ export interface TargetProject {
96
96
  export declare function targetProject(projectRoot: string, templatesDir: string, targetAnswers?: TemplateVars): TargetProject;
97
97
  export declare function planUpdate(projectRoot: string, templatesDir: string, options?: PlanOptions): UpdatePlan;
98
98
  export interface ApplyOptions {
99
- /** Templates for the version the project is currently on, for 3-way merges. */
99
+ /**
100
+ * Templates for the version the project is currently on, for 3-way merges.
101
+ * Without it, the update uses the installed templates when the project is on
102
+ * the installed version and otherwise fetches that version's.
103
+ */
100
104
  oldTemplatesDir?: string;
105
+ /** Fetches the templates a published PodoKit version ships. Defaults to npm. */
106
+ fetchTemplates?: (version: string) => PreviousTemplates;
101
107
  /** Preinstalled previous external modules. Primarily useful for offline updates. */
102
108
  oldExternalModulesRoot?: string;
103
109
  /** Replay variables and manifest value used by a runtime conversion. */
@@ -124,9 +130,22 @@ export interface ApplyResult {
124
130
  conflicts: string[];
125
131
  /** Owned files whose template changed; they are left as they are. */
126
132
  review: string[];
133
+ /** Edited managed files the update left as they are, and why. */
134
+ skipped: SkippedFile[];
127
135
  /** Direct API paths added to `.podokit/dev.json` and deployment profiles. */
128
136
  apiPaths: ApiPathResult;
129
137
  }
138
+ export interface SkippedFile {
139
+ path: string;
140
+ reason: string;
141
+ }
142
+ /** A template set to merge against, and how to dispose of it. */
143
+ export interface PreviousTemplates {
144
+ dir: string;
145
+ cleanup(): void;
146
+ }
147
+ /** Install a published PodoKit CLI into a temporary directory and return its templates. */
148
+ export declare function publishedTemplates(version: string): PreviousTemplates;
130
149
  export interface UpstreamChange {
131
150
  path: string;
132
151
  /** Unified diff from the previous template to the new one; empty when the
@@ -141,10 +160,11 @@ export interface UpstreamChange {
141
160
  export declare function upstreamChanges(projectRoot: string, templatesDir: string, plan: UpdatePlan, oldTemplatesDir: string, oldExternalModulesRoot?: string): UpstreamChange[];
142
161
  /**
143
162
  * Apply an update to the working copy. Clean updates and additions are written,
144
- * upstream removals deleted, and user-edited files 3-way merged against the old
145
- * version (when `oldTemplatesDir` is given) or written with git-style conflict
146
- * markers otherwise. Owned files are never touched. Refreshes the lockfile and
147
- * stamps the new version.
163
+ * upstream removals deleted, and user-edited files 3-way merged against the
164
+ * templates of the version the project is on, with git-style conflict markers
165
+ * where both sides changed the same lines. Refuses before writing anything when
166
+ * those templates are unavailable. Owned files are never touched. Refreshes the
167
+ * lockfile and stamps the new version.
148
168
  */
149
169
  export declare function applyUpdate(projectRoot: string, templatesDir: string, options?: ApplyOptions): ApplyResult;
150
170
  /** One line per external module installed at an older version than recorded. */
package/dist/update.js CHANGED
@@ -4,6 +4,7 @@ exports.compareVersions = compareVersions;
4
4
  exports.declaredOwnedGlobs = declaredOwnedGlobs;
5
5
  exports.targetProject = targetProject;
6
6
  exports.planUpdate = planUpdate;
7
+ exports.publishedTemplates = publishedTemplates;
7
8
  exports.upstreamChanges = upstreamChanges;
8
9
  exports.applyUpdate = applyUpdate;
9
10
  exports.describeDowngrades = describeDowngrades;
@@ -334,6 +335,61 @@ function planUpdate(projectRoot, templatesDir, options = {}) {
334
335
  ownedWithoutTemplate,
335
336
  };
336
337
  }
338
+ const CLI_PACKAGE = "@podosoft/podokit";
339
+ const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
340
+ /** Install a published PodoKit CLI into a temporary directory and return its templates. */
341
+ function publishedTemplates(version) {
342
+ if (!EXACT_VERSION.test(version)) {
343
+ throw new Error(`"${version}" is not a published ${CLI_PACKAGE} version.`);
344
+ }
345
+ const root = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "podokit-update-templates-"));
346
+ const cleanup = () => (0, node_fs_1.rmSync)(root, { recursive: true, force: true });
347
+ try {
348
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(root, "package.json"), `${JSON.stringify({ name: "podokit-update-templates", private: true, devDependencies: { [CLI_PACKAGE]: version } }, null, 2)}\n`);
349
+ (0, node_child_process_1.execFileSync)("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", "--package-lock=false"], { cwd: root, stdio: "pipe" });
350
+ const packageDir = (0, node_path_1.join)(root, "node_modules", ...CLI_PACKAGE.split("/"));
351
+ const installed = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(packageDir, "package.json"), "utf8")).version;
352
+ const dir = (0, node_path_1.join)(packageDir, "dist", "templates");
353
+ if (installed !== version || !(0, node_fs_1.existsSync)(dir)) {
354
+ throw new Error(`the installed package is not ${version} or ships no templates`);
355
+ }
356
+ return { dir, cleanup };
357
+ }
358
+ catch (error) {
359
+ cleanup();
360
+ throw new Error(`Cannot install ${CLI_PACKAGE}@${version} from the npm registry: ${npmFailure(error)}`);
361
+ }
362
+ }
363
+ /** npm's error code, such as ECONNREFUSED or ETARGET, rather than the command line. */
364
+ function npmFailure(error) {
365
+ const stderr = error?.stderr;
366
+ const code = String(stderr ?? "").match(/^npm (?:error|ERR!) code (\S+)/m)?.[1];
367
+ if (code)
368
+ return `npm error ${code}`;
369
+ return error instanceof Error ? (error.message.split("\n")[0] ?? error.message) : String(error);
370
+ }
371
+ /**
372
+ * The templates the project's edited managed files were generated from, which
373
+ * a 3-way merge needs as its base: `--from` when given, the installed ones when
374
+ * the project is on the installed version, and the recorded version's otherwise.
375
+ */
376
+ function mergeBaseTemplates(recordedVersion, templatesDir, options, paths) {
377
+ if (options.oldTemplatesDir)
378
+ return { dir: options.oldTemplatesDir, cleanup: () => { } };
379
+ if (recordedVersion === (0, lockfile_1.podokitVersion)())
380
+ return { dir: templatesDir, cleanup: () => { } };
381
+ try {
382
+ return (options.fetchTemplates ?? publishedTemplates)(recordedVersion);
383
+ }
384
+ catch (error) {
385
+ throw new Error(`${error instanceof Error ? error.message : String(error)}\n` +
386
+ `The 3-way merge of these edited files needs the templates of ${recordedVersion}, ` +
387
+ `the version this project was last generated or updated with:\n${paths
388
+ .map((path) => ` - ${path}`)
389
+ .join("\n")}\n` +
390
+ "Nothing was written. Check npm registry access, or pass that version's templates with --from <dir>.");
391
+ }
392
+ }
337
393
  function assertRecordedExternalModules(moduleRoot, templatesDir, modules) {
338
394
  const modulesDir = (0, node_path_1.join)(templatesDir, "modules");
339
395
  for (const module of modules) {
@@ -486,10 +542,11 @@ function updatedFilesLock(projectRoot, newTree, previous, ownedGlobs, managedOve
486
542
  }
487
543
  /**
488
544
  * Apply an update to the working copy. Clean updates and additions are written,
489
- * upstream removals deleted, and user-edited files 3-way merged against the old
490
- * version (when `oldTemplatesDir` is given) or written with git-style conflict
491
- * markers otherwise. Owned files are never touched. Refreshes the lockfile and
492
- * stamps the new version.
545
+ * upstream removals deleted, and user-edited files 3-way merged against the
546
+ * templates of the version the project is on, with git-style conflict markers
547
+ * where both sides changed the same lines. Refuses before writing anything when
548
+ * those templates are unavailable. Owned files are never touched. Refreshes the
549
+ * lockfile and stamps the new version.
493
550
  */
494
551
  function applyUpdate(projectRoot, templatesDir, options = {}) {
495
552
  const manifest = (0, lockfile_1.readManifest)(projectRoot);
@@ -528,10 +585,18 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
528
585
  const migratedManagedOverrides = [
529
586
  ...new Set(managedOverrides.map((glob) => (0, module_migrations_1.migrateConfiguredPath)(glob, pendingMigrations))),
530
587
  ];
531
- const needsMergeBase = plan.changes.some((change) => change.action === "conflict");
588
+ const mergePaths = plan.changes
589
+ .filter((change) => change.action === "conflict" && treeText(newTree, change.path) !== null)
590
+ .map((change) => change.path);
532
591
  let oldTree = null;
533
- if (options.oldTemplatesDir && needsMergeBase) {
534
- oldTree = previousTree(projectRoot, manifest, options.oldTemplatesDir, options.oldExternalModulesRoot);
592
+ if (mergePaths.length) {
593
+ const base = mergeBaseTemplates(manifest.podokitVersion, templatesDir, options, mergePaths);
594
+ try {
595
+ oldTree = previousTree(projectRoot, manifest, base.dir, options.oldExternalModulesRoot);
596
+ }
597
+ finally {
598
+ base.cleanup();
599
+ }
535
600
  }
536
601
  if (options.abortOnConflict) {
537
602
  const unresolved = [];
@@ -562,6 +627,7 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
562
627
  merged: [],
563
628
  conflicts: [],
564
629
  review: plan.changes.filter((change) => change.action === "review").map((change) => change.path),
630
+ skipped: [],
565
631
  apiPaths: { changed: [], skipped: [] },
566
632
  };
567
633
  for (const change of plan.changes) {
@@ -574,12 +640,22 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
574
640
  (0, node_fs_1.rmSync)((0, node_path_1.join)(projectRoot, change.path), { force: true });
575
641
  result.removed.push(change.path);
576
642
  }
577
- else if (change.action === "conflict" && newText !== null) {
643
+ else if (change.action === "conflict") {
644
+ if (newText === null) {
645
+ result.skipped.push({
646
+ path: change.path,
647
+ reason: "removed in this version but edited locally, so it was kept; delete it when you no longer need it",
648
+ });
649
+ continue;
650
+ }
578
651
  const disk = (0, node_fs_1.readFileSync)((0, node_path_1.join)(projectRoot, change.path), "utf8");
579
652
  const base = oldTree ? treeText(oldTree, change.path) : null;
580
653
  if (base === null) {
581
- // No old version to merge against — never clobber the user's edits.
582
- result.conflicts.push(change.path);
654
+ // Nothing to merge against: never clobber the application's version.
655
+ result.skipped.push({
656
+ path: change.path,
657
+ reason: `the previous version's templates do not generate it, so there is no merge base; compare it with podo diff ${change.path}`,
658
+ });
583
659
  continue;
584
660
  }
585
661
  const merge = (0, podokit_template_engine_1.threeWayMerge)(base, disk, newText, { current: "current", next: "podokit" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podosoft/podokit",
3
- "version": "1.6.1",
3
+ "version": "1.6.3",
4
4
  "description": "An opinionated Bun and Elysia full-stack TypeScript starter toolkit and CLI with SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {