@zaaxch/tailframe 2.2.0 → 3.0.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.
- package/bin/tailframe.mjs +30 -12
- package/package.json +1 -1
- package/src/architecture.mjs +15 -10
- package/src/config.mjs +59 -0
- package/src/conventions.mjs +22 -5
- package/src/flutter.mjs +111 -0
- package/src/generate.mjs +3 -2
- package/src/new.mjs +207 -52
- package/src/owned-guidance.mjs +15 -0
- package/src/owned-sources.mjs +479 -0
- package/src/service-templates.mjs +222 -19
- package/src/sync.mjs +57 -0
- package/src/ui-templates.mjs +180 -1
- package/src/validate.mjs +44 -0
|
@@ -16,6 +16,7 @@ export function serviceOperationName(moduleName, verbNoun) {
|
|
|
16
16
|
export const requestContextSource = `export interface AuthenticatedPrincipal {
|
|
17
17
|
uid: string;
|
|
18
18
|
email?: string;
|
|
19
|
+
emailVerified: boolean;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export interface RequestContext {
|
|
@@ -64,14 +65,40 @@ import type { RequestContext } from "@/core/RequestContext";
|
|
|
64
65
|
import { AppError } from "@/core/errors";
|
|
65
66
|
import { verifyFirebaseToken } from "@/platform/auth/firebase";
|
|
66
67
|
|
|
68
|
+
declare global {
|
|
69
|
+
namespace Express {
|
|
70
|
+
interface Request {
|
|
71
|
+
requestContext?: RequestContext;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validRequestId(value: string | undefined): value is string {
|
|
77
|
+
return Boolean(value && value.length <= 128 && /^[a-zA-Z0-9._:-]+$/.test(value));
|
|
78
|
+
}
|
|
79
|
+
|
|
67
80
|
export async function createRequestContext(req: Request): Promise<RequestContext> {
|
|
68
|
-
|
|
81
|
+
if (req.requestContext) return req.requestContext;
|
|
82
|
+
const headerRequestId = typeof req.headers["x-request-id"] === "string" ? req.headers["x-request-id"] : undefined;
|
|
83
|
+
const requestId = validRequestId(headerRequestId) ? headerRequestId : randomUUID();
|
|
69
84
|
const header = req.headers.authorization;
|
|
70
|
-
if (!header
|
|
85
|
+
if (!header) {
|
|
86
|
+
const context = { requestId };
|
|
87
|
+
req.requestContext = context;
|
|
88
|
+
return context;
|
|
89
|
+
}
|
|
90
|
+
if (!/^Bearer [^\\s]+$/.test(header)) {
|
|
91
|
+
throw new AppError("UNAUTHENTICATED", "Unauthorized", "unauthenticated");
|
|
92
|
+
}
|
|
71
93
|
|
|
72
94
|
try {
|
|
73
95
|
const decoded = await verifyFirebaseToken(header.slice(7));
|
|
74
|
-
|
|
96
|
+
const context = {
|
|
97
|
+
requestId,
|
|
98
|
+
principal: { uid: decoded.uid, email: decoded.email, emailVerified: decoded.email_verified === true }
|
|
99
|
+
};
|
|
100
|
+
req.requestContext = context;
|
|
101
|
+
return context;
|
|
75
102
|
} catch {
|
|
76
103
|
throw new AppError("UNAUTHENTICATED", "Unauthorized", "unauthenticated");
|
|
77
104
|
}
|
|
@@ -86,8 +113,24 @@ export function systemRequestContext(requestId = "system"): RequestContext {
|
|
|
86
113
|
import type { Request } from "express";
|
|
87
114
|
import type { RequestContext } from "@/core/RequestContext";
|
|
88
115
|
|
|
116
|
+
declare global {
|
|
117
|
+
namespace Express {
|
|
118
|
+
interface Request {
|
|
119
|
+
requestContext?: RequestContext;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validRequestId(value: string | undefined): value is string {
|
|
125
|
+
return Boolean(value && value.length <= 128 && /^[a-zA-Z0-9._:-]+$/.test(value));
|
|
126
|
+
}
|
|
127
|
+
|
|
89
128
|
export async function createRequestContext(req: Request): Promise<RequestContext> {
|
|
90
|
-
|
|
129
|
+
if (req.requestContext) return req.requestContext;
|
|
130
|
+
const headerRequestId = typeof req.headers["x-request-id"] === "string" ? req.headers["x-request-id"] : undefined;
|
|
131
|
+
const context = { requestId: validRequestId(headerRequestId) ? headerRequestId : randomUUID() };
|
|
132
|
+
req.requestContext = context;
|
|
133
|
+
return context;
|
|
91
134
|
}
|
|
92
135
|
|
|
93
136
|
export function systemRequestContext(requestId = "system"): RequestContext {
|
|
@@ -96,13 +139,16 @@ export function systemRequestContext(requestId = "system"): RequestContext {
|
|
|
96
139
|
`;
|
|
97
140
|
}
|
|
98
141
|
|
|
99
|
-
export const firebaseSource = `import { applicationDefault, getApps, initializeApp } from "firebase-admin/app";
|
|
142
|
+
export const firebaseSource = `import { applicationDefault, cert, getApps, initializeApp } from "firebase-admin/app";
|
|
100
143
|
import { getAuth, type DecodedIdToken } from "firebase-admin/auth";
|
|
101
144
|
import { env } from "@/platform/config/env";
|
|
102
145
|
|
|
103
146
|
export function verifyFirebaseToken(token: string): Promise<DecodedIdToken> {
|
|
104
147
|
if (!getApps().length) {
|
|
105
|
-
initializeApp({
|
|
148
|
+
initializeApp({
|
|
149
|
+
credential: env.firebaseServiceAccountPath ? cert(env.firebaseServiceAccountPath) : applicationDefault(),
|
|
150
|
+
projectId: env.firebaseProjectId || undefined
|
|
151
|
+
});
|
|
106
152
|
}
|
|
107
153
|
return getAuth().verifyIdToken(token);
|
|
108
154
|
}
|
|
@@ -127,6 +173,7 @@ interface RpcOperation<Input, Output> {
|
|
|
127
173
|
|
|
128
174
|
export interface RpcHandlerOptions<Input, WireInput> {
|
|
129
175
|
mapInput?: (input: WireInput) => Input;
|
|
176
|
+
fromRequest?: (req: Request) => Input;
|
|
130
177
|
status?: number;
|
|
131
178
|
}
|
|
132
179
|
|
|
@@ -139,7 +186,11 @@ export function rpcHandler<Input, Output, WireInput = Input>(
|
|
|
139
186
|
return async (req: Request, res: Response, next: NextFunction) => {
|
|
140
187
|
try {
|
|
141
188
|
const validated = await schema.validateAsync(req.body ?? {}, { abortEarly: false });
|
|
142
|
-
const input = options.
|
|
189
|
+
const input = options.fromRequest
|
|
190
|
+
? options.fromRequest(req)
|
|
191
|
+
: options.mapInput
|
|
192
|
+
? options.mapInput(validated)
|
|
193
|
+
: (validated as unknown as Input);
|
|
143
194
|
if (options.status !== undefined) res.status(options.status);
|
|
144
195
|
rpcResult(res, await operation.execute(await createRequestContext(req), input));
|
|
145
196
|
} catch (error) {
|
|
@@ -196,13 +247,152 @@ import { AppError } from "@/core/errors";
|
|
|
196
247
|
|
|
197
248
|
const MUTATING = ["POST", "PUT", "PATCH", "DELETE"];
|
|
198
249
|
|
|
199
|
-
export
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
250
|
+
export function csrfHeaderGuard(exemptPaths: readonly string[] = []): RequestHandler {
|
|
251
|
+
const exempt = new Set(exemptPaths);
|
|
252
|
+
return (req, _res, next) => {
|
|
253
|
+
if (
|
|
254
|
+
MUTATING.includes(req.method) &&
|
|
255
|
+
!exempt.has(req.path) &&
|
|
256
|
+
req.headers["x-requested-with"] !== "XMLHttpRequest"
|
|
257
|
+
) {
|
|
258
|
+
next(new AppError("CSRF_HEADER_MISSING", "CSRF header missing", "forbidden"));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
next();
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
`;
|
|
265
|
+
|
|
266
|
+
export const schemaLifecycleSource = `export interface SchemaLifecycle {
|
|
267
|
+
apply(): Promise<void>;
|
|
268
|
+
close(): Promise<void>;
|
|
269
|
+
}
|
|
270
|
+
`;
|
|
271
|
+
|
|
272
|
+
export const applySchemaCliSource = `import "reflect-metadata";
|
|
273
|
+
import { schemaLifecycle } from "@/app/schema";
|
|
274
|
+
|
|
275
|
+
async function main() {
|
|
276
|
+
try {
|
|
277
|
+
await schemaLifecycle.apply();
|
|
278
|
+
} finally {
|
|
279
|
+
await schemaLifecycle.close();
|
|
203
280
|
}
|
|
204
|
-
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
void main().catch((error) => {
|
|
284
|
+
console.error(error instanceof Error ? error.message : error);
|
|
285
|
+
process.exitCode = 1;
|
|
286
|
+
});
|
|
287
|
+
`;
|
|
288
|
+
|
|
289
|
+
export const readEnvSource = `export function requiredEnv(name: string): string {
|
|
290
|
+
const value = process.env[name];
|
|
291
|
+
if (!value) throw new Error(\`Missing required environment variable \${name}\`);
|
|
292
|
+
return value;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function integerEnv(name: string, fallback: number): number {
|
|
296
|
+
const value = process.env[name];
|
|
297
|
+
if (value === undefined || value === "") return fallback;
|
|
298
|
+
const parsed = Number(value);
|
|
299
|
+
if (!Number.isInteger(parsed)) throw new Error(\`Environment variable \${name} must be an integer\`);
|
|
300
|
+
return parsed;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function listEnv(name: string, fallback: readonly string[] = []): string[] {
|
|
304
|
+
const value = process.env[name];
|
|
305
|
+
return value
|
|
306
|
+
? value
|
|
307
|
+
.split(",")
|
|
308
|
+
.map((item) => item.trim())
|
|
309
|
+
.filter(Boolean)
|
|
310
|
+
: [...fallback];
|
|
311
|
+
}
|
|
312
|
+
`;
|
|
313
|
+
|
|
314
|
+
export const rateLimitSource = `import type { NextFunction, Request, RequestHandler, Response } from "express";
|
|
315
|
+
import { RateLimiterRedis, RateLimiterRes } from "rate-limiter-flexible";
|
|
316
|
+
import type { RedisClientType } from "redis";
|
|
317
|
+
import { AppError } from "@/core/errors";
|
|
318
|
+
import { createRequestContext } from "@/platform/http/createRequestContext";
|
|
319
|
+
|
|
320
|
+
export interface RateLimitPolicy {
|
|
321
|
+
keyPrefix: string;
|
|
322
|
+
points: number;
|
|
323
|
+
durationSeconds: number;
|
|
324
|
+
key: "identity-or-ip" | "ip";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function createRateLimiter(client: RedisClientType, policy: RateLimitPolicy): RequestHandler {
|
|
328
|
+
const limiter = new RateLimiterRedis({
|
|
329
|
+
useRedisPackage: true,
|
|
330
|
+
storeClient: client,
|
|
331
|
+
keyPrefix: policy.keyPrefix,
|
|
332
|
+
points: policy.points,
|
|
333
|
+
duration: policy.durationSeconds
|
|
334
|
+
});
|
|
335
|
+
return async (req: Request, _res: Response, next: NextFunction) => {
|
|
336
|
+
try {
|
|
337
|
+
const context = await createRequestContext(req);
|
|
338
|
+
const key =
|
|
339
|
+
policy.key === "identity-or-ip" && context.principal?.uid
|
|
340
|
+
? \`user:\${context.principal.uid}\`
|
|
341
|
+
: \`ip:\${req.ip ?? "unknown"}\`;
|
|
342
|
+
await limiter.consume(key);
|
|
343
|
+
next();
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if (error instanceof RateLimiterRes)
|
|
346
|
+
next(new AppError("RATE_LIMITED", "Too many requests", "rate_limited"));
|
|
347
|
+
else if (error instanceof AppError) next(error);
|
|
348
|
+
else next(new AppError("RATE_LIMIT_UNAVAILABLE", "Request protection unavailable", "unavailable"));
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
`;
|
|
353
|
+
|
|
354
|
+
export const mongoUsersSource = `const required = (name) => {
|
|
355
|
+
const value = process.env[name];
|
|
356
|
+
if (!value) throw new Error(\`\${name} is required\`);
|
|
357
|
+
return value;
|
|
205
358
|
};
|
|
359
|
+
|
|
360
|
+
const databaseName = required("MONGODB_DB_NAME");
|
|
361
|
+
const applicationUsername = required("MONGODB_APP_USERNAME");
|
|
362
|
+
const applicationPassword = required("MONGODB_APP_PASSWORD");
|
|
363
|
+
const backupUsername = required("MONGODB_BACKUP_USERNAME");
|
|
364
|
+
const backupPassword = required("MONGODB_BACKUP_PASSWORD");
|
|
365
|
+
const applicationCollections = required("MONGODB_APP_COLLECTIONS")
|
|
366
|
+
.split(",")
|
|
367
|
+
.map((name) => name.trim())
|
|
368
|
+
.filter(Boolean);
|
|
369
|
+
|
|
370
|
+
if (applicationCollections.length === 0) throw new Error("MONGODB_APP_COLLECTIONS must name at least one collection");
|
|
371
|
+
|
|
372
|
+
const applicationDatabase = db.getSiblingDB(databaseName);
|
|
373
|
+
applicationDatabase.createRole({
|
|
374
|
+
role: "applicationDml",
|
|
375
|
+
privileges: [
|
|
376
|
+
...applicationCollections.map((collection) => ({
|
|
377
|
+
resource: { db: databaseName, collection },
|
|
378
|
+
actions: ["find", "insert", "remove", "update", "changeStream", "listIndexes"]
|
|
379
|
+
})),
|
|
380
|
+
{ resource: { db: databaseName, collection: "" }, actions: ["listCollections"] }
|
|
381
|
+
],
|
|
382
|
+
roles: []
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
applicationDatabase.createUser({
|
|
386
|
+
user: applicationUsername,
|
|
387
|
+
pwd: applicationPassword,
|
|
388
|
+
roles: [{ role: "applicationDml", db: databaseName }]
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
db.getSiblingDB("admin").createUser({
|
|
392
|
+
user: backupUsername,
|
|
393
|
+
pwd: backupPassword,
|
|
394
|
+
roles: [{ role: "backup", db: "admin" }]
|
|
395
|
+
});
|
|
206
396
|
`;
|
|
207
397
|
|
|
208
398
|
export const getHealthSource = `import type { RequestContext } from "@/core/RequestContext";
|
|
@@ -280,6 +470,18 @@ export class MongoReadinessProbe implements ReadinessProbe {
|
|
|
280
470
|
}
|
|
281
471
|
`;
|
|
282
472
|
|
|
473
|
+
export const postgresReadinessProbeSource = `import type { Pool } from "pg";
|
|
474
|
+
import type { ReadinessProbe } from "@/modules/health/use-cases/ports/ReadinessProbe";
|
|
475
|
+
|
|
476
|
+
export class PostgresReadinessProbe implements ReadinessProbe {
|
|
477
|
+
constructor(private readonly pool: Pool) {}
|
|
478
|
+
|
|
479
|
+
async check(): Promise<void> {
|
|
480
|
+
await this.pool.query("SELECT 1");
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
`;
|
|
484
|
+
|
|
283
485
|
export const redisReadinessProbeSource = `import type { RedisClientType } from "redis";
|
|
284
486
|
import type { ReadinessProbe } from "@/modules/health/use-cases/ports/ReadinessProbe";
|
|
285
487
|
|
|
@@ -308,25 +510,26 @@ export async function closeRedis() {
|
|
|
308
510
|
}
|
|
309
511
|
`;
|
|
310
512
|
|
|
311
|
-
export function containerSource({ redis }) {
|
|
513
|
+
export function containerSource({ database = "mongo", redis }) {
|
|
514
|
+
const postgres = database === "postgres";
|
|
312
515
|
return `import { container, type InjectionToken } from "tsyringe";
|
|
313
|
-
import type { Db } from "mongodb";
|
|
516
|
+
import type { ${postgres ? "Pool" : "Db"} } from "${postgres ? "pg" : "mongodb"}";
|
|
314
517
|
${redis ? 'import type { RedisClientType } from "redis";\n' : ""}import { GetHealth } from "@/modules/health/use-cases/GetHealth";
|
|
315
518
|
import { GetReadiness } from "@/modules/health/use-cases/GetReadiness";
|
|
316
519
|
import type { ReadinessProbe } from "@/modules/health/use-cases/ports/ReadinessProbe";
|
|
317
|
-
import { MongoReadinessProbe } from "@/platform/integrations/mongodb/MongoReadinessProbe";
|
|
520
|
+
import { ${postgres ? "PostgresReadinessProbe" : "MongoReadinessProbe"} } from "@/platform/integrations/${postgres ? "postgres/PostgresReadinessProbe" : "mongodb/MongoReadinessProbe"}";
|
|
318
521
|
${redis ? 'import { RedisReadinessProbe } from "@/platform/integrations/redis/RedisReadinessProbe";\n' : ""}
|
|
319
522
|
const register = <T>(token: InjectionToken<T>, value: T) => container.registerInstance(token, value);
|
|
320
523
|
|
|
321
524
|
export interface ApplicationDependencies {
|
|
322
|
-
db: Db;
|
|
525
|
+
db: ${postgres ? "Pool" : "Db"};
|
|
323
526
|
${redis ? "\tredis: RedisClientType;\n" : ""}}
|
|
324
527
|
|
|
325
528
|
/** App-owned composition root. Domain and use-case classes remain dependency-injection-framework free. */
|
|
326
529
|
export function registerDependencies(dependencies: ApplicationDependencies) {
|
|
327
530
|
container.reset();
|
|
328
531
|
|
|
329
|
-
const readinessProbes: ReadinessProbe[] = [new MongoReadinessProbe(dependencies.db)];
|
|
532
|
+
const readinessProbes: ReadinessProbe[] = [new ${postgres ? "PostgresReadinessProbe" : "MongoReadinessProbe"}(dependencies.db)];
|
|
330
533
|
${redis ? "\treadinessProbes.push(new RedisReadinessProbe(dependencies.redis));\n" : ""}
|
|
331
534
|
register(GetHealth, new GetHealth());
|
|
332
535
|
register(GetReadiness, new GetReadiness(readinessProbes));
|
|
@@ -363,7 +566,7 @@ import { registerDependencies } from "@/app/container";
|
|
|
363
566
|
import { applicationRoutes } from "@/app/routes";
|
|
364
567
|
import { env } from "@/platform/config/env";
|
|
365
568
|
import { closeDatabase, connectDatabase } from "@/platform/database";
|
|
366
|
-
${redis ? 'import { closeRedis, connectRedis } from "@/platform/redis";\n' : ""}${csrf ? 'import {
|
|
569
|
+
${redis ? 'import { closeRedis, connectRedis } from "@/platform/redis";\n' : ""}${csrf ? 'import { csrfHeaderGuard } from "@/platform/http/csrf";\n' : ""}import { errorHandler } from "@/platform/http/errors";
|
|
367
570
|
|
|
368
571
|
export interface ApplicationOptions {
|
|
369
572
|
${ui ? "\tpublicPath?: string;\n\tproduction?: boolean;\n" : ""}}
|
|
@@ -373,7 +576,7 @@ export function createApplication(options: ApplicationOptions = {}) {
|
|
|
373
576
|
${ui ? '\tconst publicPath = options.publicPath ?? path.join(__dirname, "..", "public");\n\tconst production = options.production ?? env.nodeEnv === "production";\n' : ""} app.set("trust proxy", 1);
|
|
374
577
|
${ui ? "\tif (production) {\n\t\tapp.use(express.static(publicPath, { index: false, maxAge: \"1y\", immutable: true }));\n\t}\n" : ""} app.use(cors({ origin: env.corsOrigin }));
|
|
375
578
|
app.use(express.json());
|
|
376
|
-
app.use("/api/v1", ${csrf ? "
|
|
579
|
+
app.use("/api/v1", ${csrf ? "csrfHeaderGuard(), " : ""}applicationRoutes());
|
|
377
580
|
${ui ? '\tif (production) {\n\t\tapp.get(/^(?!\\/api(?:\\/|$)).*/, (_req, res) => res.sendFile(path.join(publicPath, "index.html")));\n\t}\n' : ""} app.use(errorHandler);
|
|
378
581
|
return app;
|
|
379
582
|
}
|
package/src/sync.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadConfig } from "./config.mjs";
|
|
4
|
+
import { readFlutterPackageName } from "./flutter.mjs";
|
|
5
|
+
import { OWNED_GUIDANCE_END, OWNED_GUIDANCE_START, ownedGuidance } from "./owned-guidance.mjs";
|
|
6
|
+
import { ownedSources } from "./owned-sources.mjs";
|
|
7
|
+
|
|
8
|
+
export function runSync(rootArgument, mode, runningVersion) {
|
|
9
|
+
const loaded = loadConfig(rootArgument);
|
|
10
|
+
if (loaded.errors.length) return { changed: [], errors: loaded.errors };
|
|
11
|
+
if (loaded.config.contractVersion !== runningVersion) {
|
|
12
|
+
return { changed: [], errors: [`tailframe.json requires ${loaded.config.contractVersion}, but this CLI is ${runningVersion}`] };
|
|
13
|
+
}
|
|
14
|
+
const changed = [];
|
|
15
|
+
const errors = [];
|
|
16
|
+
const sourceOptions = {};
|
|
17
|
+
if (loaded.config.kind === "flutter") {
|
|
18
|
+
const packageName = readFlutterPackageName(loaded.root);
|
|
19
|
+
if (!packageName) return { changed, errors: ["pubspec.yaml must declare a package name"] };
|
|
20
|
+
sourceOptions.flutterPackageName = packageName;
|
|
21
|
+
}
|
|
22
|
+
const guidanceFile = path.join(loaded.root, "AGENTS.md");
|
|
23
|
+
const guidance = ownedGuidance(loaded.config);
|
|
24
|
+
const existingGuidance = fs.existsSync(guidanceFile) ? fs.readFileSync(guidanceFile, "utf8") : "";
|
|
25
|
+
const start = existingGuidance.indexOf(OWNED_GUIDANCE_START);
|
|
26
|
+
const end = existingGuidance.indexOf(OWNED_GUIDANCE_END);
|
|
27
|
+
const validSentinels = start >= 0 && end > start;
|
|
28
|
+
const currentSection = validSentinels
|
|
29
|
+
? existingGuidance.slice(start, end + OWNED_GUIDANCE_END.length)
|
|
30
|
+
: undefined;
|
|
31
|
+
if (currentSection !== guidance) {
|
|
32
|
+
if (mode === "check") errors.push("AGENTS.md Tailframe-owned section differs from the canonical guidance");
|
|
33
|
+
else {
|
|
34
|
+
let appendix = validSentinels
|
|
35
|
+
? `${existingGuidance.slice(0, start)}${existingGuidance.slice(end + OWNED_GUIDANCE_END.length)}`.trim()
|
|
36
|
+
: existingGuidance.trim();
|
|
37
|
+
appendix = appendix.replace(/^## Product-specific appendix\s*/u, "");
|
|
38
|
+
const next = appendix
|
|
39
|
+
? `${guidance}\n\n## Product-specific appendix\n\n${appendix}\n`
|
|
40
|
+
: `${guidance}\n`;
|
|
41
|
+
fs.writeFileSync(guidanceFile, next);
|
|
42
|
+
changed.push("AGENTS.md");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const [relative, expected] of Object.entries(ownedSources(loaded.config, sourceOptions))) {
|
|
46
|
+
const absolute = path.join(loaded.root, relative);
|
|
47
|
+
const actual = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : undefined;
|
|
48
|
+
if (actual === expected) continue;
|
|
49
|
+
if (mode === "check") errors.push(`${relative} differs from the Tailframe ${runningVersion} canonical source`);
|
|
50
|
+
else {
|
|
51
|
+
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
|
52
|
+
fs.writeFileSync(absolute, expected);
|
|
53
|
+
changed.push(relative);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { changed, errors };
|
|
57
|
+
}
|
package/src/ui-templates.mjs
CHANGED
|
@@ -37,6 +37,7 @@ export interface RpcErrorEnvelope {
|
|
|
37
37
|
export const uiHttpSource = `import axios, { type AxiosError } from "axios";
|
|
38
38
|
import type { ServiceError } from "@/core/errors";
|
|
39
39
|
import type { RpcErrorEnvelope, RpcResponse } from "@/core/rpc";
|
|
40
|
+
import { apiBaseUrl } from "@/platform/config";
|
|
40
41
|
|
|
41
42
|
const failureKind = (status?: number): ServiceError["kind"] => {
|
|
42
43
|
if (!status) return "network";
|
|
@@ -52,7 +53,7 @@ const failureKind = (status?: number): ServiceError["kind"] => {
|
|
|
52
53
|
};
|
|
53
54
|
|
|
54
55
|
export const http = axios.create({
|
|
55
|
-
baseURL:
|
|
56
|
+
baseURL: apiBaseUrl,
|
|
56
57
|
headers: {
|
|
57
58
|
"Content-Type": "application/json",
|
|
58
59
|
Accept: "application/json",
|
|
@@ -98,6 +99,184 @@ export async function rpc<T>(operation: string, payload: unknown = {}): Promise<
|
|
|
98
99
|
}
|
|
99
100
|
`;
|
|
100
101
|
|
|
102
|
+
export const uiErrorMessagesSource = `import { isServiceError } from "@/core/errors";
|
|
103
|
+
|
|
104
|
+
export type ErrorMessageMap = Record<string, string>;
|
|
105
|
+
|
|
106
|
+
export function getErrorMessage(
|
|
107
|
+
error: unknown,
|
|
108
|
+
fallback = "Please try again.",
|
|
109
|
+
messages: ErrorMessageMap = {}
|
|
110
|
+
): string {
|
|
111
|
+
if (isServiceError(error)) return messages[error.code] ?? fallback;
|
|
112
|
+
return fallback;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const hasErrorCode = (error: unknown, code: string) => isServiceError(error) && error.code === code;
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
export const notificationStoreSource = `import { defineStore } from "pinia";
|
|
119
|
+
import { computed, ref } from "vue";
|
|
120
|
+
|
|
121
|
+
export type NotificationKind = "info" | "success" | "warning" | "error";
|
|
122
|
+
|
|
123
|
+
export interface NotificationInput {
|
|
124
|
+
message: string;
|
|
125
|
+
kind?: NotificationKind;
|
|
126
|
+
durationMs?: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface AppNotification {
|
|
130
|
+
id: number;
|
|
131
|
+
message: string;
|
|
132
|
+
kind: NotificationKind;
|
|
133
|
+
durationMs: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const useNotificationStore = defineStore("notification", () => {
|
|
137
|
+
const queue = ref<AppNotification[]>([]);
|
|
138
|
+
const active = computed<AppNotification | undefined>(() => queue.value[0]);
|
|
139
|
+
let nextId = 1;
|
|
140
|
+
|
|
141
|
+
function notify({ message, kind = "info", durationMs = 4000 }: NotificationInput) {
|
|
142
|
+
const id = nextId++;
|
|
143
|
+
queue.value.push({ id, message, kind, durationMs });
|
|
144
|
+
return id;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function dismiss(id: number) {
|
|
148
|
+
queue.value = queue.value.filter((notification) => notification.id !== id);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function clear() {
|
|
152
|
+
queue.value = [];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { queue, active, notify, dismiss, clear };
|
|
156
|
+
});
|
|
157
|
+
`;
|
|
158
|
+
|
|
159
|
+
export const notificationHostSource = `<template>
|
|
160
|
+
<div class="tailframe-notifications" aria-live="polite" aria-atomic="true">
|
|
161
|
+
<div v-if="active" :class="['tailframe-notification', \`is-\${active.kind}\`]" role="status">
|
|
162
|
+
<span>{{ active.message }}</span>
|
|
163
|
+
<button type="button" aria-label="Dismiss notification" @click="notifications.dismiss(active.id)">×</button>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
</template>
|
|
167
|
+
|
|
168
|
+
<script setup lang="ts">
|
|
169
|
+
import { computed, onBeforeUnmount, watch } from "vue";
|
|
170
|
+
import { useNotificationStore } from "@/app/stores/notification.store";
|
|
171
|
+
|
|
172
|
+
const notifications = useNotificationStore();
|
|
173
|
+
const active = computed(() => notifications.active);
|
|
174
|
+
let timer: number | undefined;
|
|
175
|
+
|
|
176
|
+
watch(
|
|
177
|
+
active,
|
|
178
|
+
(notification) => {
|
|
179
|
+
if (timer !== undefined) window.clearTimeout(timer);
|
|
180
|
+
timer = undefined;
|
|
181
|
+
if (notification && notification.durationMs > 0) {
|
|
182
|
+
timer = window.setTimeout(() => notifications.dismiss(notification.id), notification.durationMs);
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
{ immediate: true }
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
onBeforeUnmount(() => {
|
|
189
|
+
if (timer !== undefined) window.clearTimeout(timer);
|
|
190
|
+
});
|
|
191
|
+
</script>
|
|
192
|
+
|
|
193
|
+
<style scoped>
|
|
194
|
+
.tailframe-notifications {
|
|
195
|
+
position: fixed;
|
|
196
|
+
inset: auto 1rem 1rem;
|
|
197
|
+
z-index: 10000;
|
|
198
|
+
display: grid;
|
|
199
|
+
justify-items: end;
|
|
200
|
+
pointer-events: none;
|
|
201
|
+
}
|
|
202
|
+
.tailframe-notification {
|
|
203
|
+
display: flex;
|
|
204
|
+
max-width: min(32rem, calc(100vw - 2rem));
|
|
205
|
+
align-items: center;
|
|
206
|
+
gap: 0.75rem;
|
|
207
|
+
border-radius: 0.75rem;
|
|
208
|
+
background: #1e293b;
|
|
209
|
+
color: white;
|
|
210
|
+
padding: 0.75rem 1rem;
|
|
211
|
+
box-shadow: 0 12px 30px rgb(15 23 42 / 0.28);
|
|
212
|
+
pointer-events: auto;
|
|
213
|
+
}
|
|
214
|
+
.tailframe-notification.is-success {
|
|
215
|
+
background: #166534;
|
|
216
|
+
}
|
|
217
|
+
.tailframe-notification.is-warning {
|
|
218
|
+
background: #92400e;
|
|
219
|
+
}
|
|
220
|
+
.tailframe-notification.is-error {
|
|
221
|
+
background: #991b1b;
|
|
222
|
+
}
|
|
223
|
+
.tailframe-notification button {
|
|
224
|
+
border: 0;
|
|
225
|
+
background: transparent;
|
|
226
|
+
color: inherit;
|
|
227
|
+
cursor: pointer;
|
|
228
|
+
font: inherit;
|
|
229
|
+
font-size: 1.25rem;
|
|
230
|
+
line-height: 1;
|
|
231
|
+
}
|
|
232
|
+
</style>
|
|
233
|
+
`;
|
|
234
|
+
|
|
235
|
+
export const extensionAuthStoreSource = `import { auth, signInWithGoogleAccessToken } from "@/platform/firebase";
|
|
236
|
+
import { invalidateGoogleAccessToken, requestGoogleAccessToken, signOutGoogleSession } from "@/platform/extensionAuth";
|
|
237
|
+
import { onIdTokenChanged, signOut, type User } from "firebase/auth/web-extension";
|
|
238
|
+
import { defineStore } from "pinia";
|
|
239
|
+
import { ref } from "vue";
|
|
240
|
+
|
|
241
|
+
export const useAuthStore = defineStore("auth", () => {
|
|
242
|
+
const firebaseUser = ref<User | null>(null);
|
|
243
|
+
const ready = ref(false);
|
|
244
|
+
|
|
245
|
+
onIdTokenChanged(auth, (value) => {
|
|
246
|
+
firebaseUser.value = value;
|
|
247
|
+
ready.value = true;
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
async function signInWithGoogle(interactive = true) {
|
|
251
|
+
let accessToken = await requestGoogleAccessToken(interactive);
|
|
252
|
+
try {
|
|
253
|
+
return await signInWithGoogleAccessToken(accessToken);
|
|
254
|
+
} catch (reason: unknown) {
|
|
255
|
+
if (
|
|
256
|
+
!interactive ||
|
|
257
|
+
typeof reason !== "object" ||
|
|
258
|
+
reason === null ||
|
|
259
|
+
!("code" in reason) ||
|
|
260
|
+
reason.code !== "auth/invalid-credential"
|
|
261
|
+
)
|
|
262
|
+
throw reason;
|
|
263
|
+
await invalidateGoogleAccessToken(accessToken);
|
|
264
|
+
accessToken = await requestGoogleAccessToken(true);
|
|
265
|
+
return signInWithGoogleAccessToken(accessToken);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function logout() {
|
|
270
|
+
await signOut(auth);
|
|
271
|
+
await signOutGoogleSession();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const getIdToken = () => firebaseUser.value?.getIdToken();
|
|
275
|
+
|
|
276
|
+
return { firebaseUser, ready, signInWithGoogle, logout, getIdToken };
|
|
277
|
+
});
|
|
278
|
+
`;
|
|
279
|
+
|
|
101
280
|
export const configureHttpSource = `import type { Pinia } from "pinia";
|
|
102
281
|
import router from "@/app/router";
|
|
103
282
|
import { useAuthStore } from "@/app/stores/auth.store";
|
package/src/validate.mjs
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import { validateArchitecture } from "./architecture.mjs";
|
|
2
4
|
import { validateConventions } from "./conventions.mjs";
|
|
5
|
+
import { loadConfig } from "./config.mjs";
|
|
3
6
|
import { isExcepted, loadExceptions } from "./exceptions.mjs";
|
|
7
|
+
import { validateFlutter } from "./flutter.mjs";
|
|
8
|
+
import { runSync } from "./sync.mjs";
|
|
4
9
|
|
|
5
10
|
const NON_EXCEPTABLE_RULES = new Set(["S8", "S9", "U5", "U6", "U7"]);
|
|
6
11
|
|
|
7
12
|
export function runValidate(root, kind) {
|
|
13
|
+
if (kind === "flutter") return validateFlutter(root);
|
|
8
14
|
const structural = validateArchitecture(root, kind);
|
|
9
15
|
if (structural.some((error) => error.startsWith("Architecture root") || error.startsWith("Architecture kind"))) {
|
|
10
16
|
return structural;
|
|
@@ -15,3 +21,41 @@ export function runValidate(root, kind) {
|
|
|
15
21
|
.map((violation) => `${violation.rule}: ${violation.message}`);
|
|
16
22
|
return [...structural, ...conventions];
|
|
17
23
|
}
|
|
24
|
+
|
|
25
|
+
function validateVersionMetadata(root, runningVersion) {
|
|
26
|
+
const errors = [];
|
|
27
|
+
const packageFile = path.join(root, "package.json");
|
|
28
|
+
const lockFile = path.join(root, "package-lock.json");
|
|
29
|
+
if (!fs.existsSync(packageFile)) return ["package.json is required to pin the Tailframe build-time contract"];
|
|
30
|
+
let manifest;
|
|
31
|
+
try { manifest = JSON.parse(fs.readFileSync(packageFile, "utf8")); }
|
|
32
|
+
catch { return ["package.json is not valid JSON"]; }
|
|
33
|
+
if (manifest.devDependencies?.["@zaaxch/tailframe"] !== runningVersion) {
|
|
34
|
+
errors.push(`package.json must pin @zaaxch/tailframe exactly to ${runningVersion}`);
|
|
35
|
+
}
|
|
36
|
+
if (!fs.existsSync(lockFile)) return [...errors, "package-lock.json is required to lock the Tailframe contract"];
|
|
37
|
+
try {
|
|
38
|
+
const lock = JSON.parse(fs.readFileSync(lockFile, "utf8"));
|
|
39
|
+
const rootPin = lock.packages?.[""]?.devDependencies?.["@zaaxch/tailframe"];
|
|
40
|
+
const installed = lock.packages?.["node_modules/@zaaxch/tailframe"]?.version;
|
|
41
|
+
if (rootPin !== runningVersion) errors.push(`package-lock.json root must pin @zaaxch/tailframe exactly to ${runningVersion}`);
|
|
42
|
+
if (installed !== runningVersion) errors.push(`package-lock.json resolves @zaaxch/tailframe ${installed ?? "nowhere"}, expected ${runningVersion}`);
|
|
43
|
+
} catch {
|
|
44
|
+
errors.push("package-lock.json is not valid JSON");
|
|
45
|
+
}
|
|
46
|
+
return errors;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function runConfiguredValidate(rootArgument, runningVersion) {
|
|
50
|
+
const loaded = loadConfig(rootArgument);
|
|
51
|
+
if (loaded.errors.length) return loaded.errors;
|
|
52
|
+
const errors = [];
|
|
53
|
+
if (loaded.config.contractVersion !== runningVersion) {
|
|
54
|
+
errors.push(`tailframe.json requires ${loaded.config.contractVersion}, but this CLI is ${runningVersion}`);
|
|
55
|
+
}
|
|
56
|
+
errors.push(...validateVersionMetadata(loaded.root, runningVersion));
|
|
57
|
+
if (errors.length) return errors;
|
|
58
|
+
errors.push(...runValidate(loaded.root, loaded.config.kind));
|
|
59
|
+
errors.push(...runSync(loaded.root, "check", runningVersion).errors);
|
|
60
|
+
return errors;
|
|
61
|
+
}
|