@lpdjs/firestore-repo-service 2.6.11 → 2.6.13

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.
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- 'use strict';var fs=require('fs'),path=require('path'),process$1=require('process'),promises=require('readline/promises');var O={skipSegments:["useCases","useCase","use-cases","use-case"],casing:"preserve"};function M(e,t=O){let s=new Set(t.skipSegments.map(o=>o.toLowerCase()));return "/"+e.split("/").filter(Boolean).filter(o=>!s.has(o.toLowerCase())).map(o=>t.casing==="kebab"?oe(o):o).join("/")}function oe(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").replace(/[\s_]+/g,"-").toLowerCase()}function W(e,t,s){let r=H(e),o=H(t),i=0;for(;i<r.length&&i<o.length&&r[i]===o[i];)i++;let c=r.length-i,n=o.slice(i),d=(n[n.length-1]??"").replace(/\.[mc]?[tj]sx?$/i,""),f=s===""?d:`${d}${s}`;return n[n.length-1]=f,(c===0?"./":"../".repeat(c))+n.join("/")}function H(e){return e.replace(/\\/g,"/").replace(/\/+$/,"").split("/").filter((s,r)=>!(r===0&&s===""))}var ae="/**\n * AUTO-GENERATED by `@lpdjs/firestore-repo-service` Hono codegen.\n * Do not edit by hand \u2014 re-run `hono:gen` after adding / removing route files.\n */\n";function J(e,t){let s=path.dirname(t.outFile);fs.mkdirSync(s,{recursive:true});let r=t.banner??ae,o=(t.now??new Date).toISOString(),i=t.importExtension,c=[],n=[],a=[];e.forEach((f,p)=>{let P=W(s,f.absPath,i),y=M(f.relDir,t.derive);c.push(`import mod${p} from ${JSON.stringify(P)};`),n.push(` { __derivedPath: ${JSON.stringify(y)}, mod: mod${p} },`),a.push({source:f.relPath,url:y});});let d=`${r}// Generated at ${o} \u2014 ${e.length} route file${e.length===1?"":"s"}.
2
+ 'use strict';var fs=require('fs'),path=require('path'),process$1=require('process'),promises=require('readline/promises');var _={skipSegments:["useCases","useCase","use-cases","use-case"],casing:"preserve"};function J(e,r=_){let s=new Set(r.skipSegments.map(o=>o.toLowerCase()));return "/"+e.split("/").filter(Boolean).filter(o=>!s.has(o.toLowerCase())).map(o=>r.casing==="kebab"?ne(o):o).join("/")}function ne(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").replace(/[\s_]+/g,"-").toLowerCase()}function Z(e,r,s){let t=W(e),o=W(r),i=0;for(;i<t.length&&i<o.length&&t[i]===o[i];)i++;let a=t.length-i,n=o.slice(i),f=(n[n.length-1]??"").replace(/\.[mc]?[tj]sx?$/i,""),g=s===""?f:`${f}${s}`;return n[n.length-1]=g,(a===0?"./":"../".repeat(a))+n.join("/")}function W(e){return e.replace(/\\/g,"/").replace(/\/+$/,"").split("/").filter((s,t)=>!(t===0&&s===""))}var pe="/**\n * AUTO-GENERATED by `@lpdjs/firestore-repo-service` Hono codegen.\n * Do not edit by hand \u2014 re-run `hono:gen` after adding / removing route files.\n */\n";function K(e,r){let s=path.dirname(r.outFile);fs.mkdirSync(s,{recursive:true});let t=r.banner??pe,o=(r.now??new Date).toISOString(),i=r.importExtension,a=[],n=[],c=[];e.forEach((g,p)=>{let b=Z(s,g.absPath,i),h=J(g.relDir,r.derive);a.push(`import mod${p} from ${JSON.stringify(b)};`),n.push(` { __derivedPath: ${JSON.stringify(h)}, mod: mod${p} },`),c.push({source:g.relPath,url:h});});let f=`${t}// Generated at ${o} \u2014 ${e.length} route file${e.length===1?"":"s"}.
3
3
 
4
4
  import type { AnyRouteDef, RouteModuleDefault } from "@lpdjs/firestore-repo-service/servers/hono";
5
5
 
6
- `+c.join(`
7
- `)+(c.length?`
6
+ `+a.join(`
7
+ `)+(a.length?`
8
8
 
9
9
  `:`
10
10
  `)+`const __defs: { __derivedPath: string; mod: RouteModuleDefault }[] = [
@@ -16,8 +16,8 @@ export const routes: AnyRouteDef[] = __defs.flatMap(({ __derivedPath, mod }) =>
16
16
  const list = Array.isArray(mod) ? mod : [mod];
17
17
  return list.map((route) => ({ ...route, path: route.path ?? __derivedPath }));
18
18
  });
19
- `;return fs.writeFileSync(t.outFile,d,"utf8"),{outFile:t.outFile,routeCount:e.length,derivedPaths:a}}var E={routesFile:"routes.ts",excludeSegments:["node_modules","__generated__","tests","__tests__",".turbo","dist","build",".next"]};function K(e,t=E){let s=[];return V(e,e,t,s),s.sort((r,o)=>r.relPath.localeCompare(o.relPath)),s}function V(e,t,s,r){let o;try{o=fs.readdirSync(t);}catch{return}for(let i of o){if(s.excludeSegments.includes(i))continue;let c=path.join(t,i),n;try{n=fs.statSync(c);}catch{continue}if(n.isDirectory())V(e,c,s,r);else if(n.isFile()&&i===s.routesFile){let a=path.relative(e,c).split(path.sep).join("/"),d=a.replace(/\/?[^/]+$/,"");r.push({absPath:c,relPath:a,relDir:d});}}}var ee=".frsrc.json";function T(e=process.cwd()){let t=path.resolve(e,ee);if(!fs.existsSync(t))return {};try{return JSON.parse(fs.readFileSync(t,"utf8"))}catch{return {}}}function he(e,t=process.cwd()){let s=path.resolve(t,ee),o={...T(t),...e};return fs.writeFileSync(s,`${JSON.stringify(o,null,2)}
20
- `,"utf8"),s}function ve(e){let[t,...s]=e,r={};for(let o=0;o<s.length;o++){let i=s[o];if(!i.startsWith("--"))continue;let c=i.slice(2),n=s[o+1];n&&!n.startsWith("--")?(r[c]=n,o++):r[c]=true;}return {command:t??"help",flags:r}}function Z(){console.log(`frs \u2014 Hono file-based codegen
19
+ `;return fs.writeFileSync(r.outFile,f,"utf8"),{outFile:r.outFile,routeCount:e.length,derivedPaths:c}}var I={routesFile:"routes.ts",excludeSegments:["node_modules","__generated__","tests","__tests__",".turbo","dist","build",".next"]};function V(e,r=I){let s=[];return Y(e,e,r,s),s.sort((t,o)=>t.relPath.localeCompare(o.relPath)),s}function Y(e,r,s,t){let o;try{o=fs.readdirSync(r);}catch{return}for(let i of o){if(s.excludeSegments.includes(i))continue;let a=path.join(r,i),n;try{n=fs.statSync(a);}catch{continue}if(n.isDirectory())Y(e,a,s,t);else if(n.isFile()&&i===s.routesFile){let c=path.relative(e,a).split(path.sep).join("/"),f=c.replace(/\/?[^/]+$/,"");t.push({absPath:a,relPath:c,relDir:f});}}}var se=".frsrc.json";function z(e=process.cwd()){let r=path.resolve(e,se);if(!fs.existsSync(r))return {};try{return JSON.parse(fs.readFileSync(r,"utf8"))}catch{return {}}}function ve(e,r=process.cwd()){let s=path.resolve(r,se),o={...z(r),...e};return fs.writeFileSync(s,`${JSON.stringify(o,null,2)}
20
+ `,"utf8"),s}function ye(e){let[r,...s]=e,t={};for(let o=0;o<s.length;o++){let i=s[o];if(!i.startsWith("--"))continue;let a=i.slice(2),n=s[o+1];n&&!n.startsWith("--")?(t[a]=n,o++):t[a]=true;}return {command:r??"help",flags:t}}function X(){console.log(`frs \u2014 Hono file-based codegen
21
21
 
22
22
  Usage:
23
23
  frs init [flags]
@@ -83,21 +83,20 @@ Examples:
83
83
  frs new createPost --domain posts --method post
84
84
  frs new listPosts --domain posts --method get --api v1
85
85
  frs add service postRepo
86
- `);}function Q(e){if(typeof e=="string")return e.split(",").map(t=>t.trim()).filter(Boolean)}function m(e){return typeof e=="string"?e:void 0}function te(e){if(e||!process$1.stdin.isTTY)return {ask:async(s,r)=>r??"",askChoice:async(s,r,o)=>o??"",askBool:async(s,r)=>r,close:()=>{}};let t=promises.createInterface({input:process$1.stdin,output:process$1.stdout});return {async ask(s,r){let o=r?` (${r})`:"";return (await t.question(`? ${s}${o} \u203A `)).trim()||r||""},async askChoice(s,r,o){let i=` [${r.join("/")}${o?`, default: ${o}`:""}]`;for(;;){let c=(await t.question(`? ${s}${i} \u203A `)).trim().toLowerCase();if(!c&&o)return o;if(r.includes(c))return c;console.log(` invalid choice \u2014 pick one of: ${r.join(", ")}`);}},async askBool(s,r){let o=` (${r?"Y/n":"y/N"})`,i=(await t.question(`? ${s}${o} \u203A `)).trim().toLowerCase();return i?i==="y"||i==="yes"||i==="true":r},close:()=>t.close()}}async function ye(e){let t=T(),s=m(e.root)??t.root;s||(console.error("[frs] --root is required (or run `frs init` to write it to .frsrc.json)"),process.exit(2));let r=path.resolve(process.cwd(),s);fs.existsSync(r)||(console.error(`[frs] root not found: ${r}`),process.exit(2));let o=m(e.out)??t.out??"__generated__/routes.ts",i=Q(e.skip)??O.skipSegments,c=m(e.casing)==="kebab"?"kebab":O.casing,n={skipSegments:i,casing:c},a=m(e.ext)??".js",d=Q(e.exclude)??E.excludeSegments,f=m(e["routes-file"])??E.routesFile,P=K(r,{routesFile:f,excludeSegments:d});P.length===0&&console.warn(`[frs] no "${f}" files found under ${r} \u2014 generated an empty manifest.`);let y=J(P,{outFile:path.resolve(r,o),derive:n,importExtension:a});if(!e.silent){console.log(`[frs] wrote ${y.outFile} (${y.routeCount} route${y.routeCount===1?"":"s"})`);for(let{source:S,url:g}of y.derivedPaths)console.log(` ${g.padEnd(48)} \u2190 ${S}`);}}async function $e(e,t){let s=t.yes===true,r=te(s),o=T();try{let i=e&&!e.startsWith("--")?e:void 0;i||(i=(await r.ask("Route name (e.g. createPost)")).trim(),i||(console.error("[frs] route name is required"),process.exit(2)));let c=m(t.domain);c||(c=(await r.ask("Domain name (e.g. posts)")).trim(),c||(console.error("[frs] --domain is required"),process.exit(2)));let n=m(t.root)??o.root??"src/domains",a=m(t.method)?.toLowerCase();a||(a=await r.askChoice("HTTP method",["get","post","put","patch","delete"],"post")),["get","post","put","patch","delete"].includes(a)||(console.error(`[frs] invalid --method: ${a}`),process.exit(2));let d=m(t.api);if(!d){let v=o.apis?.[0]??"v1";d=(await r.ask("API tag",v)).trim()||v;}let f=m(t["usecase-folder"])??o.useCaseFolder??"useCases",p=t["with-usecase"]===void 0?s?!0:await r.askBool("Scaffold useCase.ts?",!0):t["with-usecase"]!==!1,P=t["with-test"]===void 0?s||!p?p:await r.askBool("Scaffold useCase.test.ts (Vitest)?",!0):t["with-test"]!==!1,y=t.force===!0,S=path.resolve(process.cwd(),n),g=path.resolve(S,c,f,i),A=path.resolve(g,"routes.ts"),$=`${c}.${i}.useCase`,R=path.resolve(g,`${$}.ts`),k=path.resolve(g,`${$}.test.ts`);fs.mkdirSync(g,{recursive:!0});let F=v=>v.charAt(0).toUpperCase()+v.slice(1),w=`${F(c)}${F(i)}UseCase`,D="@lpdjs/firestore-repo-service/servers/hono",h=Se(S,g),b=a==="get"?"// GET \u2192 lu depuis les query params":`// ${a.toUpperCase()} \u2192 lu depuis le body JSON`,L=`/**
87
- * ${w} \u2014 pure business logic, no HTTP awareness.
86
+ `);}function ee(e){if(typeof e=="string")return e.split(",").map(r=>r.trim()).filter(Boolean)}function m(e){return typeof e=="string"?e:void 0}function te(e){if(e||!process$1.stdin.isTTY)return {ask:async(s,t)=>t??"",askChoice:async(s,t,o)=>o??"",askBool:async(s,t)=>t,close:()=>{}};let r=promises.createInterface({input:process$1.stdin,output:process$1.stdout});return {async ask(s,t){let o=t?` (${t})`:"";return (await r.question(`? ${s}${o} \u203A `)).trim()||t||""},async askChoice(s,t,o){let i=` [${t.join("/")}${o?`, default: ${o}`:""}]`;for(;;){let a=(await r.question(`? ${s}${i} \u203A `)).trim().toLowerCase();if(!a&&o)return o;if(t.includes(a))return a;console.log(` invalid choice \u2014 pick one of: ${t.join(", ")}`);}},async askBool(s,t){let o=` (${t?"Y/n":"y/N"})`,i=(await r.question(`? ${s}${o} \u203A `)).trim().toLowerCase();return i?i==="y"||i==="yes"||i==="true":t},close:()=>r.close()}}async function xe(e){let r=z(),s=m(e.root)??r.root;s||(console.error("[frs] --root is required (or run `frs init` to write it to .frsrc.json)"),process.exit(2));let t=path.resolve(process.cwd(),s);fs.existsSync(t)||(console.error(`[frs] root not found: ${t}`),process.exit(2));let o=m(e.out)??r.out??"__generated__/routes.ts",i=ee(e.skip)??_.skipSegments,a=m(e.casing)==="kebab"?"kebab":_.casing,n={skipSegments:i,casing:a},c=m(e.ext)??".js",f=ee(e.exclude)??I.excludeSegments,g=m(e["routes-file"])??I.routesFile,b=V(t,{routesFile:g,excludeSegments:f});b.length===0&&console.warn(`[frs] no "${g}" files found under ${t} \u2014 generated an empty manifest.`);let h=K(b,{outFile:path.resolve(t,o),derive:n,importExtension:c});if(!e.silent){console.log(`[frs] wrote ${h.outFile} (${h.routeCount} route${h.routeCount===1?"":"s"})`);for(let{source:P,url:y}of h.derivedPaths)console.log(` ${y.padEnd(48)} \u2190 ${P}`);}}async function we(e,r){let s=r.yes===true,t=te(s),o=z();try{let i=e&&!e.startsWith("--")?e:void 0;i||(i=(await t.ask("Route name (e.g. createPost)")).trim(),i||(console.error("[frs] route name is required"),process.exit(2)));let a=m(r.domain);a||(a=(await t.ask("Domain name (e.g. posts)")).trim(),a||(console.error("[frs] --domain is required"),process.exit(2)));let n=m(r.root)??o.root??"src/domains",c=m(r.method)?.toLowerCase();c||(c=await t.askChoice("HTTP method",["get","post","put","patch","delete"],"post")),["get","post","put","patch","delete"].includes(c)||(console.error(`[frs] invalid --method: ${c}`),process.exit(2));let f=m(r.api);if(!f){let v=o.apis?.[0]??"v1";f=(await t.ask("API tag",v)).trim()||v;}let g=m(r["usecase-folder"])??o.useCaseFolder??"useCases",p=r["with-usecase"]===void 0?s?!0:await t.askBool("Scaffold useCase.ts?",!0):r["with-usecase"]!==!1,b=r["with-test"]===void 0?s||!p?p:await t.askBool("Scaffold useCase.test.ts (Vitest)?",!0):r["with-test"]!==!1,h=r.force===!0,P=path.resolve(process.cwd(),n),y=path.resolve(P,a,g,i),F=path.resolve(y,"routes.ts"),x=`${a}.${i}.useCase`,A=path.resolve(y,`${x}.ts`),C=path.resolve(y,`${x}.test.ts`);fs.mkdirSync(y,{recursive:!0});let k=v=>v.charAt(0).toUpperCase()+v.slice(1),$=`${k(a)}${k(i)}UseCase`,D="@lpdjs/firestore-repo-service/servers/hono",w=Ce(P,y),E=Se(P,y),L=c==="get"?"// GET \u2192 lu depuis les query params":`// ${c.toUpperCase()} \u2192 lu depuis le body JSON`,U=`/**
87
+ * ${$} \u2014 pure business logic, no HTTP awareness.
88
88
  *
89
89
  * Owns its Zod \`input\` / \`output\` schemas (declared as \`static\` members, the
90
90
  * single source of truth shared with \`routes.ts\`) and runs the logic in
91
- * \`execute\`. The shared \`services\` container is injected by the \`UseCase\` base
92
- * class via the constructor.
91
+ * \`execute\`. Extends \`AppUseCase\`, so \`this.services\`, \`this.logger\` and
92
+ * \`this.error\` are all available.
93
93
  */
94
94
 
95
95
  import { z } from "zod";
96
- import { UseCase } from "${D}";
97
- import type { Services } from "${h}";
96
+ import { AppUseCase } from "${E}";
98
97
 
99
98
  const input = z.object({
100
- ${b}
99
+ ${L}
101
100
  example: z.string(),
102
101
  });
103
102
 
@@ -105,41 +104,45 @@ const output = z.object({
105
104
  id: z.string(),
106
105
  });
107
106
 
108
- export class ${w} extends UseCase<typeof input, typeof output, Services> {
107
+ export class ${$} extends AppUseCase<typeof input, typeof output> {
109
108
  static readonly input = input;
110
109
  static readonly output = output;
111
110
 
112
111
  async execute(
113
112
  payload: z.infer<typeof input>,
114
113
  ): Promise<z.infer<typeof output>> {
114
+ this.logger.info("${$} called", { example: payload.example });
115
+ // Guard example \u2014 mapped to HTTP by the AppErrorHandler:
116
+ // if (!payload.example) throw this.error.badRequest("example is required");
117
+
115
118
  // TODO: implement using \`this.services\`
116
119
  return { id: payload.example };
117
120
  }
118
121
  }
119
- `,l=a==="get"?`
120
- source: "query",`:"",_=p?`import { ${w} } from "./${$}.js";
121
- `:"",U=m(t["apis-import"])??Pe(S,g),se=p?`import { defineRoutes } from "${D}";
122
- import { useCaseRoute } from "${U}";
123
- ${_}
122
+ `,H=c==="get"?`
123
+ source: "query",`:"",N=p?`import { ${$} } from "./${x}.js";
124
+ `:"",O=m(r["apis-import"])??Pe(P,y),u=p?`import { defineRoutes } from "${D}";
125
+ import { useCaseRoute } from "${O}";
126
+ ${N}
124
127
  export default defineRoutes([
125
- useCaseRoute(${w}, {
126
- api: "${d}",
127
- method: "${a}",${l}
128
+ useCaseRoute(${$}, {
129
+ api: "${f}",
130
+ method: "${c}",${H}
128
131
  summary: "TODO: ${i}",
129
- tags: ["${c}"],
132
+ tags: ["${a}"],
130
133
  }),
131
134
  ]);
132
135
  `:`import { z } from "zod";
133
136
  import { defineRoutes } from "${D}";
134
- import { defineRoute } from "${U}";
137
+ import { defineRoute } from "${O}";
135
138
 
136
139
  export default defineRoutes([
137
140
  defineRoute({
138
- api: "${d}",
139
- method: "${a}",
141
+ api: "${f}",
142
+ method: "${c}",
140
143
 
141
144
  input: z.object({
142
- ${b}
145
+ ${L}
143
146
  example: z.string(),
144
147
  }),
145
148
 
@@ -148,7 +151,7 @@ export default defineRoutes([
148
151
  }),
149
152
 
150
153
  summary: "TODO: ${i}",
151
- tags: ["${c}"],
154
+ tags: ["${a}"],
152
155
 
153
156
  handler: async ({ input }) => {
154
157
  // TODO: business logic
@@ -156,38 +159,40 @@ export default defineRoutes([
156
159
  },
157
160
  }),
158
161
  ]);
159
- `,z=[],G=[],N=(v,re)=>{if(fs.existsSync(v)&&!y){G.push(v);return}fs.writeFileSync(v,re,"utf8"),z.push(v);};if(N(A,se),p&&N(R,L),p&&P){let v=`import { describe, it, expect } from "vitest";
160
- import type { Services } from "${h}";
161
- import { ${w} } from "./${$}.js";
162
+ `,j=[],G=[],M=(v,oe)=>{if(fs.existsSync(v)&&!h){G.push(v);return}fs.writeFileSync(v,oe,"utf8"),j.push(v);};if(M(F,u),p&&M(A,U),p&&b){let v=`import { describe, it, expect } from "vitest";
163
+ import type { Services } from "${w}";
164
+ import { ${$} } from "./${x}.js";
162
165
 
163
- describe("${w}", () => {
166
+ describe("${$}", () => {
164
167
  it("returns a response shaped like the output schema", async () => {
165
168
  // TODO: replace with real mocks for the services the useCase consumes.
166
169
  const services = {} as unknown as Services;
167
170
 
168
- const useCase = new ${w}(services);
171
+ const useCase = new ${$}(services);
169
172
  const result = await useCase.execute({ example: "hello" });
170
173
  expect(result).toMatchObject({ id: expect.any(String) });
171
174
  });
172
175
 
173
176
  // TODO: add error-path tests, repository mocks, etc.
174
177
  });
175
- `;N(k,v);}for(let v of z)console.log(`[frs] wrote ${v}`);for(let v of G)console.log(`[frs] skipped ${v} (use --force to overwrite)`);console.log(`
176
- [frs] reminder: run "frs gen --root ${n}" to refresh the manifest.`);}finally{r.close();}}async function we(e){let t=e.yes===true,s=te(t);try{let r=e.force===!0,o=m(e.root);o||(o=(await s.ask("Domain root","src/domains")).trim()||"src/domains");let i=m(e["apis-file"]);i||(i=(await s.ask("apis.ts location","src/apis.ts")).trim()||"src/apis.ts");let c=m(e["services-file"]);if(!c){let l=i.replace(/apis\.ts$/,"services.ts")||"src/services.ts";c=(await s.ask("services.ts location",l)).trim()||l;}let n=m(e.apis);n||(n=(await s.ask("API tags (comma-separated)","v1")).trim()||"v1");let a=n.split(",").map(l=>l.trim()).filter(Boolean);a.length===0&&(console.error("[frs] at least one API tag is required"),process.exit(2));let d=m(e["base-path"]),f=path.resolve(process.cwd(),o),p=path.resolve(process.cwd(),i),P=path.resolve(process.cwd(),c),y=path.resolve(f,"__generated__"),S=path.resolve(y,"routes.ts"),g=[],A=[],$=(l,_)=>{if(fs.mkdirSync(path.dirname(l),{recursive:!0}),fs.existsSync(l)&&!r){A.push(l);return}fs.writeFileSync(l,_,"utf8"),g.push(l);},R=a.map(l=>{let _=d??`/${l}`;return ` ${l}: {
177
- basePath: "${_}",
178
+ `;M(C,v);}for(let v of j)console.log(`[frs] wrote ${v}`);for(let v of G)console.log(`[frs] skipped ${v} (use --force to overwrite)`);console.log(`
179
+ [frs] reminder: run "frs gen --root ${n}" to refresh the manifest.`);}finally{t.close();}}async function $e(e){let r=e.yes===true,s=te(r);try{let t=e.force===!0,o=m(e.root);o||(o=(await s.ask("Domain root","src/domains")).trim()||"src/domains");let i=m(e["apis-file"]);i||(i=(await s.ask("apis.ts location","src/apis.ts")).trim()||"src/apis.ts");let a=m(e["services-file"]);if(!a){let u=i.replace(/apis\.ts$/,"services.ts")||"src/services.ts";a=(await s.ask("services.ts location",u)).trim()||u;}let n=m(e.apis);n||(n=(await s.ask("API tags (comma-separated)","v1")).trim()||"v1");let c=n.split(",").map(u=>u.trim()).filter(Boolean);c.length===0&&(console.error("[frs] at least one API tag is required"),process.exit(2));let f=m(e["base-path"]),g=path.resolve(process.cwd(),o),p=path.resolve(process.cwd(),i),b=path.resolve(process.cwd(),a),h=path.resolve(path.dirname(p),"app-error.ts"),P=path.resolve(path.dirname(p),"base-usecase.ts"),y=path.resolve(g,"__generated__"),F=path.resolve(y,"routes.ts"),x=[],A=[],C=(u,j)=>{if(fs.mkdirSync(path.dirname(u),{recursive:!0}),fs.existsSync(u)&&!t){A.push(u);return}fs.writeFileSync(u,j,"utf8"),x.push(u);},k=c.map(u=>{let j=f??`/${u}`;return ` ${u}: {
180
+ basePath: "${j}",
178
181
  openapi: {
179
- info: { title: "${l.toUpperCase()} API", version: "1.0.0", description: "" },
182
+ info: { title: "${u.toUpperCase()} API", version: "1.0.0", description: "" },
180
183
  },
181
- // Built-in error mapping. Extend BaseErrorHandler to map your own
182
- // domain errors, then swap it in here (per API).
183
- errorHandler: new BaseErrorHandler(),
184
- // Structured console logger. Extend BaseLogger (override \`write\`) to
185
- // route to your sink, then swap it in here (per API).
186
- logger: new BaseLogger(),
184
+ // Maps your AppError \u2192 HTTP (extend it in app-error.ts). \`gcpLogs\` adds a
185
+ // dev-only deep link to the matching GCP log in the error response.
186
+ errorHandler: new AppErrorHandler({
187
+ gcpLogs: { enabled: process.env["NODE_ENV"] !== "production" },
188
+ }),
189
+ // Shared structured logger (same instance exposed as \`this.logger\`).
190
+ logger: appLogger,
187
191
  verbose: process.env["NODE_ENV"] !== "production",
188
192
  },`}).join(`
189
- `),k=`import { BaseErrorHandler, BaseLogger, createApiRegistry } from "@lpdjs/firestore-repo-service/servers/hono";
190
- import { services } from "${I(path.dirname(p),P)}";
193
+ `),$=`import { createApiRegistry } from "@lpdjs/firestore-repo-service/servers/hono";
194
+ import { AppErrorHandler, appLogger } from "${R(path.dirname(p),h)}";
195
+ import { services } from "${R(path.dirname(p),b)}";
191
196
 
192
197
  /**
193
198
  * Single source of truth for every API exposed by this project.
@@ -196,12 +201,12 @@ import { services } from "${I(path.dirname(p),P)}";
196
201
  * Per-API resources injected into every handler / interceptor / error-handler
197
202
  * context (override them per API above):
198
203
  * - \`services\` \u2014 shared DI container (\`services.ctx.c\` = current request);
199
- * - \`errorHandler\` \u2014 maps thrown errors \u2192 HTTP (extend \`BaseErrorHandler\`);
200
- * - \`logger\` \u2014 structured logging (extend \`BaseLogger\`).
204
+ * - \`errorHandler\` \u2014 maps thrown \`AppError\`s \u2192 HTTP (see app-error.ts);
205
+ * - \`logger\` \u2014 structured logging (also \`this.logger\` in useCases).
201
206
  */
202
207
  export const apis = createApiRegistry(
203
208
  {
204
- ${R}
209
+ ${k}
205
210
  },
206
211
  { services },
207
212
  );
@@ -209,7 +214,7 @@ ${R}
209
214
  /** Typed helpers used inside every route file. */
210
215
  export const defineRoute = apis.defineRoute;
211
216
  export const useCaseRoute = apis.useCaseRoute;
212
- `;$(p,k),$(P,`import { createServices } from "@lpdjs/firestore-repo-service/servers/hono";
217
+ `;C(p,$),C(b,`import { createServices } from "@lpdjs/firestore-repo-service/servers/hono";
213
218
 
214
219
  /**
215
220
  * Global DI container \u2014 declare every singleton (repositories, SDK
@@ -242,43 +247,230 @@ export const services = createServices({
242
247
 
243
248
  /** Convenience type \u2014 \`function fn(svc: Services) { ... }\`. */
244
249
  export type Services = typeof services;
245
- `);let w=`// AUTO-GENERATED by frs \u2014 do not edit.
250
+ `),C(h,`import {
251
+ BaseErrorHandler,
252
+ BaseLogger,
253
+ type ErrorHandlerContext,
254
+ type LogSeverity,
255
+ } from "@lpdjs/firestore-repo-service/servers/hono";
256
+
257
+ /** Supported locales \u2014 the single source of truth (runtime + type). */
258
+ export const LOCALES = ["en", "fr"] as const;
259
+
260
+ /** A supported locale, derived from {@link LOCALES}. */
261
+ export type Locale = (typeof LOCALES)[number];
262
+
263
+ /** Localized message \u2014 one string per supported locale. */
264
+ export type LocalizedMessage = Record<Locale, string>;
265
+
266
+ /**
267
+ * Domain error \u2014 pure business semantics, zero HTTP awareness. Thrown anywhere
268
+ * in useCases / handlers; the \`AppErrorHandler\` below maps it to an HTTP
269
+ * response. Carries a localized message; add your own factory methods as your
270
+ * domain grows.
271
+ */
272
+ export class AppError extends Error {
273
+ readonly statusCode: number;
274
+ readonly userFacing: boolean;
275
+ readonly errorId: string;
276
+ readonly localizedMessage: LocalizedMessage;
277
+
278
+ private constructor(
279
+ localizedMessage: LocalizedMessage,
280
+ statusCode: number,
281
+ userFacing = false,
282
+ ) {
283
+ super(localizedMessage.en);
284
+ this.name = "AppError";
285
+ this.statusCode = statusCode;
286
+ this.userFacing = userFacing;
287
+ this.localizedMessage = localizedMessage;
288
+ this.errorId = Math.random().toString(36).slice(2, 12);
289
+ }
290
+
291
+ /** Business message shown directly to the user \u2014 HTTP 412. */
292
+ static userMessage(message: LocalizedMessage): AppError {
293
+ return new AppError(message, 412, true);
294
+ }
295
+
296
+ /** Resource not found \u2014 HTTP 404. */
297
+ static notFound(resource?: string): AppError {
298
+ return new AppError(
299
+ {
300
+ en: \`\${resource ?? "Resource"} not found\`,
301
+ fr: \`\${resource ?? "Ressource"} introuvable\`,
302
+ },
303
+ 404,
304
+ );
305
+ }
306
+
307
+ /** Malformed request / invalid data \u2014 HTTP 400. */
308
+ static badRequest(detail?: string): AppError {
309
+ return new AppError(
310
+ {
311
+ en: \`Bad request: \${detail ?? "invalid parameters"}\`,
312
+ fr: \`Requ\xEAte invalide : \${detail ?? "param\xE8tres incorrects"}\`,
313
+ },
314
+ 400,
315
+ );
316
+ }
317
+
318
+ /** Generic fallback message for non-user-facing errors. */
319
+ static default(locale: Locale): string {
320
+ return locale === "fr" ? "Une erreur est survenue" : "An error occurred";
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Pick the response locale from the \`Accept-Language\` header.
326
+ *
327
+ * Parses the comma-separated, q-weighted list (e.g.
328
+ * \`fr-FR,fr;q=0.9,en;q=0.8\`), keeps the supported locales, and returns the one
329
+ * with the highest quality. Falls back to \`"en"\`.
330
+ */
331
+ function pickLocale(c: {
332
+ req: { header(name: string): string | undefined };
333
+ }): Locale {
334
+ const header = c.req.header("accept-language");
335
+ if (!header) return "en";
336
+
337
+ const ranked = header
338
+ .split(",")
339
+ .map((part) => {
340
+ const [tag = "", ...params] = part.trim().split(";");
341
+ const qParam = params.find((p) => p.trim().startsWith("q="));
342
+ const q = qParam ? Number.parseFloat(qParam.split("=")[1] ?? "") : 1;
343
+ return {
344
+ lang: tag.trim().toLowerCase().split("-")[0] ?? "",
345
+ quality: Number.isFinite(q) ? q : 1,
346
+ };
347
+ })
348
+ .filter((x): x is { lang: Locale; quality: number } =>
349
+ (LOCALES as readonly string[]).includes(x.lang),
350
+ )
351
+ .sort((a, b) => b.quality - a.quality);
352
+
353
+ return ranked[0]?.lang ?? "en";
354
+ }
355
+
356
+ /**
357
+ * Project logger \u2014 extends \`BaseLogger\` and overrides the single \`write\` hook.
358
+ * Swap \`console\` for \`firebase-functions/v2\` \`logger\` in real code.
359
+ */
360
+ export class AppLogger extends BaseLogger {
361
+ protected override write(
362
+ severity: LogSeverity,
363
+ payload: Record<string, unknown>,
364
+ ): void {
365
+ // eslint-disable-next-line no-console
366
+ console.log(JSON.stringify({ severity, ...payload }));
367
+ }
368
+ }
369
+
370
+ /** Shared logger instance (per-API \`logger\` + \`this.logger\` in useCases). */
371
+ export const appLogger = new AppLogger();
372
+
373
+ /**
374
+ * Project error strategy \u2014 extends \`BaseErrorHandler\`: \`mapError\` localizes our
375
+ * \`AppError\` (user-facing aware, with an optional GCP logs deep link),
376
+ * \`logError\` routes through the logger, and unmatched errors fall back to the
377
+ * built-in mapping via \`super\`. Wired per API in apis.ts.
378
+ */
379
+ export class AppErrorHandler extends BaseErrorHandler {
380
+ protected override mapError({
381
+ error,
382
+ c,
383
+ }: ErrorHandlerContext): Response | null {
384
+ if (!(error instanceof AppError)) return null; // \u2192 built-in mapping
385
+
386
+ const locale = pickLocale(c);
387
+ const logsUrl = this.gcpLogsUrl(error.errorId); // undefined when disabled
388
+ return c.json(
389
+ {
390
+ // expose the localized message only when it is meant for the user
391
+ error: error.userFacing
392
+ ? error.localizedMessage[locale]
393
+ : AppError.default(locale),
394
+ errorId: error.errorId,
395
+ ...(logsUrl ? { logsUrl } : {}),
396
+ },
397
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
398
+ error.statusCode as any,
399
+ );
400
+ }
401
+
402
+ protected override logError({ error, logger }: ErrorHandlerContext): void {
403
+ const log = logger ?? appLogger;
404
+ if (error instanceof AppError && error.statusCode < 500) {
405
+ log.warn(error.message);
406
+ } else {
407
+ log.error(error);
408
+ }
409
+ }
410
+ }
411
+ `);let E=`import { UseCase } from "@lpdjs/firestore-repo-service/servers/hono";
412
+ import type { z } from "zod";
413
+ import { AppError, appLogger } from "${R(path.dirname(P),h)}";
414
+ import type { Services } from "${R(path.dirname(P),b)}";
415
+
416
+ /**
417
+ * Project base class for every useCase \u2014 extends the package's {@link UseCase}
418
+ * (which injects \`this.services\` via the constructor) and adds two shared
419
+ * ergonomics: \`this.logger\` (structured logger) and \`this.error\` (the
420
+ * {@link AppError} factory, mapped to HTTP by the \`AppErrorHandler\`).
421
+ * Subclasses still declare \`static input\` / \`static output\`.
422
+ */
423
+ export abstract class AppUseCase<
424
+ TInput extends z.ZodTypeAny = z.ZodTypeAny,
425
+ TOutput extends z.ZodTypeAny = z.ZodTypeAny,
426
+ > extends UseCase<TInput, TOutput, Services> {
427
+ /** Shared structured logger instance (same one injected per-API). */
428
+ protected readonly logger = appLogger;
429
+
430
+ /**
431
+ * Domain error factory \u2014 \`throw this.error.notFound(...)\` /
432
+ * \`this.error.badRequest(...)\` / \`this.error.userMessage(...)\`. The thrown
433
+ * {@link AppError} is mapped to an HTTP response by the \`AppErrorHandler\`.
434
+ */
435
+ protected readonly error = AppError;
436
+ }
437
+ `;C(P,E);let L=`// AUTO-GENERATED by frs \u2014 do not edit.
246
438
  // Run \`frs gen --root ${o}\` to refresh.
247
439
 
248
440
  import type { AnyRouteDef } from "@lpdjs/firestore-repo-service/servers/hono";
249
441
 
250
442
  export const routes: AnyRouteDef[] = [];
251
- `;$(S,w);let D=he({root:o,apisFile:i,servicesFile:c,apis:a});g.push(D);let h=I(path.dirname(p),p),b=I(path.dirname(p),S),L=a.length===1?`export const { ${a[0]} } = apis.toFunctions(routes, onRequest, {`:`export const { ${a.join(", ")} } = apis.toFunctions(routes, onRequest, {`;for(let l of g)console.log(`[frs] wrote ${l}`);for(let l of A)console.log(`[frs] skipped ${l} (use --force to overwrite)`);console.log(`
443
+ `;C(F,L);let U=ve({root:o,apisFile:i,servicesFile:a,apis:c});x.push(U);let H=R(path.dirname(p),p),N=R(path.dirname(p),F),O=c.length===1?`export const { ${c[0]} } = apis.toFunctions(routes, onRequest, {`:`export const { ${c.join(", ")} } = apis.toFunctions(routes, onRequest, {`;for(let u of x)console.log(`[frs] wrote ${u}`);for(let u of A)console.log(`[frs] skipped ${u} (use --force to overwrite)`);console.log(`
252
444
  Next steps:
253
445
 
254
446
  1. Wire the registry in your Functions entrypoint (e.g. src/index.ts):
255
447
 
256
448
  import { onRequest } from "firebase-functions/v2/https";
257
- import { apis } from "${h}";
258
- import { routes } from "${b}";
449
+ import { apis } from "${H}";
450
+ import { routes } from "${N}";
259
451
 
260
- ${L}
452
+ ${O}
261
453
  defaults: { region: "us-central1", invoker: "public" },
262
454
  });
263
455
 
264
456
  2. Scaffold a first route:
265
457
 
266
- frs new createPost --domain posts --method post --api ${a[0]}
458
+ frs new createPost --domain posts --method post --api ${c[0]}
267
459
 
268
460
  3. Refresh the manifest before each build:
269
461
 
270
462
  frs gen --root ${o}
271
- `);}finally{s.close();}}function I(e,t){let s=path.relative(e,t).replace(/\\/g,"/");return s=s.replace(/\.ts$/,".js"),s.startsWith(".")||(s=`./${s}`),s}async function xe(e,t,s){e!=="service"&&(console.error(`[frs] unknown "add" target: ${e??"(missing)"} \u2014 supported: service`),process.exit(2)),t||(console.error("[frs] service name is required: frs add service <name>"),process.exit(2));let r=s.force===true,o=T(),c=[m(s["services-file"]),o.servicesFile,"src/services.ts","services.ts"].filter(h=>typeof h=="string"&&h.length>0),n;for(let h of c){let b=path.resolve(process.cwd(),h);if(fs.existsSync(b)){n=b;break}}if(!n){let h=c.map(b=>path.resolve(process.cwd(),b)).join(`
463
+ `);}finally{s.close();}}function R(e,r){let s=path.relative(e,r).replace(/\\/g,"/");return s=s.replace(/\.ts$/,".js"),s.startsWith(".")||(s=`./${s}`),s}async function be(e,r,s){e!=="service"&&(console.error(`[frs] unknown "add" target: ${e??"(missing)"} \u2014 supported: service`),process.exit(2)),r||(console.error("[frs] service name is required: frs add service <name>"),process.exit(2));let t=s.force===true,o=z(),a=[m(s["services-file"]),o.servicesFile,"src/services.ts","services.ts"].filter(w=>typeof w=="string"&&w.length>0),n;for(let w of a){let E=path.resolve(process.cwd(),w);if(fs.existsSync(E)){n=E;break}}if(!n){let w=a.map(E=>path.resolve(process.cwd(),E)).join(`
272
464
  `);console.error(`[frs] services file not found. Tried:
273
- ${h}
274
- Run \`frs init\` first or pass --services-file <path>.`),process.exit(2);}let a=m(s["services-dir"])??o.servicesDir??path.resolve(path.dirname(n),"services"),d=path.resolve(process.cwd(),a);fs.mkdirSync(d,{recursive:true});let f=`${t.charAt(0).toUpperCase()}${t.slice(1)}Service`,p=path.resolve(d,`${t}.ts`),P=`import type { RequestContext } from "@lpdjs/firestore-repo-service/servers/hono";
465
+ ${w}
466
+ Run \`frs init\` first or pass --services-file <path>.`),process.exit(2);}let c=m(s["services-dir"])??o.servicesDir??path.resolve(path.dirname(n),"services"),f=path.resolve(process.cwd(),c);fs.mkdirSync(f,{recursive:true});let g=`${r.charAt(0).toUpperCase()}${r.slice(1)}Service`,p=path.resolve(f,`${r}.ts`),b=`import type { RequestContext } from "@lpdjs/firestore-repo-service/servers/hono";
275
467
 
276
468
  /**
277
- * ${f} \u2014 generated by \`frs add service ${t}\`.
469
+ * ${g} \u2014 generated by \`frs add service ${r}\`.
278
470
  *
279
471
  * Registered with a **factory** in \`services.ts\` so dependencies are
280
472
  * destructured at registration time. Add new constructor parameters here
281
- * and update the factory line (\`({ ctx, otherSvc }) => new ${f}(ctx, otherSvc)\`)
473
+ * and update the factory line (\`({ ctx, otherSvc }) => new ${g}(ctx, otherSvc)\`)
282
474
  * \u2014 TypeScript will tell you when something is missing.
283
475
  *
284
476
  * Async resources (DB connections, SDK clients) should stay lazy-loaded
@@ -292,17 +484,17 @@ Next steps:
292
484
  * }
293
485
  * \`\`\`
294
486
  */
295
- export class ${f} {
487
+ export class ${g} {
296
488
  // eslint-disable-next-line @typescript-eslint/no-useless-constructor
297
489
  constructor(private readonly ctx: RequestContext) {}
298
490
 
299
491
  hello(): string {
300
- return \`hello from ${t} \u2014 user=\${this.ctx.maybeC?.get("user")?.id ?? "anonymous"}\`;
492
+ return \`hello from ${r} \u2014 user=\${this.ctx.maybeC?.get("user")?.id ?? "anonymous"}\`;
301
493
  }
302
494
  }
303
- `;fs.existsSync(p)&&!r?console.log(`[frs] skipped ${p} (use --force to overwrite)`):(fs.writeFileSync(p,P,"utf8"),console.log(`[frs] wrote ${p}`));let y=fs.readFileSync(n,"utf8"),S=I(path.dirname(n),p),g=`import { ${f} } from "${S}";`,A=` ${t}: ({ ctx }) => new ${f}(ctx),`;if(y.includes(g)){console.log(`[frs] services.ts already registers "${t}" \u2014 skipping.`);return}let $=y.split(`
304
- `),R=-1;for(let h=0;h<$.length;h++)/^import\s/.test($[h])&&(R=h);R>=0?$.splice(R+1,0,g):$.unshift(g);let k=$.join(`
305
- `),F=k.match(/createServices\s*\(\s*\{/);if(!F){console.error(`[frs] could not find \`createServices({\` in ${n} \u2014 register "${t}" manually.`);return}let w=F.index+F[0].length,D=k.slice(0,w)+`
306
- `+A+k.slice(w);fs.writeFileSync(n,D,"utf8"),console.log(`[frs] updated ${n} (+ ${t})`);}function Pe(e,t){let s=["apis.ts","apis.js","api.ts","api.js"],r=[e,path.dirname(e),path.dirname(path.dirname(e))];for(let o of r)for(let i of s){let c=path.resolve(o,i);if(fs.existsSync(c)){let n=path.relative(t,c).replace(/\\/g,"/");return n=n.replace(/\.ts$/,".js").replace(/\.js$/,".js"),n.startsWith(".")||(n=`./${n}`),n}}return "../../../../apis.js"}function Se(e,t){let s=["services.ts","services.js"],r=[e,path.dirname(e),path.dirname(path.dirname(e))];for(let o of r)for(let i of s){let c=path.resolve(o,i);if(fs.existsSync(c)){let n=path.relative(t,c).replace(/\\/g,"/");return n=n.replace(/\.ts$/,".js"),n.startsWith(".")||(n=`./${n}`),n}}return "../../../../services.js"}async function be(){let e=process.argv.slice(2),{command:t,flags:s}=ve(e);switch(t){case "init":await we(s);return;case "gen":await ye(s);return;case "new":await $e(e[1],s);return;case "add":await xe(e[1],e[2],s);return;case "help":case "--help":case "-h":Z();return;default:console.error(`[frs] unknown command: ${t}
307
- `),Z(),process.exit(2);}}be().catch(e=>{console.error(e),process.exit(1);});//# sourceMappingURL=cli.cjs.map
495
+ `;fs.existsSync(p)&&!t?console.log(`[frs] skipped ${p} (use --force to overwrite)`):(fs.writeFileSync(p,b,"utf8"),console.log(`[frs] wrote ${p}`));let h=fs.readFileSync(n,"utf8"),P=R(path.dirname(n),p),y=`import { ${g} } from "${P}";`,F=` ${r}: ({ ctx }) => new ${g}(ctx),`;if(h.includes(y)){console.log(`[frs] services.ts already registers "${r}" \u2014 skipping.`);return}let x=h.split(`
496
+ `),A=-1;for(let w=0;w<x.length;w++)/^import\s/.test(x[w])&&(A=w);A>=0?x.splice(A+1,0,y):x.unshift(y);let C=x.join(`
497
+ `),k=C.match(/createServices\s*\(\s*\{/);if(!k){console.error(`[frs] could not find \`createServices({\` in ${n} \u2014 register "${r}" manually.`);return}let $=k.index+k[0].length,D=C.slice(0,$)+`
498
+ `+F+C.slice($);fs.writeFileSync(n,D,"utf8"),console.log(`[frs] updated ${n} (+ ${r})`);}function Pe(e,r){let s=["apis.ts","apis.js","api.ts","api.js"],t=[e,path.dirname(e),path.dirname(path.dirname(e))];for(let o of t)for(let i of s){let a=path.resolve(o,i);if(fs.existsSync(a)){let n=path.relative(r,a).replace(/\\/g,"/");return n=n.replace(/\.ts$/,".js").replace(/\.js$/,".js"),n.startsWith(".")||(n=`./${n}`),n}}return "../../../../apis.js"}function Ce(e,r){let s=["services.ts","services.js"],t=[e,path.dirname(e),path.dirname(path.dirname(e))];for(let o of t)for(let i of s){let a=path.resolve(o,i);if(fs.existsSync(a)){let n=path.relative(r,a).replace(/\\/g,"/");return n=n.replace(/\.ts$/,".js"),n.startsWith(".")||(n=`./${n}`),n}}return "../../../../services.js"}function Se(e,r){let s=["base-usecase.ts","base-usecase.js"],t=[e,path.dirname(e),path.dirname(path.dirname(e))];for(let o of t)for(let i of s){let a=path.resolve(o,i);if(fs.existsSync(a)){let n=path.relative(r,a).replace(/\\/g,"/");return n=n.replace(/\.ts$/,".js"),n.startsWith(".")||(n=`./${n}`),n}}return "../../../../base-usecase.js"}async function Ae(){let e=process.argv.slice(2),{command:r,flags:s}=ye(e);switch(r){case "init":await $e(s);return;case "gen":await xe(s);return;case "new":await we(e[1],s);return;case "add":await be(e[1],e[2],s);return;case "help":case "--help":case "-h":X();return;default:console.error(`[frs] unknown command: ${r}
499
+ `),X(),process.exit(2);}}Ae().catch(e=>{console.error(e),process.exit(1);});//# sourceMappingURL=cli.cjs.map
308
500
  //# sourceMappingURL=cli.cjs.map