@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.
- package/dist/deploy-checks.d.ts +16 -5
- package/dist/deploy-checks.js +14 -6
- package/dist/deploy-render.js +28 -3
- package/dist/deploy.d.ts +9 -0
- package/dist/deploy.js +83 -17
- package/dist/index.js +9 -2
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +50 -4
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +1 -1
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +3 -1
- package/dist/templates/fullstack/tests/README.md +11 -0
- package/dist/templates/fullstack/tests/helpers/paths.ts +13 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +96 -7
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +5 -5
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +61 -12
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +3 -3
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +24 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +609 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +178 -42
- package/dist/templates/modules/audit-log/module.manifest.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +13 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.spec.ts +67 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.ts +54 -0
- package/dist/templates/modules/auth/module.manifest.json +1 -0
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +208 -62
- package/dist/update.d.ts +25 -5
- package/dist/update.js +86 -10
- 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
|
|
13
|
-
// per RATE_LIMIT_TTL seconds, per client)
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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(
|
|
107
|
-
test.skip(probe.status() >= 500, "
|
|
108
|
-
const remaining = (response: APIResponse): number =>
|
|
109
|
-
const
|
|
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(
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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(
|
|
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
|
|
137
|
-
//
|
|
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
|
-
|
|
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
|
|
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(
|
|
171
|
-
test.skip(probe.status() >= 500, "
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
/**
|
|
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
|
|
145
|
-
* version
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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
|
|
490
|
-
* version
|
|
491
|
-
*
|
|
492
|
-
*
|
|
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
|
|
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 (
|
|
534
|
-
|
|
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"
|
|
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
|
-
//
|
|
582
|
-
result.
|
|
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.
|
|
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": {
|