From 0de3592e9f998795d349721afc98517a40b0f19d Mon Sep 17 00:00:00 2001 From: Anmol1696 Date: Tue, 5 May 2026 16:11:05 +0800 Subject: [PATCH 01/10] feat(packages): extract fn-types; make fn-runtime/fn-app publishable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First wave of the portable-functions toolkit (Starship-style split). Establishes a single source-of-truth types package that the rest of the toolkit (fn-generator, fn-client, fn-cli — landing in later waves) will depend on. - New @constructive-io/fn-types@0.1.0 — runtime contract types (FunctionHandler, FunctionContext, ServerOptions), HandlerManifest, FnRegistry/FnRegistryEntry, FnConfig + defineConfig() helper for typed fn.config.ts files. No logic, no shell-out. - @constructive-io/fn-runtime@1.2.0 — drops private:true; now depends on fn-types and re-exports the runtime types from there. Source files import from @constructive-io/fn-types instead of local ./types.ts (which is removed). - @constructive-io/knative-job-fn@1.6.0 — drops private:true; ready to publish (rename to @constructive-io/fn-app deferred to a later wave to avoid churning internal imports). Verified: pnpm generate && pnpm install && pnpm build passes for all three existing functions (example, simple-email, send-email-link). --- packages/fn-app/README.md | 19 +++++++ packages/fn-app/package.json | 14 +++-- packages/fn-runtime/README.md | 36 +++++++++++++ packages/fn-runtime/package.json | 14 +++-- packages/fn-runtime/src/context.ts | 2 +- packages/fn-runtime/src/index.ts | 7 ++- packages/fn-runtime/src/server.ts | 2 +- packages/fn-types/README.md | 37 +++++++++++++ packages/fn-types/package.json | 29 ++++++++++ packages/fn-types/src/config.ts | 53 +++++++++++++++++++ packages/fn-types/src/index.ts | 21 ++++++++ packages/fn-types/src/manifest.ts | 12 +++++ packages/fn-types/src/registry.ts | 14 +++++ .../src/types.ts => fn-types/src/runtime.ts} | 8 ++- packages/fn-types/tsconfig.json | 10 ++++ pnpm-lock.yaml | 27 ++++++++++ 16 files changed, 295 insertions(+), 10 deletions(-) create mode 100644 packages/fn-runtime/README.md create mode 100644 packages/fn-types/README.md create mode 100644 packages/fn-types/package.json create mode 100644 packages/fn-types/src/config.ts create mode 100644 packages/fn-types/src/index.ts create mode 100644 packages/fn-types/src/manifest.ts create mode 100644 packages/fn-types/src/registry.ts rename packages/{fn-runtime/src/types.ts => fn-types/src/runtime.ts} (72%) create mode 100644 packages/fn-types/tsconfig.json diff --git a/packages/fn-app/README.md b/packages/fn-app/README.md index 9b49319..b95436b 100644 --- a/packages/fn-app/README.md +++ b/packages/fn-app/README.md @@ -1 +1,20 @@ # @constructive-io/knative-job-fn + +Express app factory for Knative-style job functions. Provides: + +- One POST `/` endpoint with JSON body parsing and request logging. +- Per-request job context lifted from `X-Worker-Id`, `X-Job-Id`, `X-Database-Id`, `X-Callback-Url` headers. +- An on-finish hook that POSTs `{status: "success" | "error"}` to the callback URL when set. +- Centralized error middleware that emits an error callback and a 200 with `{message}` body (Knative requires 2xx for delivery acknowledgment). + +Most users should depend on `@constructive-io/fn-runtime`, which wraps this with a typed handler and GraphQL clients. Use this package directly only if you need raw Express middleware control. + +```ts +import { createJobApp } from '@constructive-io/knative-job-fn'; + +const app = createJobApp(); +app.post('/', (req, res) => { + res.json({ ok: true }); +}); +app.listen(8080); +``` diff --git a/packages/fn-app/package.json b/packages/fn-app/package.json index a983f7f..07028f5 100644 --- a/packages/fn-app/package.json +++ b/packages/fn-app/package.json @@ -1,10 +1,18 @@ { "name": "@constructive-io/knative-job-fn", - "version": "1.5.2", - "description": "Express app factory for Knative job functions with callback handling", + "version": "1.6.0", + "description": "Express app factory for Knative job functions with callback handling.", "author": "Constructive ", - "private": true, + "license": "SEE LICENSE IN LICENSE", "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "publishConfig": { + "access": "public" + }, "scripts": { "build": "makage build", "build:dev": "makage build --dev", diff --git a/packages/fn-runtime/README.md b/packages/fn-runtime/README.md new file mode 100644 index 0000000..5941189 --- /dev/null +++ b/packages/fn-runtime/README.md @@ -0,0 +1,36 @@ +# @constructive-io/fn-runtime + +Runtime contract for Constructive Functions: wraps a typed handler in an Express app with a built-in GraphQL client, structured logger, and Knative job-callback support. + +This is the package handler authors import directly. The `@constructive-io/fn-cli` toolchain stamps out function packages that depend on this runtime. + +## Usage + +```ts +import { createFunctionServer } from '@constructive-io/fn-runtime'; +import type { FunctionHandler } from '@constructive-io/fn-types'; + +type Payload = { to: string; subject: string }; + +const handler: FunctionHandler = async (params, ctx) => { + ctx.log.info('processing', { to: params.to }); + return { ok: true }; +}; + +const app = createFunctionServer(handler, { name: 'my-function' }); + +if (require.main === module) { + app.listen(Number(process.env.PORT || 8080)); +} + +export default app; +``` + +## What you get on `ctx` + +- `ctx.job` — `{ jobId, workerId, databaseId }` lifted from `X-*` request headers. +- `ctx.client` / `ctx.meta` — GraphQL clients, configured via `GRAPHQL_URL` / `META_GRAPHQL_URL` env. Lazily created when `databaseId` is present; throw a helpful error if used without configuration. +- `ctx.log` — structured logger (`@pgpmjs/logger`). +- `ctx.env` — `process.env` reference for convenience. + +The HTTP contract (one POST `/`, callback-on-finish) and error handling come from `@constructive-io/knative-job-fn` underneath, so the function plays nicely with the Constructive job-service. diff --git a/packages/fn-runtime/package.json b/packages/fn-runtime/package.json index e90692e..5220b1e 100644 --- a/packages/fn-runtime/package.json +++ b/packages/fn-runtime/package.json @@ -1,16 +1,24 @@ { "name": "@constructive-io/fn-runtime", - "version": "1.1.0", - "description": "Runtime for Constructive functions — wraps handler in Express app with GraphQL client, logging, and job callback support", + "version": "1.2.0", + "description": "Runtime for Constructive functions — wraps a handler in an Express app with GraphQL client, logging, and job callback support.", "author": "Constructive ", - "private": true, + "license": "SEE LICENSE IN LICENSE", "main": "dist/index.js", "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "publishConfig": { + "access": "public" + }, "scripts": { "build": "tsc -p tsconfig.json", "clean": "rimraf dist" }, "dependencies": { + "@constructive-io/fn-types": "workspace:^", "@constructive-io/knative-job-fn": "workspace:^", "@pgpmjs/logger": "^2.4.3", "graphql-request": "^7.1.2" diff --git a/packages/fn-runtime/src/context.ts b/packages/fn-runtime/src/context.ts index 810665d..5763d4a 100644 --- a/packages/fn-runtime/src/context.ts +++ b/packages/fn-runtime/src/context.ts @@ -1,6 +1,6 @@ import { createLogger } from '@pgpmjs/logger'; +import type { FunctionContext } from '@constructive-io/fn-types'; import { createClients } from './graphql'; -import type { FunctionContext } from './types'; type RequestHeaders = { databaseId?: string; diff --git a/packages/fn-runtime/src/index.ts b/packages/fn-runtime/src/index.ts index 47c62ef..2b74016 100644 --- a/packages/fn-runtime/src/index.ts +++ b/packages/fn-runtime/src/index.ts @@ -1,4 +1,9 @@ export { createFunctionServer } from './server'; export { createClients } from './graphql'; export { buildContext } from './context'; -export type { FunctionHandler, FunctionContext, ServerOptions } from './types'; +export type { + FunctionHandler, + FunctionContext, + FunctionLogger, + ServerOptions +} from '@constructive-io/fn-types'; diff --git a/packages/fn-runtime/src/server.ts b/packages/fn-runtime/src/server.ts index c559a79..143ddee 100644 --- a/packages/fn-runtime/src/server.ts +++ b/packages/fn-runtime/src/server.ts @@ -1,6 +1,6 @@ import { createJobApp } from '@constructive-io/knative-job-fn'; +import type { FunctionHandler, ServerOptions } from '@constructive-io/fn-types'; import { buildContext } from './context'; -import type { FunctionHandler, ServerOptions } from './types'; export const createFunctionServer = ( handler: FunctionHandler, diff --git a/packages/fn-types/README.md b/packages/fn-types/README.md new file mode 100644 index 0000000..1817361 --- /dev/null +++ b/packages/fn-types/README.md @@ -0,0 +1,37 @@ +# @constructive-io/fn-types + +Source-of-truth TypeScript types for the Constructive Functions toolkit. + +This package has **no logic** — it's the contract every other `@constructive-io/fn-*` package depends on. Types are split into four areas: + +- **Runtime** — `FunctionHandler`, `FunctionContext`, `ServerOptions` (used by `@constructive-io/fn-runtime` and handler authors). +- **Manifest** — `HandlerManifest` (the shape of `functions//handler.json`). +- **Config** — `FnConfig`, `FnPreset`, `K8sOptions`, `DockerOptions`, plus a `defineConfig()` helper for `fn.config.ts` files. +- **Registry** — `FnRegistry`, `FnRegistryEntry` (manifest format consumed by `@constructive-io/fn-job-service`). + +## Usage in `fn.config.ts` + +```ts +import { defineConfig } from '@constructive-io/fn-types'; + +export default defineConfig({ + functionsDir: 'functions', + outputDir: 'generated', + preset: 'jobs-bundle', + registry: 'ghcr.io/my-org', + k8s: { target: 'knative' } +}); +``` + +## Usage in a handler + +```ts +import type { FunctionHandler } from '@constructive-io/fn-types'; + +const handler: FunctionHandler<{ to: string }, { ok: true }> = async (params, ctx) => { + ctx.log.info('sending', params); + return { ok: true }; +}; + +export default handler; +``` diff --git a/packages/fn-types/package.json b/packages/fn-types/package.json new file mode 100644 index 0000000..6a1daf3 --- /dev/null +++ b/packages/fn-types/package.json @@ -0,0 +1,29 @@ +{ + "name": "@constructive-io/fn-types", + "version": "0.1.0", + "description": "Source-of-truth TypeScript types for the Constructive Functions toolkit: handler manifests, FnConfig, runtime context, registry.", + "author": "Constructive ", + "license": "SEE LICENSE IN LICENSE", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "clean": "rimraf dist", + "lint": "eslint . --fix" + }, + "dependencies": { + "graphql-request": "^7.1.2" + }, + "devDependencies": { + "@types/node": "^22.10.4", + "rimraf": "^5.0.5", + "typescript": "^5.1.6" + } +} diff --git a/packages/fn-types/src/config.ts b/packages/fn-types/src/config.ts new file mode 100644 index 0000000..1f8769f --- /dev/null +++ b/packages/fn-types/src/config.ts @@ -0,0 +1,53 @@ +export type FnPreset = 'functions-only' | 'jobs-bundle'; + +export type K8sTarget = 'knative' | 'deployment'; + +export interface K8sResourceQuantities { + cpu?: string; + memory?: string; +} + +export interface K8sOptions { + target?: K8sTarget; + namespace?: string; + imagePullSecrets?: string[]; + resources?: { + requests?: K8sResourceQuantities; + limits?: K8sResourceQuantities; + }; + /** Knative-specific: containerConcurrency, timeoutSeconds, etc. */ + knative?: { + containerConcurrency?: number; + timeoutSeconds?: number; + visibility?: 'cluster-local' | 'public'; + }; +} + +export interface DockerOptions { + /** Image registry, e.g. 'ghcr.io/my-org'. */ + registry?: string; + /** Base image override; defaults to template's choice. */ + baseImage?: string; + /** Generate one Dockerfile per function (true) vs one shared image (false). */ + perFunction?: boolean; +} + +export interface FnConfig { + /** Directory containing per-function source: //handler.{json,ts,py}. Default: 'functions'. */ + functionsDir?: string; + /** Output directory for generated artifacts. Default: 'generated'. */ + outputDir?: string; + /** Bundle preset. Default: 'functions-only'. */ + preset?: FnPreset; + /** Image registry default; per-function manifests can still override. */ + registry?: string; + /** Default Kubernetes namespace for generated manifests. */ + namespace?: string; + k8s?: K8sOptions; + docker?: DockerOptions; + /** Map of template type → module specifier resolved by the generator. */ + templates?: Record; +} + +/** Identity helper for editor autocomplete in fn.config.ts files. */ +export const defineConfig = (config: FnConfig): FnConfig => config; diff --git a/packages/fn-types/src/index.ts b/packages/fn-types/src/index.ts new file mode 100644 index 0000000..14433b4 --- /dev/null +++ b/packages/fn-types/src/index.ts @@ -0,0 +1,21 @@ +export type { + FunctionHandler, + FunctionContext, + FunctionLogger, + ServerOptions +} from './runtime'; + +export type { HandlerManifest } from './manifest'; + +export type { FnRegistry, FnRegistryEntry } from './registry'; + +export type { + FnConfig, + FnPreset, + K8sTarget, + K8sOptions, + K8sResourceQuantities, + DockerOptions +} from './config'; + +export { defineConfig } from './config'; diff --git a/packages/fn-types/src/manifest.ts b/packages/fn-types/src/manifest.ts new file mode 100644 index 0000000..721e69f --- /dev/null +++ b/packages/fn-types/src/manifest.ts @@ -0,0 +1,12 @@ +export interface HandlerManifest { + name: string; + version: string; + description?: string; + /** Template type. Defaults to 'node-graphql'. */ + type?: string; + /** HTTP port the function listens on. Auto-assigned if omitted. */ + port?: number; + /** Extra dependencies merged into the generated package's package.json. */ + dependencies?: Record; + [key: string]: unknown; +} diff --git a/packages/fn-types/src/registry.ts b/packages/fn-types/src/registry.ts new file mode 100644 index 0000000..35233bb --- /dev/null +++ b/packages/fn-types/src/registry.ts @@ -0,0 +1,14 @@ +export interface FnRegistryEntry { + name: string; + /** npm-style module ID for dynamic require (in-process job-service). */ + moduleName?: string; + /** HTTP URL for remote dispatch (cluster job-service). */ + url?: string; + /** Default port the function listens on. */ + port?: number; +} + +export interface FnRegistry { + version: 1; + functions: FnRegistryEntry[]; +} diff --git a/packages/fn-runtime/src/types.ts b/packages/fn-types/src/runtime.ts similarity index 72% rename from packages/fn-runtime/src/types.ts rename to packages/fn-types/src/runtime.ts index 033a70c..e9ab5c1 100644 --- a/packages/fn-runtime/src/types.ts +++ b/packages/fn-types/src/runtime.ts @@ -5,6 +5,12 @@ export type FunctionHandler

= ( context: FunctionContext ) => Promise | R; +export type FunctionLogger = { + info: (...args: any[]) => void; + error: (...args: any[]) => void; + warn: (...args: any[]) => void; +}; + export type FunctionContext = { job: { jobId?: string; @@ -13,7 +19,7 @@ export type FunctionContext = { }; client: GraphQLClient; meta: GraphQLClient; - log: { info: (...args: any[]) => void; error: (...args: any[]) => void; warn: (...args: any[]) => void }; + log: FunctionLogger; env: Record; }; diff --git a/packages/fn-types/tsconfig.json b/packages/fn-types/tsconfig.json new file mode 100644 index 0000000..a45c527 --- /dev/null +++ b/packages/fn-types/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6cb7aa3..e958811 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -285,6 +285,9 @@ importers: packages/fn-runtime: dependencies: + '@constructive-io/fn-types': + specifier: workspace:^ + version: link:../fn-types '@constructive-io/knative-job-fn': specifier: workspace:^ version: link:../fn-app @@ -302,6 +305,22 @@ importers: specifier: ^5.1.6 version: 5.9.3 + packages/fn-types: + dependencies: + graphql-request: + specifier: ^7.1.2 + version: 7.4.0(graphql@16.12.0) + devDependencies: + '@types/node': + specifier: ^22.10.4 + version: 22.19.3 + rimraf: + specifier: ^5.0.5 + version: 5.0.10 + typescript: + specifier: ^5.1.6 + version: 5.9.3 + packages: 12factor-env@1.6.2: @@ -2851,6 +2870,10 @@ packages: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -6443,6 +6466,10 @@ snapshots: retry@0.13.1: {} + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + router@2.2.0: dependencies: debug: 4.4.3(supports-color@5.5.0) From c8e2728a27db92588295533dbcda2e4074b735eb Mon Sep 17 00:00:00 2001 From: Anmol1696 Date: Tue, 5 May 2026 17:05:05 +0800 Subject: [PATCH 02/10] feat(fn-generator): port scripts/generate.ts into a typed library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 2 of the portable-functions toolkit. Introduces the programmatic generator that fn-client and fn-cli (next waves) will compose. @constructive-io/fn-generator@0.1.0 - FnGenerator class with discover()/buildPackages()/buildManifest()/ buildConfigMaps()/buildSkaffold()/apply()/generate() methods. - Pure builder layer (returns Manifest[]) + a single apply() boundary that does idempotent file I/O via writeIfChanged() and ensureSymlink(). - Builders: per-function package files (templates + shared + handler symlinks), functions-manifest.json, per-function and aggregate functions-configmap.yaml, root skaffold.yaml. - Honours --only and --packages-only modes (skip k8s/skaffold and per-function/aggregate configmaps respectively). - Auto-assigns ports starting at 8081, validates 8080 is reserved for the job-service, detects port conflicts. Verification: snapshot test runs FnGenerator against the brasilia repo's own functions/+templates/ into a tmp dir and asserts byte- identical output (file contents, symlink targets, skaffold.yaml) vs scripts/generate.ts. All three assertions pass. Also updates @constructive-io/fn-types FnRegistry to match the actual on-disk format ({name, dir, port, type}) and adds optional moduleName and url for the upcoming job-service rewrite (Wave 4). scripts/generate.ts is unchanged in this PR — Wave 4 will collapse it into a one-line shim that delegates to FnGenerator. --- packages/fn-generator/README.md | 45 + .../fn-generator/__tests__/snapshot.test.ts | 91 ++ packages/fn-generator/jest.config.js | 6 + packages/fn-generator/package.json | 32 + .../fn-generator/src/builders/configmap.ts | 40 + .../fn-generator/src/builders/manifest.ts | 25 + packages/fn-generator/src/builders/package.ts | 72 ++ .../fn-generator/src/builders/skaffold.ts | 71 ++ packages/fn-generator/src/discovery.ts | 101 ++ packages/fn-generator/src/fs-utils.ts | 63 ++ packages/fn-generator/src/generator.ts | 164 +++ packages/fn-generator/src/index.ts | 17 + packages/fn-generator/src/placeholders.ts | 90 ++ packages/fn-generator/src/types.ts | 53 + packages/fn-generator/tsconfig.json | 10 + packages/fn-types/src/registry.ts | 14 +- pnpm-lock.yaml | 946 ++++++++++++++++++ 17 files changed, 1835 insertions(+), 5 deletions(-) create mode 100644 packages/fn-generator/README.md create mode 100644 packages/fn-generator/__tests__/snapshot.test.ts create mode 100644 packages/fn-generator/jest.config.js create mode 100644 packages/fn-generator/package.json create mode 100644 packages/fn-generator/src/builders/configmap.ts create mode 100644 packages/fn-generator/src/builders/manifest.ts create mode 100644 packages/fn-generator/src/builders/package.ts create mode 100644 packages/fn-generator/src/builders/skaffold.ts create mode 100644 packages/fn-generator/src/discovery.ts create mode 100644 packages/fn-generator/src/fs-utils.ts create mode 100644 packages/fn-generator/src/generator.ts create mode 100644 packages/fn-generator/src/index.ts create mode 100644 packages/fn-generator/src/placeholders.ts create mode 100644 packages/fn-generator/src/types.ts create mode 100644 packages/fn-generator/tsconfig.json diff --git a/packages/fn-generator/README.md b/packages/fn-generator/README.md new file mode 100644 index 0000000..edfc126 --- /dev/null +++ b/packages/fn-generator/README.md @@ -0,0 +1,45 @@ +# @constructive-io/fn-generator + +Programmatic generator for the Constructive Functions toolkit. Given a `functions//handler.json` manifest and a set of templates, it produces: + +- A workspace package per function (Dockerfile, entry point, package.json with merged deps, tsconfig, k8s YAML). +- Symlinks back to the source `handler.{ts,py}` and any auxiliary `.d.ts` / `.py` files. +- A `functions-manifest.json` registry. +- Per-function and aggregate `functions-configmap.yaml` (Knative job-service registry). +- A root `skaffold.yaml` with one profile per function plus an aggregate `local-simple` profile. + +The library is **pure**: each builder returns a list of `Manifest` objects (`file` or `symlink`); `FnGenerator.apply()` is the only step that touches disk, and it does so idempotently (no rewrite if the on-disk content already matches). + +## Usage + +```ts +import { FnGenerator } from '@constructive-io/fn-generator'; + +const generator = new FnGenerator({ + rootDir: process.cwd(), // default + functionsDir: 'functions', // default /functions + outputDir: 'generated', // default /generated + templatesDir: 'templates', // default /templates + namespace: 'constructive-functions', // default +}); + +// One-shot +generator.generate(); // all functions +generator.generate({ only: 'simple-email' }); // single +generator.generate({ packagesOnly: true }); // skip k8s/skaffold + +// Or assemble manifests yourself +const fns = generator.discover(); +const manifests = [ + ...generator.buildPackages(fns), + generator.buildManifest(fns), + ...generator.buildConfigMaps(fns), + generator.buildSkaffold(fns), +]; +const result = generator.apply(manifests); +console.log(result.filesWritten, result.symlinksCreated); +``` + +## Byte-identical output guarantee + +`FnGenerator` is a port of the legacy `scripts/generate.ts` and is verified by a snapshot regression test against that script's output. Any change that breaks byte-identicalness is a bug. diff --git a/packages/fn-generator/__tests__/snapshot.test.ts b/packages/fn-generator/__tests__/snapshot.test.ts new file mode 100644 index 0000000..6f1c1f7 --- /dev/null +++ b/packages/fn-generator/__tests__/snapshot.test.ts @@ -0,0 +1,91 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { FnGenerator } from '../src'; + +/** + * Regression test: run FnGenerator against the brasilia repo's own + * `functions/` and `templates/`, output to a temp dir, and assert it + * is byte-identical to the brasilia repo's checked-in `generated/` + * (which is produced by the legacy `scripts/generate.ts`). + */ + +const ROOT = path.resolve(__dirname, '..', '..', '..'); +const FUNCTIONS_DIR = path.join(ROOT, 'functions'); +const TEMPLATES_DIR = path.join(ROOT, 'templates'); +const BASELINE_GENERATED = path.join(ROOT, 'generated'); +const BASELINE_SKAFFOLD = path.join(ROOT, 'skaffold.yaml'); + +const walk = (dir: string): { files: string[]; symlinks: string[] } => { + const files: string[] = []; + const symlinks: string[] = []; + const recurse = (current: string, base: string): void => { + const entries = fs.readdirSync(current); + for (const entry of entries) { + // Skip pnpm-installed dirs that aren't generator output. + if (entry === 'node_modules' || entry === 'dist') continue; + const full = path.join(current, entry); + const rel = base ? path.join(base, entry) : entry; + const stat = fs.lstatSync(full); + if (stat.isSymbolicLink()) { + symlinks.push(rel); + } else if (stat.isDirectory()) { + recurse(full, rel); + } else { + files.push(rel); + } + } + }; + recurse(dir, ''); + return { files, symlinks }; +}; + +describe('FnGenerator snapshot vs scripts/generate.ts', () => { + let tmpDir: string; + let tmpRoot: string; + + beforeAll(() => { + // The generator writes skaffold.yaml at the rootDir, so we mirror the + // brasilia layout into a tmp tree (functions/, templates/ symlinked to + // the real ones; outputDir in tmpDir; rootDir = tmpRoot). + tmpRoot = fs.mkdtempSync(path.join(require('os').tmpdir(), 'fn-gen-test-')); + tmpDir = path.join(tmpRoot, 'generated'); + + fs.symlinkSync(FUNCTIONS_DIR, path.join(tmpRoot, 'functions')); + fs.symlinkSync(TEMPLATES_DIR, path.join(tmpRoot, 'templates')); + + const gen = new FnGenerator({ rootDir: tmpRoot }); + gen.generate(); + }); + + afterAll(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('produces the same files (byte-identical)', () => { + const baseline = walk(BASELINE_GENERATED); + const actual = walk(tmpDir); + expect([...actual.files].sort()).toEqual([...baseline.files].sort()); + for (const rel of baseline.files) { + const a = fs.readFileSync(path.join(tmpDir, rel), 'utf-8'); + const b = fs.readFileSync(path.join(BASELINE_GENERATED, rel), 'utf-8'); + expect({ file: rel, content: a }).toEqual({ file: rel, content: b }); + } + }); + + it('produces the same symlinks (same relative target)', () => { + const baseline = walk(BASELINE_GENERATED); + const actual = walk(tmpDir); + expect([...actual.symlinks].sort()).toEqual([...baseline.symlinks].sort()); + for (const rel of baseline.symlinks) { + const a = fs.readlinkSync(path.join(tmpDir, rel)); + const b = fs.readlinkSync(path.join(BASELINE_GENERATED, rel)); + expect({ symlink: rel, target: a }).toEqual({ symlink: rel, target: b }); + } + }); + + it('produces an identical skaffold.yaml', () => { + const a = fs.readFileSync(path.join(tmpRoot, 'skaffold.yaml'), 'utf-8'); + const b = fs.readFileSync(BASELINE_SKAFFOLD, 'utf-8'); + expect(a).toBe(b); + }); +}); diff --git a/packages/fn-generator/jest.config.js b/packages/fn-generator/jest.config.js new file mode 100644 index 0000000..184cbe3 --- /dev/null +++ b/packages/fn-generator/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/__tests__/**/*.test.ts'], + collectCoverageFrom: ['src/**/*.ts'], +}; diff --git a/packages/fn-generator/package.json b/packages/fn-generator/package.json new file mode 100644 index 0000000..16637e9 --- /dev/null +++ b/packages/fn-generator/package.json @@ -0,0 +1,32 @@ +{ + "name": "@constructive-io/fn-generator", + "version": "0.1.0", + "description": "Programmatic generator for Constructive Functions: walks handler.json manifests and stamps out Dockerfiles, k8s YAML, configmaps, and Skaffold profiles. Pure functions; file I/O at the boundary.", + "author": "Constructive ", + "license": "SEE LICENSE IN LICENSE", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "clean": "rimraf dist", + "test": "jest" + }, + "dependencies": { + "@constructive-io/fn-types": "workspace:^" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "^22.10.4", + "jest": "^29.7.0", + "rimraf": "^5.0.5", + "ts-jest": "^29.2.5", + "typescript": "^5.1.6" + } +} diff --git a/packages/fn-generator/src/builders/configmap.ts b/packages/fn-generator/src/builders/configmap.ts new file mode 100644 index 0000000..079eae7 --- /dev/null +++ b/packages/fn-generator/src/builders/configmap.ts @@ -0,0 +1,40 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { renderTemplate } from '../placeholders'; +import type { FunctionInfo, Manifest } from '../types'; + +/** + * Build a `functions-configmap.yaml` for either a single function (when + * `target` is supplied) or the aggregate of all functions. + * + * - per-function output: `//k8s/functions-configmap.yaml` + * - aggregate output: `/functions-configmap.yaml` + */ +export const buildConfigMap = (args: { + fns: FunctionInfo[]; + target?: FunctionInfo; + outputDir: string; + templatesDir: string; + namespace: string; +}): Manifest => { + const targetFns = args.target ? [args.target] : args.fns; + const gatewayMap: Record = {}; + for (const fn of targetFns) { + gatewayMap[fn.name] = `http://${fn.name}.${args.namespace}.svc.cluster.local`; + } + + const template = fs.readFileSync( + path.join(args.templatesDir, 'k8s', 'functions-configmap.yaml'), + 'utf-8' + ); + const yaml = renderTemplate(template, { + jobs_supported: targetFns.map((fn) => fn.name).join(','), + gateway_map: JSON.stringify(gatewayMap), + }); + + const outPath = args.target + ? path.join(args.outputDir, args.target.dir, 'k8s', 'functions-configmap.yaml') + : path.join(args.outputDir, 'functions-configmap.yaml'); + + return { kind: 'file', path: outPath, content: yaml }; +}; diff --git a/packages/fn-generator/src/builders/manifest.ts b/packages/fn-generator/src/builders/manifest.ts new file mode 100644 index 0000000..f64b56f --- /dev/null +++ b/packages/fn-generator/src/builders/manifest.ts @@ -0,0 +1,25 @@ +import * as path from 'path'; +import type { FunctionInfo, Manifest } from '../types'; + +/** + * Build the `generated/functions-manifest.json` manifest. Field order is + * fixed (`name`, `dir`, `port`, `type`) to keep output byte-identical. + */ +export const buildManifestJson = ( + fns: FunctionInfo[], + outputDir: string +): Manifest => { + const data = { + functions: fns.map((fn) => ({ + name: fn.name, + dir: fn.dir, + port: fn.port, + type: fn.type, + })), + }; + return { + kind: 'file', + path: path.join(outputDir, 'functions-manifest.json'), + content: JSON.stringify(data, null, 2) + '\n', + }; +}; diff --git a/packages/fn-generator/src/builders/package.ts b/packages/fn-generator/src/builders/package.ts new file mode 100644 index 0000000..327f5dd --- /dev/null +++ b/packages/fn-generator/src/builders/package.ts @@ -0,0 +1,72 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { walkTemplateFiles } from '../fs-utils'; +import { processTemplateFile } from '../placeholders'; +import type { FunctionInfo, Manifest } from '../types'; + +/** + * Build all manifests for a single function: + * - per-template files (placeholders applied, package.json/tsconfig.json processed) + * - shared template files (same processing) + * - symlinks for handler.{ts,py}, *.d.ts, and any other *.py + * + * `templateDir` is the resolved type-specific dir (`templates/node-graphql/` etc.). + */ +export const buildPackageManifests = ( + fn: FunctionInfo, + args: { + fnDir: string; // absolute: /

+ genDir: string; // absolute: / + templateDir: string; // absolute: / + sharedDir?: string; // absolute: /shared (if present) + } +): Manifest[] => { + const out: Manifest[] = []; + + // 1. Per-template files + const templateFiles = walkTemplateFiles(args.templateDir); + for (const relPath of templateFiles) { + const templateFile = path.join(args.templateDir, relPath); + const outputFile = path.join(args.genDir, relPath); + const templateContent = fs.readFileSync(templateFile, 'utf-8'); + const baseName = path.basename(relPath); + const processed = processTemplateFile(baseName, templateContent, fn.manifest, args.fnDir); + out.push({ kind: 'file', path: outputFile, content: processed }); + } + + // 2. Shared template files + if (args.sharedDir && fs.existsSync(args.sharedDir)) { + const sharedFiles = walkTemplateFiles(args.sharedDir); + for (const relPath of sharedFiles) { + const templateFile = path.join(args.sharedDir, relPath); + const outputFile = path.join(args.genDir, relPath); + const templateContent = fs.readFileSync(templateFile, 'utf-8'); + const baseName = path.basename(relPath); + const processed = processTemplateFile(baseName, templateContent, fn.manifest, args.fnDir); + out.push({ kind: 'file', path: outputFile, content: processed }); + } + } + + // 3. Handler symlink (handler.ts wins; handler.py only if no handler.ts) + const handlerTs = path.join(args.fnDir, 'handler.ts'); + const handlerPy = path.join(args.fnDir, 'handler.py'); + if (fs.existsSync(handlerTs)) { + out.push({ kind: 'symlink', path: path.join(args.genDir, 'handler.ts'), target: handlerTs }); + } else if (fs.existsSync(handlerPy)) { + out.push({ kind: 'symlink', path: path.join(args.genDir, 'handler.py'), target: handlerPy }); + } + + // 4. Auxiliary symlinks: all *.d.ts and *.py (except handler.py already linked above) + const files = fs.readdirSync(args.fnDir); + for (const file of files) { + if (file.endsWith('.d.ts') || (file.endsWith('.py') && file !== 'handler.py')) { + out.push({ + kind: 'symlink', + path: path.join(args.genDir, file), + target: path.join(args.fnDir, file), + }); + } + } + + return out; +}; diff --git a/packages/fn-generator/src/builders/skaffold.ts b/packages/fn-generator/src/builders/skaffold.ts new file mode 100644 index 0000000..c0d3069 --- /dev/null +++ b/packages/fn-generator/src/builders/skaffold.ts @@ -0,0 +1,71 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { renderTemplate } from '../placeholders'; +import type { FunctionInfo, Manifest } from '../types'; + +const PYTHON_ARTIFACTS = ` - image: constructive-functions-python + context: . + docker: + dockerfile: Dockerfile.python.dev + sync: + manual: + - src: 'functions/**/*.py' + dest: /usr/src/app + - src: 'generated/**/*.py' + dest: /usr/src/app`; + +/** + * Build the root `skaffold.yaml` from `templates/k8s/skaffold.yaml`, + * including per-function profiles, an aggregate raw-yaml list, port + * forwards, and the Python build artifact (only if any function is python). + */ +export const buildSkaffold = (args: { + fns: FunctionInfo[]; + rootDir: string; + templatesDir: string; + namespace: string; +}): Manifest => { + const k8s = path.join(args.templatesDir, 'k8s'); + const nodeProfile = fs.readFileSync(path.join(k8s, 'skaffold-profile.yaml'), 'utf-8'); + const pythonProfile = fs.readFileSync(path.join(k8s, 'skaffold-profile-python.yaml'), 'utf-8'); + const main = fs.readFileSync(path.join(k8s, 'skaffold.yaml'), 'utf-8'); + + const perFnProfiles = args.fns + .map((fn) => + renderTemplate(fn.type === 'python' ? pythonProfile : nodeProfile, { + name: fn.name, + dir: fn.dir, + port: String(fn.port), + namespace: args.namespace, + }).trimEnd() + ) + .join('\n'); + + const allRawYaml = args.fns + .map((fn) => ` - generated/${fn.dir}/k8s/local-deployment.yaml`) + .join('\n'); + + const allPortForwards = args.fns + .map((fn) => + [ + ' - resourceType: service', + ` resourceName: ${fn.name}`, + ` namespace: ${args.namespace}`, + ' port: 80', + ` localPort: ${fn.port}`, + ].join('\n') + ) + .join('\n'); + + const pythonArtifacts = args.fns.some((fn) => fn.type === 'python') ? PYTHON_ARTIFACTS : ''; + + const skaffold = renderTemplate(main, { + per_function_profiles: perFnProfiles, + all_raw_yaml: allRawYaml, + all_port_forwards: allPortForwards, + python_artifacts: pythonArtifacts, + namespace: args.namespace, + }); + + return { kind: 'file', path: path.join(args.rootDir, 'skaffold.yaml'), content: skaffold }; +}; diff --git a/packages/fn-generator/src/discovery.ts b/packages/fn-generator/src/discovery.ts new file mode 100644 index 0000000..6553c88 --- /dev/null +++ b/packages/fn-generator/src/discovery.ts @@ -0,0 +1,101 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { HandlerManifest } from '@constructive-io/fn-types'; +import type { FunctionInfo } from './types'; + +/** + * Return directory names under `functionsDir` that contain a handler.json, + * in `fs.readdirSync()` order. If `only` is provided, restrict to that one. + */ +export const findFunctions = (functionsDir: string, only?: string): string[] => { + if (!fs.existsSync(functionsDir)) return []; + return fs + .readdirSync(functionsDir) + .filter((name) => fs.existsSync(path.join(functionsDir, name, 'handler.json'))) + .filter((name) => !only || name === only); +}; + +export const readManifest = (fnDir: string): HandlerManifest => { + const raw = fs.readFileSync(path.join(fnDir, 'handler.json'), 'utf-8'); + return JSON.parse(raw) as HandlerManifest; +}; + +/** + * Resolve `templates//`. Throws if the directory does not exist. + */ +export const resolveTemplateDir = ( + manifest: HandlerManifest, + templatesDir: string, + defaultTemplate: string +): string => { + const templateType = manifest.type || defaultTemplate; + const templateDir = path.join(templatesDir, templateType); + if (!fs.existsSync(templateDir)) { + throw new Error( + `Template "${templateType}" not found at ${templateDir}. ` + + `Check the "type" field in handler.json for function "${manifest.name}".` + ); + } + return templateDir; +}; + +/** + * Auto-assign ports to manifests missing one and validate there are no + * conflicts (and that 8080 — reserved for the job-service — isn't claimed). + * + * Mutates the input manifests' `port` field. + */ +export const assignAndValidatePorts = ( + manifests: HandlerManifest[], + defaultTemplate: string +): void => { + const usedPorts = new Set( + manifests.filter((m) => m.port).map((m) => m.port as number) + ); + let nextPort = usedPorts.size > 0 ? Math.max(...usedPorts) + 1 : 8081; + for (const m of manifests) { + if (!m.port) { + while (usedPorts.has(nextPort)) nextPort++; + m.port = nextPort; + usedPorts.add(nextPort); + nextPort++; + } + } + + const portToFunction = new Map(); + for (const m of manifests) { + if (m.port === 8080) { + throw new Error( + `Function "${m.name}" uses port 8080 which is reserved for job-service.` + ); + } + if (portToFunction.has(m.port as number)) { + throw new Error( + `Port ${m.port} conflict: "${m.name}" and "${portToFunction.get(m.port as number)}".` + ); + } + portToFunction.set(m.port as number, m.name); + } + // suppress unused var lint by referencing defaultTemplate consumer side + void defaultTemplate; +}; + +/** + * Build the resolved `FunctionInfo[]` for a discovered set of function dirs. + * Reads manifests, assigns ports, defaults the template type. + */ +export const computeFunctionInfos = ( + fnDirs: string[], + functionsDir: string, + defaultTemplate: string +): FunctionInfo[] => { + const manifests = fnDirs.map((dir) => readManifest(path.join(functionsDir, dir))); + assignAndValidatePorts(manifests, defaultTemplate); + return fnDirs.map((dir, i) => ({ + name: manifests[i].name, + dir, + port: manifests[i].port as number, + type: manifests[i].type || defaultTemplate, + manifest: manifests[i], + })); +}; diff --git a/packages/fn-generator/src/fs-utils.ts b/packages/fn-generator/src/fs-utils.ts new file mode 100644 index 0000000..ac845e6 --- /dev/null +++ b/packages/fn-generator/src/fs-utils.ts @@ -0,0 +1,63 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Recursively walk `dir` and return relative paths of all files (not + * directories), in `fs.readdirSync()` order — which is filesystem-dependent + * and intentionally NOT sorted, to match the legacy generator byte-for-byte. + */ +export const walkTemplateFiles = (dir: string, base = ''): string[] => { + const results: string[] = []; + const entries = fs.readdirSync(dir); + for (const entry of entries) { + const fullPath = path.join(dir, entry); + const relPath = base ? path.join(base, entry) : entry; + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + results.push(...walkTemplateFiles(fullPath, relPath)); + } else { + results.push(relPath); + } + } + return results; +}; + +/** + * Write `content` to `filePath` only if the existing file's contents differ. + * Returns true if a write occurred. + */ +export const writeIfChanged = (filePath: string, content: string): boolean => { + if (fs.existsSync(filePath)) { + const existing = fs.readFileSync(filePath, 'utf-8'); + if (existing === content) return false; + } + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, content, 'utf-8'); + return true; +}; + +/** + * Ensure a relative symlink at `linkPath` points at `target`. Returns true if + * the link was created or refreshed. + */ +export const ensureSymlink = (target: string, linkPath: string): boolean => { + const linkDir = path.dirname(linkPath); + if (!fs.existsSync(linkDir)) fs.mkdirSync(linkDir, { recursive: true }); + const relTarget = path.relative(linkDir, target); + + try { + const existing = fs.readlinkSync(linkPath); + if (existing === relTarget) return false; + fs.unlinkSync(linkPath); + } catch { + try { + fs.unlinkSync(linkPath); + } catch { + /* doesn't exist */ + } + } + + fs.symlinkSync(relTarget, linkPath); + return true; +}; diff --git a/packages/fn-generator/src/generator.ts b/packages/fn-generator/src/generator.ts new file mode 100644 index 0000000..491aeed --- /dev/null +++ b/packages/fn-generator/src/generator.ts @@ -0,0 +1,164 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { computeFunctionInfos, findFunctions, resolveTemplateDir } from './discovery'; +import { ensureSymlink, writeIfChanged } from './fs-utils'; +import { buildPackageManifests } from './builders/package'; +import { buildManifestJson } from './builders/manifest'; +import { buildConfigMap } from './builders/configmap'; +import { buildSkaffold } from './builders/skaffold'; +import type { + ApplyResult, + FnGeneratorOptions, + FunctionInfo, + GenerateOptions, + Manifest, +} from './types'; + +const DEFAULT_NAMESPACE = 'constructive-functions'; +const DEFAULT_TEMPLATE = 'node-graphql'; + +interface ResolvedOptions { + rootDir: string; + functionsDir: string; + outputDir: string; + templatesDir: string; + sharedDir: string; + namespace: string; + defaultTemplate: string; +} + +const resolveOptions = (opts: FnGeneratorOptions = {}): ResolvedOptions => { + const rootDir = opts.rootDir ?? process.cwd(); + const templatesDir = opts.templatesDir ?? path.resolve(rootDir, 'templates'); + return { + rootDir, + functionsDir: opts.functionsDir ?? path.resolve(rootDir, 'functions'), + outputDir: opts.outputDir ?? path.resolve(rootDir, 'generated'), + templatesDir, + sharedDir: path.resolve(templatesDir, 'shared'), + namespace: opts.namespace ?? DEFAULT_NAMESPACE, + defaultTemplate: opts.defaultTemplate ?? DEFAULT_TEMPLATE, + }; +}; + +export class FnGenerator { + private readonly opts: ResolvedOptions; + + constructor(options: FnGeneratorOptions = {}) { + this.opts = resolveOptions(options); + } + + /** Discover functions and resolve their info (port assignment, type defaulting). */ + discover(only?: string): FunctionInfo[] { + const dirs = findFunctions(this.opts.functionsDir, only); + if (dirs.length === 0) return []; + return computeFunctionInfos(dirs, this.opts.functionsDir, this.opts.defaultTemplate); + } + + /** Build all per-function package manifests (templates + shared + symlinks). */ + buildPackages(fns: FunctionInfo[]): Manifest[] { + const out: Manifest[] = []; + for (const fn of fns) { + const fnDir = path.join(this.opts.functionsDir, fn.dir); + const genDir = path.join(this.opts.outputDir, fn.dir); + const templateDir = resolveTemplateDir( + fn.manifest, + this.opts.templatesDir, + this.opts.defaultTemplate + ); + out.push( + ...buildPackageManifests(fn, { + fnDir, + genDir, + templateDir, + sharedDir: this.opts.sharedDir, + }) + ); + } + return out; + } + + /** Build the global functions-manifest.json. */ + buildManifest(fns: FunctionInfo[]): Manifest { + return buildManifestJson(fns, this.opts.outputDir); + } + + /** Build per-function and aggregate functions-configmap.yaml entries. */ + buildConfigMaps(fns: FunctionInfo[]): Manifest[] { + const out: Manifest[] = []; + for (const fn of fns) { + out.push( + buildConfigMap({ + fns, + target: fn, + outputDir: this.opts.outputDir, + templatesDir: this.opts.templatesDir, + namespace: this.opts.namespace, + }) + ); + } + out.push( + buildConfigMap({ + fns, + outputDir: this.opts.outputDir, + templatesDir: this.opts.templatesDir, + namespace: this.opts.namespace, + }) + ); + return out; + } + + /** Build the root skaffold.yaml. */ + buildSkaffold(fns: FunctionInfo[]): Manifest { + return buildSkaffold({ + fns, + rootDir: this.opts.rootDir, + templatesDir: this.opts.templatesDir, + namespace: this.opts.namespace, + }); + } + + /** Write all manifests to disk idempotently. */ + apply(manifests: Manifest[]): ApplyResult { + const filesWritten: string[] = []; + const symlinksCreated: string[] = []; + for (const m of manifests) { + if (m.kind === 'file') { + if (writeIfChanged(m.path, m.content)) filesWritten.push(m.path); + } else { + if (ensureSymlink(m.target, m.path)) symlinksCreated.push(m.path); + } + } + return { filesWritten, symlinksCreated }; + } + + /** + * One-shot: discover, build all manifests, and apply. Returns the + * apply result plus the discovered function infos for inspection. + */ + generate(opts: GenerateOptions = {}): ApplyResult & { functions: FunctionInfo[] } { + const fns = this.discover(opts.only); + if (fns.length === 0) { + return { filesWritten: [], symlinksCreated: [], functions: [] }; + } + + if (!fs.existsSync(this.opts.outputDir)) { + fs.mkdirSync(this.opts.outputDir, { recursive: true }); + } + + const manifests: Manifest[] = [...this.buildPackages(fns)]; + + if (!opts.packagesOnly) { + manifests.push(this.buildManifest(fns)); + // Per-function and aggregate configmaps + skaffold are skipped in --only mode + // (matches legacy generator: those files reflect the whole repo). + if (!opts.only) { + manifests.push(...this.buildConfigMaps(fns)); + manifests.push(this.buildSkaffold(fns)); + } + } + + const result = this.apply(manifests); + return { ...result, functions: fns }; + } +} diff --git a/packages/fn-generator/src/index.ts b/packages/fn-generator/src/index.ts new file mode 100644 index 0000000..c97926b --- /dev/null +++ b/packages/fn-generator/src/index.ts @@ -0,0 +1,17 @@ +export { FnGenerator } from './generator'; +export type { + ApplyResult, + FnGeneratorOptions, + FunctionInfo, + GenerateOptions, + Manifest, +} from './types'; +export { findFunctions, readManifest, computeFunctionInfos } from './discovery'; +export { + replacePlaceholders, + renderTemplate, + processPackageJson, + processTsconfig, + processTemplateFile, +} from './placeholders'; +export { walkTemplateFiles, writeIfChanged, ensureSymlink } from './fs-utils'; diff --git a/packages/fn-generator/src/placeholders.ts b/packages/fn-generator/src/placeholders.ts new file mode 100644 index 0000000..1375192 --- /dev/null +++ b/packages/fn-generator/src/placeholders.ts @@ -0,0 +1,90 @@ +import * as fs from 'fs'; +import type { HandlerManifest } from '@constructive-io/fn-types'; + +/** + * Replace the three universal placeholders. No escaping; passes special + * characters through verbatim (matches the legacy generator). + */ +export const replacePlaceholders = ( + content: string, + manifest: HandlerManifest +): string => + content + .replace(/\{\{name\}\}/g, manifest.name) + .replace(/\{\{version\}\}/g, manifest.version) + .replace(/\{\{description\}\}/g, manifest.description || ''); + +/** + * Generic key/value placeholder renderer (used for k8s/skaffold templates that + * have non-standard keys like `{{namespace}}`, `{{port}}`). + */ +export const renderTemplate = ( + template: string, + vars: Record +): string => { + let result = template; + for (const [key, value] of Object.entries(vars)) { + result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value); + } + return result; +}; + +/** + * Apply placeholder replacement, then shallow-merge `manifest.dependencies` + * into the template's `dependencies` (handler keys win on conflict). + * `devDependencies` and other fields are passed through untouched. + */ +export const processPackageJson = ( + templateContent: string, + manifest: HandlerManifest +): string => { + const pkg = JSON.parse(replacePlaceholders(templateContent, manifest)); + + if (manifest.dependencies) { + pkg.dependencies = { + ...(pkg.dependencies || {}), + ...manifest.dependencies, + }; + } + + return JSON.stringify(pkg, null, 2) + '\n'; +}; + +/** + * Append any `.d.ts` files found in `fnDir` to the template's `include` array + * (in `fs.readdirSync()` order). Existing entries are not duplicated. + */ +export const processTsconfig = ( + templateContent: string, + fnDir: string +): string => { + const tsconfig = JSON.parse(templateContent); + + if (fs.existsSync(fnDir)) { + const files = fs.readdirSync(fnDir); + for (const file of files) { + if (file.endsWith('.d.ts') && !tsconfig.include.includes(file)) { + tsconfig.include.push(file); + } + } + } + + return JSON.stringify(tsconfig, null, 2) + '\n'; +}; + +/** Route a template file to its specific processor based on basename. */ +export const processTemplateFile = ( + fileName: string, + templateContent: string, + manifest: HandlerManifest, + fnDir: string +): string => { + switch (fileName) { + case 'package.json': + return processPackageJson(templateContent, manifest); + case 'tsconfig.json': + return processTsconfig(templateContent, fnDir); + default: + return replacePlaceholders(templateContent, manifest); + } +}; diff --git a/packages/fn-generator/src/types.ts b/packages/fn-generator/src/types.ts new file mode 100644 index 0000000..4f4ff15 --- /dev/null +++ b/packages/fn-generator/src/types.ts @@ -0,0 +1,53 @@ +import type { HandlerManifest } from '@constructive-io/fn-types'; + +/** + * A single output unit from the generator. `path` is absolute; the apply step + * decides whether to write a file (idempotently) or create/refresh a symlink. + */ +export type Manifest = + | { kind: 'file'; path: string; content: string } + | { kind: 'symlink'; path: string; target: string }; + +export interface FunctionInfo { + /** Function name from handler.json (e.g., 'knative-job-example'). */ + name: string; + /** Directory under functions/ (e.g., 'example'). May differ from `name`. */ + dir: string; + /** Resolved port (auto-assigned if missing on input). */ + port: number; + /** Template type. Defaults to `defaultTemplate` from options when missing. */ + type: string; + /** Original handler.json contents. */ + manifest: HandlerManifest; +} + +export interface FnGeneratorOptions { + /** Repo root. Default: process.cwd(). Used for skaffold.yaml output path. */ + rootDir?: string; + /** Where source functions live. Default: `/functions`. */ + functionsDir?: string; + /** Where generated artifacts go. Default: `/generated`. */ + outputDir?: string; + /** Where templates live. Default: `/templates`. */ + templatesDir?: string; + /** Kubernetes namespace stamped into configmaps and skaffold. Default: 'constructive-functions'. */ + namespace?: string; + /** Default template type when handler.json omits `type`. Default: 'node-graphql'. */ + defaultTemplate?: string; +} + +export interface GenerateOptions { + /** Generate only this function (matched by directory name). */ + only?: string; + /** + * Skip everything after the per-function package generation: no + * functions-manifest.json, no configmaps, no skaffold.yaml. + * Used by Dockerfile.dev to build a small early cache layer. + */ + packagesOnly?: boolean; +} + +export interface ApplyResult { + filesWritten: string[]; + symlinksCreated: string[]; +} diff --git a/packages/fn-generator/tsconfig.json b/packages/fn-generator/tsconfig.json new file mode 100644 index 0000000..1160dc4 --- /dev/null +++ b/packages/fn-generator/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "__tests__"] +} diff --git a/packages/fn-types/src/registry.ts b/packages/fn-types/src/registry.ts index 35233bb..c10c0e3 100644 --- a/packages/fn-types/src/registry.ts +++ b/packages/fn-types/src/registry.ts @@ -1,14 +1,18 @@ export interface FnRegistryEntry { + /** Function name from handler.json (e.g., 'simple-email', 'knative-job-example'). */ name: string; - /** npm-style module ID for dynamic require (in-process job-service). */ + /** Directory under functions/ containing the source. May differ from `name`. */ + dir: string; + /** HTTP port the function listens on. */ + port: number; + /** Template type (e.g., 'node-graphql', 'python'). */ + type: string; + /** npm-style module ID for dynamic require (in-process dispatch, optional). */ moduleName?: string; - /** HTTP URL for remote dispatch (cluster job-service). */ + /** HTTP URL for remote dispatch (cluster job-service, optional). */ url?: string; - /** Default port the function listens on. */ - port?: number; } export interface FnRegistry { - version: 1; functions: FnRegistryEntry[]; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e958811..30b9c77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -283,6 +283,31 @@ importers: specifier: ^5.1.6 version: 5.9.3 + packages/fn-generator: + dependencies: + '@constructive-io/fn-types': + specifier: workspace:^ + version: link:../fn-types + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + '@types/node': + specifier: ^22.10.4 + version: 22.19.3 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.19.3) + rimraf: + specifier: ^5.0.5 + version: 5.0.10 + ts-jest: + specifier: ^29.2.5 + version: 29.4.6(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.3))(typescript@5.9.3) + typescript: + specifier: ^5.1.6 + version: 5.9.3 + packages/fn-runtime: dependencies: '@constructive-io/fn-types': @@ -784,10 +809,23 @@ packages: resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} + '@jest/console@29.7.0': + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/console@30.2.0': resolution: {integrity: sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/core@29.7.0': + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + '@jest/core@30.2.0': resolution: {integrity: sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -801,18 +839,34 @@ packages: resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/environment@30.2.0': resolution: {integrity: sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/expect-utils@30.2.0': resolution: {integrity: sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/expect@29.7.0': + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/expect@30.2.0': resolution: {integrity: sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/fake-timers@30.2.0': resolution: {integrity: sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -821,6 +875,10 @@ packages: resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/globals@29.7.0': + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/globals@30.2.0': resolution: {integrity: sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -829,6 +887,15 @@ packages: resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/reporters@29.7.0': + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + '@jest/reporters@30.2.0': resolution: {integrity: sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -838,6 +905,10 @@ packages: node-notifier: optional: true + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/schemas@30.0.5': resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -846,22 +917,42 @@ packages: resolution: {integrity: sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/source-map@29.6.3': + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/source-map@30.0.1': resolution: {integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/test-result@29.7.0': + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/test-result@30.2.0': resolution: {integrity: sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/test-sequencer@29.7.0': + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/test-sequencer@30.2.0': resolution: {integrity: sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/transform@30.2.0': resolution: {integrity: sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/types@30.2.0': resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -917,12 +1008,18 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + '@sinclair/typebox@0.34.48': resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@sinonjs/fake-timers@13.0.5': resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==} @@ -998,6 +1095,9 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -1010,6 +1110,9 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@types/jest@29.5.14': + resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + '@types/jest@30.0.0': resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} @@ -1271,16 +1374,30 @@ packages: axios@1.13.5: resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + babel-jest@30.2.0: resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: '@babel/core': ^7.11.0 || ^8.0.0-0 + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + babel-plugin-istanbul@7.0.1: resolution: {integrity: sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==} engines: {node: '>=12'} + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + babel-plugin-jest-hoist@30.2.0: resolution: {integrity: sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1295,6 +1412,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0 || ^8.0.0-0 + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + babel-preset-jest@30.2.0: resolution: {integrity: sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1406,10 +1529,17 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} @@ -1485,6 +1615,11 @@ packages: resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==} deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. + create-jest@29.7.0: + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + cron-parser@4.9.0: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} @@ -1553,6 +1688,10 @@ packages: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dom-serializer@0.1.1: resolution: {integrity: sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA==} @@ -1773,6 +1912,14 @@ packages: resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} engines: {node: '>= 0.8.0'} + exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + expect@30.2.0: resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2052,6 +2199,10 @@ packages: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2086,6 +2237,10 @@ packages: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + istanbul-lib-instrument@6.0.3: resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} engines: {node: '>=10'} @@ -2094,6 +2249,10 @@ packages: resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + istanbul-lib-source-maps@5.0.6: resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} engines: {node: '>=10'} @@ -2109,14 +2268,32 @@ packages: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} + jest-changed-files@29.7.0: + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-changed-files@30.2.0: resolution: {integrity: sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-circus@29.7.0: + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-circus@30.2.0: resolution: {integrity: sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-cli@29.7.0: + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + jest-cli@30.2.0: resolution: {integrity: sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2127,6 +2304,18 @@ packages: node-notifier: optional: true + jest-config@29.7.0: + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + jest-config@30.2.0: resolution: {integrity: sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2142,38 +2331,78 @@ packages: ts-node: optional: true + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-diff@30.2.0: resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-docblock@29.7.0: + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-docblock@30.2.0: resolution: {integrity: sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-each@29.7.0: + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-each@30.2.0: resolution: {integrity: sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-environment-node@30.2.0: resolution: {integrity: sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-haste-map@30.2.0: resolution: {integrity: sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-leak-detector@29.7.0: + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-leak-detector@30.2.0: resolution: {integrity: sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-matcher-utils@30.2.0: resolution: {integrity: sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-message-util@30.2.0: resolution: {integrity: sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-mock@30.2.0: resolution: {integrity: sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2187,46 +2416,96 @@ packages: jest-resolve: optional: true + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-regex-util@30.0.1: resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-resolve-dependencies@29.7.0: + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-resolve-dependencies@30.2.0: resolution: {integrity: sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-resolve@29.7.0: + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-resolve@30.2.0: resolution: {integrity: sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-runner@29.7.0: + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-runner@30.2.0: resolution: {integrity: sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-runtime@29.7.0: + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-runtime@30.2.0: resolution: {integrity: sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-snapshot@30.2.0: resolution: {integrity: sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-util@30.2.0: resolution: {integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-validate@30.2.0: resolution: {integrity: sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-watcher@29.7.0: + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-watcher@30.2.0: resolution: {integrity: sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-worker@30.2.0: resolution: {integrity: sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest@29.7.0: + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + jest@30.2.0: resolution: {integrity: sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2287,6 +2566,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -2677,6 +2960,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -2778,10 +3064,18 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + pretty-format@30.2.0: resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -2799,6 +3093,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} @@ -2866,6 +3163,15 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -2950,6 +3256,9 @@ packages: simple-smtp-server@0.8.4: resolution: {integrity: sha512-5g3MKQAGqO4+YqEtt6wD0KFSCdilZXysoB0dgS0tKYk6c19giQlmuwzS3lNu9FgZI7ZeKv7smT+FlJQIq/M7gQ==} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -3040,6 +3349,10 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + synckit@0.11.12: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -3243,6 +3556,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + write-file-atomic@5.0.1: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -3758,6 +4075,15 @@ snapshots: '@istanbuljs/schema@0.1.3': {} + '@jest/console@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.19.3 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + '@jest/console@30.2.0': dependencies: '@jest/types': 30.2.0 @@ -3767,6 +4093,41 @@ snapshots: jest-util: 30.2.0 slash: 3.0.0 + '@jest/core@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.19.3 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@22.19.3) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + '@jest/core@30.2.0': dependencies: '@jest/console': 30.2.0 @@ -3805,6 +4166,13 @@ snapshots: '@jest/diff-sequences@30.0.1': {} + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.19.3 + jest-mock: 29.7.0 + '@jest/environment@30.2.0': dependencies: '@jest/fake-timers': 30.2.0 @@ -3812,10 +4180,21 @@ snapshots: '@types/node': 22.19.3 jest-mock: 30.2.0 + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + '@jest/expect-utils@30.2.0': dependencies: '@jest/get-type': 30.1.0 + '@jest/expect@29.7.0': + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + '@jest/expect@30.2.0': dependencies: expect: 30.2.0 @@ -3823,6 +4202,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 22.19.3 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + '@jest/fake-timers@30.2.0': dependencies: '@jest/types': 30.2.0 @@ -3834,6 +4222,15 @@ snapshots: '@jest/get-type@30.1.0': {} + '@jest/globals@29.7.0': + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + '@jest/globals@30.2.0': dependencies: '@jest/environment': 30.2.0 @@ -3848,6 +4245,35 @@ snapshots: '@types/node': 22.19.3 jest-regex-util: 30.0.1 + '@jest/reporters@29.7.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 22.19.3 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.2.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + '@jest/reporters@30.2.0': dependencies: '@bcoe/v8-coverage': 0.2.3 @@ -3876,6 +4302,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + '@jest/schemas@30.0.5': dependencies: '@sinclair/typebox': 0.34.48 @@ -3887,12 +4317,25 @@ snapshots: graceful-fs: 4.2.11 natural-compare: 1.4.0 + '@jest/source-map@29.6.3': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + '@jest/source-map@30.0.1': dependencies: '@jridgewell/trace-mapping': 0.3.31 callsites: 3.1.0 graceful-fs: 4.2.11 + '@jest/test-result@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + '@jest/test-result@30.2.0': dependencies: '@jest/console': 30.2.0 @@ -3900,6 +4343,13 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 collect-v8-coverage: 1.0.3 + '@jest/test-sequencer@29.7.0': + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + '@jest/test-sequencer@30.2.0': dependencies: '@jest/test-result': 30.2.0 @@ -3907,6 +4357,26 @@ snapshots: jest-haste-map: 30.2.0 slash: 3.0.0 + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.28.5 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + '@jest/transform@30.2.0': dependencies: '@babel/core': 7.28.5 @@ -3927,6 +4397,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 22.19.3 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + '@jest/types@30.2.0': dependencies: '@jest/pattern': 30.0.1 @@ -4037,12 +4516,18 @@ snapshots: '@pkgr/core@0.2.9': {} + '@sinclair/typebox@0.27.10': {} + '@sinclair/typebox@0.34.48': {} '@sinonjs/commons@3.0.1': dependencies: type-detect: 4.0.8 + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers@13.0.5': dependencies: '@sinonjs/commons': 3.0.1 @@ -4152,6 +4637,10 @@ snapshots: '@types/express-serve-static-core': 5.1.1 '@types/serve-static': 2.2.0 + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 22.19.3 + '@types/http-errors@2.0.5': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -4164,6 +4653,11 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 + '@types/jest@29.5.14': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + '@types/jest@30.0.0': dependencies: expect: 30.2.0 @@ -4419,6 +4913,19 @@ snapshots: transitivePeerDependencies: - debug + babel-jest@29.7.0(@babel/core@7.28.5): + dependencies: + '@babel/core': 7.28.5 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.28.5) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + babel-jest@30.2.0(@babel/core@7.28.5): dependencies: '@babel/core': 7.28.5 @@ -4432,6 +4939,16 @@ snapshots: transitivePeerDependencies: - supports-color + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + babel-plugin-istanbul@7.0.1: dependencies: '@babel/helper-plugin-utils': 7.27.1 @@ -4442,6 +4959,13 @@ snapshots: transitivePeerDependencies: - supports-color + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + babel-plugin-jest-hoist@30.2.0: dependencies: '@types/babel__core': 7.20.5 @@ -4489,6 +5013,12 @@ snapshots: '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.5) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.5) + babel-preset-jest@29.6.3(@babel/core@7.28.5): + dependencies: + '@babel/core': 7.28.5 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5) + babel-preset-jest@30.2.0(@babel/core@7.28.5): dependencies: '@babel/core': 7.28.5 @@ -4633,8 +5163,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + ci-info@3.9.0: {} + ci-info@4.4.0: {} + cjs-module-lexer@1.4.3: {} + cjs-module-lexer@2.2.0: {} clean-css@4.2.4: @@ -4694,6 +5228,21 @@ snapshots: core-js@2.6.12: {} + create-jest@29.7.0(@types/node@22.19.3): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@22.19.3) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + cron-parser@4.9.0: dependencies: luxon: 3.7.2 @@ -4751,6 +5300,8 @@ snapshots: detect-newline@3.1.0: {} + diff-sequences@29.6.3: {} + dom-serializer@0.1.1: dependencies: domelementtype: 1.3.1 @@ -5015,6 +5566,16 @@ snapshots: exit-x@0.2.2: {} + exit@0.1.2: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + expect@30.2.0: dependencies: '@jest/expect-utils': 30.2.0 @@ -5333,6 +5894,10 @@ snapshots: dependencies: binary-extensions: 2.3.0 + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -5353,6 +5918,16 @@ snapshots: istanbul-lib-coverage@3.2.2: {} + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.28.5 + '@babel/parser': 7.28.5 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + istanbul-lib-instrument@6.0.3: dependencies: '@babel/core': 7.28.5 @@ -5369,6 +5944,14 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + istanbul-lib-source-maps@5.0.6: dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -5392,12 +5975,44 @@ snapshots: dependencies: '@isaacs/cliui': 9.0.0 + jest-changed-files@29.7.0: + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + jest-changed-files@30.2.0: dependencies: execa: 5.1.1 jest-util: 30.2.0 p-limit: 3.1.0 + jest-circus@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.19.3 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.1 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + jest-circus@30.2.0: dependencies: '@jest/environment': 30.2.0 @@ -5424,6 +6039,25 @@ snapshots: - babel-plugin-macros - supports-color + jest-cli@29.7.0(@types/node@22.19.3): + dependencies: + '@jest/core': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@22.19.3) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@22.19.3) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest-cli@30.2.0(@types/node@22.19.3): dependencies: '@jest/core': 30.2.0 @@ -5443,6 +6077,36 @@ snapshots: - supports-color - ts-node + jest-config@29.7.0(@types/node@22.19.3): + dependencies: + '@babel/core': 7.28.5 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.28.5) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.19.3 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + jest-config@30.2.0(@types/node@22.19.3): dependencies: '@babel/core': 7.28.5 @@ -5475,6 +6139,13 @@ snapshots: - babel-plugin-macros - supports-color + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + jest-diff@30.2.0: dependencies: '@jest/diff-sequences': 30.0.1 @@ -5482,10 +6153,22 @@ snapshots: chalk: 4.1.2 pretty-format: 30.2.0 + jest-docblock@29.7.0: + dependencies: + detect-newline: 3.1.0 + jest-docblock@30.2.0: dependencies: detect-newline: 3.1.0 + jest-each@29.7.0: + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 + jest-each@30.2.0: dependencies: '@jest/get-type': 30.1.0 @@ -5494,6 +6177,15 @@ snapshots: jest-util: 30.2.0 pretty-format: 30.2.0 + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.19.3 + jest-mock: 29.7.0 + jest-util: 29.7.0 + jest-environment-node@30.2.0: dependencies: '@jest/environment': 30.2.0 @@ -5504,6 +6196,24 @@ snapshots: jest-util: 30.2.0 jest-validate: 30.2.0 + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 22.19.3 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + jest-haste-map@30.2.0: dependencies: '@jest/types': 30.2.0 @@ -5519,11 +6229,23 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + jest-leak-detector@29.7.0: + dependencies: + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + jest-leak-detector@30.2.0: dependencies: '@jest/get-type': 30.1.0 pretty-format: 30.2.0 + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + jest-matcher-utils@30.2.0: dependencies: '@jest/get-type': 30.1.0 @@ -5531,6 +6253,18 @@ snapshots: jest-diff: 30.2.0 pretty-format: 30.2.0 + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.27.1 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + jest-message-util@30.2.0: dependencies: '@babel/code-frame': 7.27.1 @@ -5543,18 +6277,37 @@ snapshots: slash: 3.0.0 stack-utils: 2.0.6 + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.19.3 + jest-util: 29.7.0 + jest-mock@30.2.0: dependencies: '@jest/types': 30.2.0 '@types/node': 22.19.3 jest-util: 30.2.0 + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + optionalDependencies: + jest-resolve: 29.7.0 + jest-pnp-resolver@1.2.3(jest-resolve@30.2.0): optionalDependencies: jest-resolve: 30.2.0 + jest-regex-util@29.6.3: {} + jest-regex-util@30.0.1: {} + jest-resolve-dependencies@29.7.0: + dependencies: + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + jest-resolve-dependencies@30.2.0: dependencies: jest-regex-util: 30.0.1 @@ -5562,6 +6315,18 @@ snapshots: transitivePeerDependencies: - supports-color + jest-resolve@29.7.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.12 + resolve.exports: 2.0.3 + slash: 3.0.0 + jest-resolve@30.2.0: dependencies: chalk: 4.1.2 @@ -5573,6 +6338,32 @@ snapshots: slash: 3.0.0 unrs-resolver: 1.11.1 + jest-runner@29.7.0: + dependencies: + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.19.3 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0 + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + jest-runner@30.2.0: dependencies: '@jest/console': 30.2.0 @@ -5600,6 +6391,33 @@ snapshots: transitivePeerDependencies: - supports-color + jest-runtime@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.19.3 + chalk: 4.1.2 + cjs-module-lexer: 1.4.3 + collect-v8-coverage: 1.0.3 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + jest-runtime@30.2.0: dependencies: '@jest/environment': 30.2.0 @@ -5627,6 +6445,31 @@ snapshots: transitivePeerDependencies: - supports-color + jest-snapshot@29.7.0: + dependencies: + '@babel/core': 7.28.5 + '@babel/generator': 7.28.5 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.28.5) + '@babel/types': 7.28.5 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + jest-snapshot@30.2.0: dependencies: '@babel/core': 7.28.5 @@ -5653,6 +6496,15 @@ snapshots: transitivePeerDependencies: - supports-color + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.19.3 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + jest-util@30.2.0: dependencies: '@jest/types': 30.2.0 @@ -5662,6 +6514,15 @@ snapshots: graceful-fs: 4.2.11 picomatch: 4.0.3 + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + jest-validate@30.2.0: dependencies: '@jest/get-type': 30.1.0 @@ -5671,6 +6532,17 @@ snapshots: leven: 3.1.0 pretty-format: 30.2.0 + jest-watcher@29.7.0: + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.19.3 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 + jest-watcher@30.2.0: dependencies: '@jest/test-result': 30.2.0 @@ -5682,6 +6554,13 @@ snapshots: jest-util: 30.2.0 string-length: 4.0.2 + jest-worker@29.7.0: + dependencies: + '@types/node': 22.19.3 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + jest-worker@30.2.0: dependencies: '@types/node': 22.19.3 @@ -5690,6 +6569,18 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jest@29.7.0(@types/node@22.19.3): + dependencies: + '@jest/core': 29.7.0 + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@22.19.3) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest@30.2.0(@types/node@22.19.3): dependencies: '@jest/core': 30.2.0 @@ -5750,6 +6641,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kleur@3.0.3: {} + leven@3.1.0: {} levn@0.4.1: @@ -6291,6 +7184,8 @@ snapshots: path-key@3.1.1: {} + path-parse@1.0.7: {} + path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -6380,12 +7275,23 @@ snapshots: prettier@3.7.4: {} + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + pretty-format@30.2.0: dependencies: '@jest/schemas': 30.0.5 ansi-styles: 5.2.0 react-is: 18.3.1 + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -6403,6 +7309,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@6.1.0: {} + pure-rand@7.0.1: {} qs@6.14.1: @@ -6464,6 +7372,15 @@ snapshots: resolve-pkg-maps@1.0.0: {} + resolve.exports@2.0.3: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + retry@0.13.1: {} rimraf@5.0.10: @@ -6574,6 +7491,8 @@ snapshots: '@pgpmjs/types': 2.21.0 nodemailer: 6.10.1 + sisteransi@1.0.5: {} + slash@3.0.0: {} slick@1.12.2: {} @@ -6695,6 +7614,8 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} + synckit@0.11.12: dependencies: '@pkgr/core': 0.2.9 @@ -6724,6 +7645,26 @@ snapshots: dependencies: typescript: 5.9.3 + ts-jest@29.4.6(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.3))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 29.7.0(@types/node@22.19.3) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.3 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.28.5 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.28.5) + jest-util: 30.2.0 + ts-jest@29.4.6(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.3))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 @@ -6905,6 +7846,11 @@ snapshots: wrappy@1.0.2: {} + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + write-file-atomic@5.0.1: dependencies: imurmurhash: 0.1.4 From 72bdce199d981822db84273b2749a8becd59e971 Mon Sep 17 00:00:00 2001 From: Anmol1696 Date: Tue, 5 May 2026 17:09:06 +0800 Subject: [PATCH 03/10] feat(fn-client,fn-cli): programmatic API + thin CLI wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 3 of the portable-functions toolkit. Composes fn-generator into a user-facing layer; closes the loop on the Starship-style split: fn-types → fn-generator → fn-client → fn-cli. @constructive-io/fn-client@0.1.0 - FnClient class wraps FnGenerator and adds: - JSON config loading (fn.config.json or .fnconfig.json) — .ts/.js loading deferred until we add an esbuild/jiti loader. - loadManifest() — reads generated/functions-manifest.json. - defaultProcessDefs() — derives DevProcessDef[] from the manifest. - build({ only? }) — runs `pnpm -r build` with optional filter. - dev({ only?, env?, jobService? }) — spawns Node child processes, returns a DevHandle with pids and a SIGTERM-based stop(). - Job-service is opt-in (jobs-bundle preset is wired by passing `dev({ jobService: ... })`); functions-only consumers omit it. - Smoke tests cover discover/generate/manifest round-trip and defaultProcessDefs derivation. @constructive-io/fn-cli@0.1.0 - bin/fn executable (chmod +x in build) using minimist for argparse. - Subcommands: generate (--only, --packages-only), build, dev, manifest, verify, help. - Each command is ~10 lines: parse → FnClient method → format output. - Verified end-to-end against the brasilia repo: fn generate ⇒ idempotent (0 file changes on rerun) fn manifest ⇒ prints functions-manifest.json fn verify ⇒ "OK: 4 function(s) in sync." The brasilia scripts/generate.ts, scripts/dev.ts, scripts/docker-build.ts remain in place; Wave 4 will collapse them into one-line shims. --- packages/fn-cli/README.md | 25 ++++ packages/fn-cli/package.json | 33 ++++++ packages/fn-cli/src/bin/fn.ts | 10 ++ packages/fn-cli/src/cli.ts | 49 ++++++++ packages/fn-cli/src/commands.ts | 83 ++++++++++++++ packages/fn-cli/src/index.ts | 2 + packages/fn-cli/tsconfig.json | 10 ++ packages/fn-client/README.md | 39 +++++++ packages/fn-client/__tests__/client.test.ts | 51 +++++++++ packages/fn-client/jest.config.js | 6 + packages/fn-client/package.json | 33 ++++++ packages/fn-client/src/build.ts | 25 ++++ packages/fn-client/src/client.ts | 119 ++++++++++++++++++++ packages/fn-client/src/config.ts | 37 ++++++ packages/fn-client/src/dev.ts | 70 ++++++++++++ packages/fn-client/src/index.ts | 12 ++ packages/fn-client/src/types.ts | 50 ++++++++ packages/fn-client/tsconfig.json | 10 ++ pnpm-lock.yaml | 55 +++++++++ 19 files changed, 719 insertions(+) create mode 100644 packages/fn-cli/README.md create mode 100644 packages/fn-cli/package.json create mode 100644 packages/fn-cli/src/bin/fn.ts create mode 100644 packages/fn-cli/src/cli.ts create mode 100644 packages/fn-cli/src/commands.ts create mode 100644 packages/fn-cli/src/index.ts create mode 100644 packages/fn-cli/tsconfig.json create mode 100644 packages/fn-client/README.md create mode 100644 packages/fn-client/__tests__/client.test.ts create mode 100644 packages/fn-client/jest.config.js create mode 100644 packages/fn-client/package.json create mode 100644 packages/fn-client/src/build.ts create mode 100644 packages/fn-client/src/client.ts create mode 100644 packages/fn-client/src/config.ts create mode 100644 packages/fn-client/src/dev.ts create mode 100644 packages/fn-client/src/index.ts create mode 100644 packages/fn-client/src/types.ts create mode 100644 packages/fn-client/tsconfig.json diff --git a/packages/fn-cli/README.md b/packages/fn-cli/README.md new file mode 100644 index 0000000..3729337 --- /dev/null +++ b/packages/fn-cli/README.md @@ -0,0 +1,25 @@ +# @constructive-io/fn-cli + +Thin command-line wrapper around `@constructive-io/fn-client`. Exposes the `fn` executable. + +## Install + +```bash +pnpm add -D @constructive-io/fn-cli +pnpm add @constructive-io/fn-runtime # for handlers +``` + +## Commands + +```bash +fn generate [--only=] [--packages-only] +fn build [--only=] +fn dev [--only=] +fn manifest +fn verify +fn help +``` + +Common flags: `--root=`, `--config=`. + +This wave (Wave 3) ships `generate`, `build`, `dev`, `manifest`, and `verify`. `init`, `dockerfile`, and `k8s` (standalone manifest emission) land in Waves 4–5. diff --git a/packages/fn-cli/package.json b/packages/fn-cli/package.json new file mode 100644 index 0000000..9dfe352 --- /dev/null +++ b/packages/fn-cli/package.json @@ -0,0 +1,33 @@ +{ + "name": "@constructive-io/fn-cli", + "version": "0.1.0", + "description": "Thin command-line wrapper for the Constructive Functions toolkit. Exposes the `fn` executable.", + "author": "Constructive ", + "license": "SEE LICENSE IN LICENSE", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "bin": { + "fn": "dist/bin/fn.js" + }, + "files": [ + "dist", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -p tsconfig.json && chmod +x dist/bin/fn.js", + "clean": "rimraf dist" + }, + "dependencies": { + "@constructive-io/fn-client": "workspace:^", + "minimist": "^1.2.8" + }, + "devDependencies": { + "@types/minimist": "^1.2.5", + "@types/node": "^22.10.4", + "rimraf": "^5.0.5", + "typescript": "^5.1.6" + } +} diff --git a/packages/fn-cli/src/bin/fn.ts b/packages/fn-cli/src/bin/fn.ts new file mode 100644 index 0000000..a7e7e15 --- /dev/null +++ b/packages/fn-cli/src/bin/fn.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { run } from '../cli'; + +run().then( + (code) => process.exit(code), + (err) => { + process.stderr.write(`fn: ${err?.message ?? err}\n`); + process.exit(1); + } +); diff --git a/packages/fn-cli/src/cli.ts b/packages/fn-cli/src/cli.ts new file mode 100644 index 0000000..0582fe6 --- /dev/null +++ b/packages/fn-cli/src/cli.ts @@ -0,0 +1,49 @@ +import minimist from 'minimist'; +import { commands, type CommandFn } from './commands'; + +const HELP = `Usage: fn [options] + +Commands: + generate Generate workspace packages, k8s YAML, configmaps, skaffold.yaml + Flags: --only= --packages-only + build Run \`pnpm -r build\` (optionally filtered) + Flags: --only= + dev Start functions as local Node processes + Flags: --only= + manifest Print the on-disk functions-manifest.json + verify Sanity-check generated/ vs functions/ (no writes) + help Print this message + +Common flags: + --root= Repo root (default: cwd) + --config= Path to fn.config.json (default: /fn.config.json) +`; + +export const run = async (argv: string[] = process.argv.slice(2)): Promise => { + const parsed = minimist(argv, { + string: ['only', 'config', 'root'], + boolean: ['packages-only', 'help', 'version'], + alias: { h: 'help', v: 'version' }, + }); + + const [name] = parsed._; + + if (parsed.help || name === 'help' || (!name && argv.length === 0)) { + process.stdout.write(HELP); + return 0; + } + + if (parsed.version) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const pkg = require('../package.json'); + process.stdout.write(`${pkg.version}\n`); + return 0; + } + + const cmd: CommandFn | undefined = commands[name]; + if (!cmd) { + process.stderr.write(`Unknown command: ${name}\n\n${HELP}`); + return 1; + } + return cmd(parsed); +}; diff --git a/packages/fn-cli/src/commands.ts b/packages/fn-cli/src/commands.ts new file mode 100644 index 0000000..d8025fa --- /dev/null +++ b/packages/fn-cli/src/commands.ts @@ -0,0 +1,83 @@ +import { FnClient } from '@constructive-io/fn-client'; +import type { ParsedArgs } from 'minimist'; + +export type CommandFn = (args: ParsedArgs) => number | Promise; + +const buildClient = (args: ParsedArgs): FnClient => + new FnClient({ + rootDir: typeof args.root === 'string' ? args.root : undefined, + config: typeof args.config === 'string' ? args.config : undefined, + }); + +const cmdGenerate: CommandFn = (args) => { + const client = buildClient(args); + const result = client.generate({ + only: typeof args.only === 'string' ? args.only : undefined, + packagesOnly: Boolean(args['packages-only']), + }); + process.stdout.write( + `Generated ${result.functions.length} function(s); wrote ${result.filesWritten.length} file(s), ${result.symlinksCreated.length} symlink(s).\n` + ); + for (const f of result.filesWritten) process.stdout.write(` + ${f}\n`); + for (const s of result.symlinksCreated) process.stdout.write(` ~ ${s}\n`); + return 0; +}; + +const cmdBuild: CommandFn = async (args) => { + const client = buildClient(args); + await client.build({ only: typeof args.only === 'string' ? args.only : undefined }); + return 0; +}; + +const cmdDev: CommandFn = (args) => { + const client = buildClient(args); + const handle = client.dev({ only: typeof args.only === 'string' ? args.only : undefined }); + const stop = (): void => { + handle.stop().finally(() => process.exit(0)); + }; + process.on('SIGINT', stop); + process.on('SIGTERM', stop); + process.stdout.write(`Started: ${Object.keys(handle.pids).join(', ')}\n`); + // Hold the event loop until a signal arrives. + return new Promise(() => {}); +}; + +const cmdManifest: CommandFn = (args) => { + const client = buildClient(args); + const m = client.loadManifest(); + if (!m) { + process.stderr.write('functions-manifest.json not found. Run `fn generate` first.\n'); + return 1; + } + process.stdout.write(JSON.stringify(m, null, 2) + '\n'); + return 0; +}; + +const cmdVerify: CommandFn = (args) => { + const client = buildClient(args); + const fns = client.discover(); + const manifest = client.loadManifest(); + if (!manifest) { + process.stderr.write('functions-manifest.json not found. Run `fn generate` first.\n'); + return 1; + } + const expected = new Set(fns.map((f) => f.name)); + const actual = new Set(manifest.functions.map((f) => f.name)); + const missing = [...expected].filter((n) => !actual.has(n)); + const extra = [...actual].filter((n) => !expected.has(n)); + if (missing.length === 0 && extra.length === 0) { + process.stdout.write(`OK: ${fns.length} function(s) in sync.\n`); + return 0; + } + if (missing.length) process.stderr.write(`Missing in manifest: ${missing.join(', ')}\n`); + if (extra.length) process.stderr.write(`Stale in manifest: ${extra.join(', ')}\n`); + return 2; +}; + +export const commands: Record = { + generate: cmdGenerate, + build: cmdBuild, + dev: cmdDev, + manifest: cmdManifest, + verify: cmdVerify, +}; diff --git a/packages/fn-cli/src/index.ts b/packages/fn-cli/src/index.ts new file mode 100644 index 0000000..f5264d5 --- /dev/null +++ b/packages/fn-cli/src/index.ts @@ -0,0 +1,2 @@ +export { run } from './cli'; +export { commands } from './commands'; diff --git a/packages/fn-cli/tsconfig.json b/packages/fn-cli/tsconfig.json new file mode 100644 index 0000000..a45c527 --- /dev/null +++ b/packages/fn-cli/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/fn-client/README.md b/packages/fn-client/README.md new file mode 100644 index 0000000..b7ae1e0 --- /dev/null +++ b/packages/fn-client/README.md @@ -0,0 +1,39 @@ +# @constructive-io/fn-client + +Programmatic client for the Constructive Functions toolkit. Composes `@constructive-io/fn-generator` with config loading, manifest readers, and child-process orchestration so a host repo (or a thin CLI) can drive the whole pipeline from one object. + +## Usage + +```ts +import { FnClient } from '@constructive-io/fn-client'; + +const client = new FnClient({ + rootDir: process.cwd(), // default + config: { functionsDir: 'functions', outputDir: 'generated' }, +}); + +// 1. Generate workspace packages, k8s manifests, configmaps, skaffold.yaml. +client.generate(); + +// 2. Build the generated packages (runs `pnpm -r build`). +await client.build(); + +// 3. Start functions as local Node processes; stop them when done. +const dev = client.dev({ + env: { GRAPHQL_URL: 'http://localhost:3002/graphql' }, +}); + +await new Promise((r) => process.once('SIGINT', r)); +await dev.stop(); +``` + +## Surface + +- `discover(only?)` — list discovered functions with resolved port/type. +- `generate(opts?)` — run the full generator pipeline (delegates to `FnGenerator`). +- `loadManifest()` — read the on-disk `functions-manifest.json`. +- `defaultProcessDefs()` — derive `DevProcessDef[]` from the manifest (one per function, pointing at `//dist/index.js`). +- `build(opts?)` — `pnpm -r build`, optionally filtered. +- `dev(opts?)` — spawn process defs as `node` children, return a `DevHandle` with `pids` and `stop()`. + +The job-service is optional: pass `dev({ jobService: { name, script, port, env } })` to start it alongside the functions; omit to run functions only. diff --git a/packages/fn-client/__tests__/client.test.ts b/packages/fn-client/__tests__/client.test.ts new file mode 100644 index 0000000..dcae14f --- /dev/null +++ b/packages/fn-client/__tests__/client.test.ts @@ -0,0 +1,51 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { FnClient } from '../src'; + +const ROOT = path.resolve(__dirname, '..', '..', '..'); + +describe('FnClient', () => { + let tmpRoot: string; + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fn-client-test-')); + fs.symlinkSync(path.join(ROOT, 'functions'), path.join(tmpRoot, 'functions')); + fs.symlinkSync(path.join(ROOT, 'templates'), path.join(tmpRoot, 'templates')); + }); + + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('discovers functions in fs.readdirSync order', () => { + const client = new FnClient({ rootDir: tmpRoot }); + const fns = client.discover(); + const names = fns.map((f) => f.name); + expect(names).toContain('simple-email'); + expect(names).toContain('send-email-link'); + expect(fns.every((f) => f.port > 0 && f.port !== 8080)).toBe(true); + }); + + it('generate() writes a manifest that loadManifest() can read', () => { + const client = new FnClient({ rootDir: tmpRoot }); + const result = client.generate(); + expect(result.functions.length).toBeGreaterThan(0); + const m = client.loadManifest(); + expect(m).not.toBeNull(); + expect(m!.functions.map((f) => f.name).sort()).toEqual( + result.functions.map((f) => f.name).sort() + ); + }); + + it('defaultProcessDefs() builds one entry per function pointing at dist/index.js', () => { + const client = new FnClient({ rootDir: tmpRoot }); + client.generate(); + const defs = client.defaultProcessDefs(); + expect(defs.length).toBe(client.discover().length); + for (const def of defs) { + expect(def.script).toMatch(/dist\/index\.js$/); + expect(typeof def.port).toBe('number'); + } + }); +}); diff --git a/packages/fn-client/jest.config.js b/packages/fn-client/jest.config.js new file mode 100644 index 0000000..184cbe3 --- /dev/null +++ b/packages/fn-client/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/__tests__/**/*.test.ts'], + collectCoverageFrom: ['src/**/*.ts'], +}; diff --git a/packages/fn-client/package.json b/packages/fn-client/package.json new file mode 100644 index 0000000..f39e0ae --- /dev/null +++ b/packages/fn-client/package.json @@ -0,0 +1,33 @@ +{ + "name": "@constructive-io/fn-client", + "version": "0.1.0", + "description": "Programmatic client for the Constructive Functions toolkit. Wraps fn-generator with config loading, manifest readers, and child-process orchestration for local dev.", + "author": "Constructive ", + "license": "SEE LICENSE IN LICENSE", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "clean": "rimraf dist", + "test": "jest" + }, + "dependencies": { + "@constructive-io/fn-generator": "workspace:^", + "@constructive-io/fn-types": "workspace:^" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "^22.10.4", + "jest": "^29.7.0", + "rimraf": "^5.0.5", + "ts-jest": "^29.2.5", + "typescript": "^5.1.6" + } +} diff --git a/packages/fn-client/src/build.ts b/packages/fn-client/src/build.ts new file mode 100644 index 0000000..e8236c4 --- /dev/null +++ b/packages/fn-client/src/build.ts @@ -0,0 +1,25 @@ +import { spawn } from 'child_process'; +import type { BuildOptions } from './types'; + +/** + * Run `pnpm -r build` (optionally filtered to a single generated package). + * Resolves on a clean exit; rejects on non-zero status. + */ +export const runBuild = (rootDir: string, opts: BuildOptions = {}): Promise => { + const inherit = opts.inheritStdio !== false; + const args = ['-r']; + if (opts.only) args.push('--filter', `*${opts.only}*`); + args.push('run', 'build'); + + return new Promise((resolve, reject) => { + const child = spawn('pnpm', args, { + cwd: rootDir, + stdio: inherit ? 'inherit' : 'pipe', + }); + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`pnpm build exited with code ${code}`)); + }); + }); +}; diff --git a/packages/fn-client/src/client.ts b/packages/fn-client/src/client.ts new file mode 100644 index 0000000..4042d90 --- /dev/null +++ b/packages/fn-client/src/client.ts @@ -0,0 +1,119 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { FnConfig, FnRegistry } from '@constructive-io/fn-types'; +import { + FnGenerator, + type ApplyResult, + type FunctionInfo, + type GenerateOptions, +} from '@constructive-io/fn-generator'; +import { loadConfig } from './config'; +import { spawnProcesses } from './dev'; +import { runBuild } from './build'; +import type { + BuildOptions, + DevHandle, + DevOptions, + DevProcessDef, + FnClientOptions, +} from './types'; + +interface ResolvedClientOptions { + rootDir: string; + config: FnConfig; +} + +const resolve = (opts: FnClientOptions): ResolvedClientOptions => { + const rootDir = opts.rootDir ?? process.cwd(); + let config: FnConfig | null = null; + if (opts.config && typeof opts.config === 'object') { + config = opts.config; + } else if (typeof opts.config === 'string' || opts.config === undefined) { + config = loadConfig(typeof opts.config === 'string' ? opts.config : undefined, rootDir); + } + return { rootDir, config: config ?? {} }; +}; + +/** + * Programmatic client for the Constructive Functions toolkit. Wraps + * `FnGenerator` with config loading, manifest readers, and child-process + * orchestration for local dev. + */ +export class FnClient { + readonly rootDir: string; + readonly config: FnConfig; + private readonly generator: FnGenerator; + + constructor(options: FnClientOptions = {}) { + const { rootDir, config } = resolve(options); + this.rootDir = rootDir; + this.config = config; + this.generator = new FnGenerator({ + rootDir, + functionsDir: config.functionsDir + ? path.resolve(rootDir, config.functionsDir) + : undefined, + outputDir: config.outputDir ? path.resolve(rootDir, config.outputDir) : undefined, + namespace: config.k8s?.namespace ?? config.namespace, + }); + } + + /** Discover functions and resolve their info. */ + discover(only?: string): FunctionInfo[] { + return this.generator.discover(only); + } + + /** Generate all artifacts (delegates to FnGenerator). */ + generate(opts: GenerateOptions = {}): ApplyResult & { functions: FunctionInfo[] } { + return this.generator.generate(opts); + } + + /** Read the on-disk functions-manifest.json (returns null if missing). */ + loadManifest(): FnRegistry | null { + const outputDir = this.config.outputDir + ? path.resolve(this.rootDir, this.config.outputDir) + : path.resolve(this.rootDir, 'generated'); + const manifestPath = path.join(outputDir, 'functions-manifest.json'); + if (!fs.existsSync(manifestPath)) return null; + return JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as FnRegistry; + } + + /** Build process defs from the on-disk manifest (one per function). */ + defaultProcessDefs(): DevProcessDef[] { + const manifest = this.loadManifest(); + if (!manifest) return []; + const outputDir = this.config.outputDir + ? path.resolve(this.rootDir, this.config.outputDir) + : path.resolve(this.rootDir, 'generated'); + return manifest.functions.map((fn) => ({ + name: fn.name, + script: path.join(outputDir, fn.dir, 'dist', 'index.js'), + port: fn.port, + })); + } + + /** + * Run pnpm build (optionally filtered). Resolves when the build finishes. + * Uses the host's `pnpm` binary. + */ + async build(opts: BuildOptions = {}): Promise { + return runBuild(this.rootDir, opts); + } + + /** + * Start the function processes (and optionally a job-service) as Node + * children. Returns a handle whose `stop()` SIGTERMs them all and resolves + * when they exit. + */ + dev(opts: DevOptions = {}): DevHandle { + let defs = opts.processes ?? this.defaultProcessDefs(); + if (opts.only) defs = defs.filter((d) => d.name === opts.only); + const all = opts.jobService ? [opts.jobService, ...defs] : defs; + if (all.length === 0) { + throw new Error( + 'No processes to start. Run `fn generate && fn build` first, or pass `processes` explicitly.' + ); + } + return spawnProcesses(all, opts); + } +} diff --git a/packages/fn-client/src/config.ts b/packages/fn-client/src/config.ts new file mode 100644 index 0000000..c6a9e15 --- /dev/null +++ b/packages/fn-client/src/config.ts @@ -0,0 +1,37 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { FnConfig } from '@constructive-io/fn-types'; + +const CANDIDATES = ['fn.config.json', '.fnconfig.json']; + +/** + * Load a fn config from JSON. The .ts/.js variants are deferred to a later + * wave (will require an esbuild/jiti loader); for now supply a JSON file or + * pass the config object directly to FnClient. + * + * Returns `null` if no config file is found and no explicit path was given. + */ +export const loadConfig = (configPath?: string, rootDir = process.cwd()): FnConfig | null => { + if (configPath) { + const abs = path.isAbsolute(configPath) ? configPath : path.resolve(rootDir, configPath); + if (!fs.existsSync(abs)) { + throw new Error(`fn config not found at ${abs}`); + } + return readConfig(abs); + } + + for (const name of CANDIDATES) { + const abs = path.resolve(rootDir, name); + if (fs.existsSync(abs)) return readConfig(abs); + } + return null; +}; + +const readConfig = (abs: string): FnConfig => { + if (abs.endsWith('.json')) { + return JSON.parse(fs.readFileSync(abs, 'utf-8')) as FnConfig; + } + throw new Error( + `Unsupported fn config format at ${abs}. Use fn.config.json (.ts/.js loading lands in a later release).` + ); +}; diff --git a/packages/fn-client/src/dev.ts b/packages/fn-client/src/dev.ts new file mode 100644 index 0000000..ecc97ed --- /dev/null +++ b/packages/fn-client/src/dev.ts @@ -0,0 +1,70 @@ +import { spawn, ChildProcess } from 'child_process'; +import type { DevHandle, DevOptions, DevProcessDef } from './types'; + +const defaultLog = (name: string, line: string, stream: 'stdout' | 'stderr'): void => { + const target = stream === 'stderr' ? console.error : console.log; + target(`[${name}] ${line}`); +}; + +const defaultExit = (name: string, code: number | null): void => { + console.log(`[${name}] exited with code ${code}`); +}; + +const streamLines = ( + child: ChildProcess, + name: string, + cb: (name: string, line: string, stream: 'stdout' | 'stderr') => void +): void => { + child.stdout?.on('data', (data: Buffer) => { + for (const line of data.toString().trimEnd().split('\n')) cb(name, line, 'stdout'); + }); + child.stderr?.on('data', (data: Buffer) => { + for (const line of data.toString().trimEnd().split('\n')) cb(name, line, 'stderr'); + }); +}; + +/** + * Spawn the given process definitions as Node child processes and return a + * handle to manage their lifecycle. + * + * The shared env (`process.env` overlaid with `opts.env`) is layered under + * each process's `env`. `PORT` is always set from the def's `port`. + */ +export const spawnProcesses = ( + defs: DevProcessDef[], + opts: DevOptions = {} +): DevHandle => { + const onLog = opts.onLog ?? defaultLog; + const onExit = opts.onExit ?? defaultExit; + + const sharedEnv = { ...process.env, ...(opts.env ?? {}) } as Record; + + const children = new Map(); + + for (const def of defs) { + const env = { ...sharedEnv, ...(def.env ?? {}), PORT: String(def.port) }; + const child = spawn('node', [def.script], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + streamLines(child, def.name, onLog); + child.on('exit', (code) => onExit(def.name, code)); + children.set(def.name, child); + } + + const pids: Record = {}; + for (const [name, child] of children) pids[name] = child.pid; + + const stop = async (): Promise => { + const exits: Promise[] = []; + for (const [, child] of children) { + if (child.exitCode !== null) continue; + exits.push( + new Promise((resolve) => { + child.once('exit', () => resolve()); + child.kill('SIGTERM'); + }) + ); + } + await Promise.all(exits); + }; + + return { pids, stop }; +}; diff --git a/packages/fn-client/src/index.ts b/packages/fn-client/src/index.ts new file mode 100644 index 0000000..86a7ed9 --- /dev/null +++ b/packages/fn-client/src/index.ts @@ -0,0 +1,12 @@ +export { FnClient } from './client'; +export { loadConfig } from './config'; +export { runBuild } from './build'; +export { spawnProcesses } from './dev'; + +export type { + FnClientOptions, + DevProcessDef, + DevOptions, + DevHandle, + BuildOptions, +} from './types'; diff --git a/packages/fn-client/src/types.ts b/packages/fn-client/src/types.ts new file mode 100644 index 0000000..e9d44ea --- /dev/null +++ b/packages/fn-client/src/types.ts @@ -0,0 +1,50 @@ +import type { FnConfig, FnRegistry } from '@constructive-io/fn-types'; +import type { ApplyResult, FunctionInfo, GenerateOptions } from '@constructive-io/fn-generator'; + +export type { FnConfig, FnRegistry, ApplyResult, FunctionInfo, GenerateOptions }; + +export interface FnClientOptions { + /** Repo root. Default: process.cwd(). */ + rootDir?: string; + /** Path to a fn config file (JSON) or a literal config object. */ + config?: string | FnConfig; +} + +/** A single child process that FnClient.dev() spawns. */ +export interface DevProcessDef { + name: string; + /** Absolute path to a JS file run with `node`. */ + script: string; + /** Port the process listens on; passed as `PORT` env. */ + port: number; + /** Process-specific env to layer over the shared env. */ + env?: Record; +} + +export interface DevOptions { + /** Explicit process list. If omitted, derived from the functions manifest. */ + processes?: DevProcessDef[]; + /** Filter to a single process name. */ + only?: string; + /** Shared env layered under per-process env (over `process.env`). */ + env?: Record; + /** Optional job-service process to start alongside functions. */ + jobService?: DevProcessDef; + /** Streamed log line callback. Default: console.log/.error with `[name]` prefix. */ + onLog?: (name: string, line: string, stream: 'stdout' | 'stderr') => void; + /** Process-exit callback. Default: console.log. */ + onExit?: (name: string, code: number | null) => void; +} + +export interface DevHandle { + pids: Record; + /** Send SIGTERM to all children and resolve when they've all exited. */ + stop(): Promise; +} + +export interface BuildOptions { + /** Filter to a single function (by directory name). */ + only?: string; + /** Stream pnpm output to the parent stdio. Default: true. */ + inheritStdio?: boolean; +} diff --git a/packages/fn-client/tsconfig.json b/packages/fn-client/tsconfig.json new file mode 100644 index 0000000..1160dc4 --- /dev/null +++ b/packages/fn-client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "__tests__"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30b9c77..44e5e52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -283,6 +283,56 @@ importers: specifier: ^5.1.6 version: 5.9.3 + packages/fn-cli: + dependencies: + '@constructive-io/fn-client': + specifier: workspace:^ + version: link:../fn-client + minimist: + specifier: ^1.2.8 + version: 1.2.8 + devDependencies: + '@types/minimist': + specifier: ^1.2.5 + version: 1.2.5 + '@types/node': + specifier: ^22.10.4 + version: 22.19.3 + rimraf: + specifier: ^5.0.5 + version: 5.0.10 + typescript: + specifier: ^5.1.6 + version: 5.9.3 + + packages/fn-client: + dependencies: + '@constructive-io/fn-generator': + specifier: workspace:^ + version: link:../fn-generator + '@constructive-io/fn-types': + specifier: workspace:^ + version: link:../fn-types + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + '@types/node': + specifier: ^22.10.4 + version: 22.19.3 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.19.3) + rimraf: + specifier: ^5.0.5 + version: 5.0.10 + ts-jest: + specifier: ^29.2.5 + version: 29.4.6(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.3))(typescript@5.9.3) + typescript: + specifier: ^5.1.6 + version: 5.9.3 + packages/fn-generator: dependencies: '@constructive-io/fn-types': @@ -1119,6 +1169,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/minimist@1.2.5': + resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} + '@types/node@22.19.3': resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==} @@ -4665,6 +4718,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/minimist@1.2.5': {} + '@types/node@22.19.3': dependencies: undici-types: 6.21.0 From a0cfd7e71f2a7a9eba05388d9be08fce97f21f4c Mon Sep 17 00:00:00 2001 From: Anmol1696 Date: Tue, 5 May 2026 17:13:22 +0800 Subject: [PATCH 04/10] feat(job-service): replace hardcoded function registry with manifest loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 4b of the portable-functions toolkit. The hardcoded registry at job/service/src/index.ts:34-43 was the biggest portability blocker — adding a function required editing this map. Now the registry is loaded at runtime from one of three sources, in priority order: 1. FUNCTIONS_REGISTRY env var Format: "name:moduleName:port,..." (moduleName + port optional; missing moduleName falls back to @constructive-io/-fn). 2. FUNCTIONS_MANIFEST_PATH env var pointing to a JSON file with the existing functions-manifest.json shape. Manifest entries can carry an optional moduleName field; otherwise convention applies. 3. Default: /generated/functions-manifest.json (the file the toolkit's fn-generator produces). If no source resolves, the registry is empty; lookups still throw the legacy "Unknown function X" error to preserve existing behaviour. Implementation lives in job/service/src/registry.ts (new), exporting loadFunctionRegistry(env, cwd) for testability. job/service/src/index.ts imports the loader and replaces the const at the top of the file; the rest of the file is unchanged. types.ts: FunctionName widened from a literal union to `string` since names are dynamic. All existing call sites continue to compile. Tests: tests/integration/job-registry.test.ts covers the three sources, override behaviour, and the moduleName convention. All 6 cases pass. Existing unit (4 suites, 19 tests) and integration (runtime.test.ts, 3 tests) still pass. --- job/service/src/index.ts | 20 ++---- job/service/src/registry.ts | 84 +++++++++++++++++++++++++ job/service/src/types.ts | 7 ++- tests/integration/job-registry.test.ts | 87 ++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 16 deletions(-) create mode 100644 job/service/src/registry.ts create mode 100644 tests/integration/job-registry.test.ts diff --git a/job/service/src/index.ts b/job/service/src/index.ts index 103c811..7ebd3f6 100644 --- a/job/service/src/index.ts +++ b/job/service/src/index.ts @@ -25,22 +25,12 @@ import { KnativeJobsSvcResult, StartedFunction } from './types'; +import { + FunctionRegistryEntry, + loadFunctionRegistry +} from './registry'; -type FunctionRegistryEntry = { - moduleName: string; - defaultPort: number; -}; - -const functionRegistry: Record = { - 'simple-email': { - moduleName: '@constructive-io/simple-email-fn', - defaultPort: 8081 - }, - 'send-email-link': { - moduleName: '@constructive-io/send-email-link-fn', - defaultPort: 8082 - } -}; +const functionRegistry = loadFunctionRegistry(); const log = new Logger('knative-job-service'); const requireFn = createRequire(__filename); diff --git a/job/service/src/registry.ts b/job/service/src/registry.ts new file mode 100644 index 0000000..1a129fc --- /dev/null +++ b/job/service/src/registry.ts @@ -0,0 +1,84 @@ +/** + * Function registry loader for the in-process function server. + * + * Sources, in priority order: + * 1. FUNCTIONS_REGISTRY env var + * Format: "name:moduleName:port,..." (port optional) + * Example: "simple-email:@org/simple-email-fn:8081,foo:@org/foo-fn" + * 2. FUNCTIONS_MANIFEST_PATH env var pointing to a JSON file with shape + * { functions: [{ name, dir, port, type, moduleName? }] } + * 3. Default file: /generated/functions-manifest.json + * + * If no source resolves, the registry is empty; callers throw on lookup of + * an unknown function (preserves the legacy "Unknown function X" behaviour). + */ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface FunctionRegistryEntry { + moduleName: string; + defaultPort: number; +} + +export type FunctionRegistry = Record; + +const DEFAULT_MODULE_PREFIX = '@constructive-io/'; +const DEFAULT_MODULE_SUFFIX = '-fn'; + +const conventionalModuleName = (name: string): string => + `${DEFAULT_MODULE_PREFIX}${name}${DEFAULT_MODULE_SUFFIX}`; + +const parseEnvRegistry = (raw: string): FunctionRegistry => { + const out: FunctionRegistry = {}; + for (const pair of raw.split(',')) { + const trimmed = pair.trim(); + if (!trimmed) continue; + const [name, moduleName, portStr] = trimmed.split(':').map((s) => s.trim()); + if (!name) continue; + const portNumber = portStr ? Number(portStr) : NaN; + out[name] = { + moduleName: moduleName || conventionalModuleName(name), + defaultPort: Number.isFinite(portNumber) ? portNumber : 0, + }; + } + return out; +}; + +interface ManifestEntry { + name: string; + dir?: string; + port?: number; + type?: string; + moduleName?: string; +} + +const fromManifestEntry = (entry: ManifestEntry): FunctionRegistryEntry => ({ + moduleName: entry.moduleName ?? conventionalModuleName(entry.name), + defaultPort: typeof entry.port === 'number' ? entry.port : 0, +}); + +const loadManifestFile = (manifestPath: string): FunctionRegistry => { + const raw = fs.readFileSync(manifestPath, 'utf-8'); + const parsed = JSON.parse(raw) as { functions?: ManifestEntry[] }; + const out: FunctionRegistry = {}; + for (const entry of parsed.functions ?? []) { + if (!entry.name) continue; + out[entry.name] = fromManifestEntry(entry); + } + return out; +}; + +export const loadFunctionRegistry = ( + env: NodeJS.ProcessEnv = process.env, + cwd: string = process.cwd() +): FunctionRegistry => { + if (env.FUNCTIONS_REGISTRY) { + return parseEnvRegistry(env.FUNCTIONS_REGISTRY); + } + const manifestPath = + env.FUNCTIONS_MANIFEST_PATH ?? path.join(cwd, 'generated', 'functions-manifest.json'); + if (fs.existsSync(manifestPath)) { + return loadManifestFile(manifestPath); + } + return {}; +}; diff --git a/job/service/src/types.ts b/job/service/src/types.ts index 9732f6f..1a78aa2 100644 --- a/job/service/src/types.ts +++ b/job/service/src/types.ts @@ -1,4 +1,9 @@ -export type FunctionName = 'simple-email' | 'send-email-link'; +/** + * Function names are dynamic — looked up at runtime from the registry loaded + * from generated/functions-manifest.json (or the FUNCTIONS_MANIFEST_PATH / + * FUNCTIONS_REGISTRY env vars). Kept as an alias for narrowing intent. + */ +export type FunctionName = string; export type FunctionServiceConfig = { name: FunctionName; diff --git a/tests/integration/job-registry.test.ts b/tests/integration/job-registry.test.ts new file mode 100644 index 0000000..09b2b45 --- /dev/null +++ b/tests/integration/job-registry.test.ts @@ -0,0 +1,87 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { loadFunctionRegistry } from '../../job/service/src/registry'; + +describe('loadFunctionRegistry', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fn-registry-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns empty when no env vars and no manifest file exist', () => { + const reg = loadFunctionRegistry({}, tmpDir); + expect(reg).toEqual({}); + }); + + it('reads from generated/functions-manifest.json by default', () => { + const dir = path.join(tmpDir, 'generated'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'functions-manifest.json'), + JSON.stringify({ + functions: [ + { name: 'simple-email', dir: 'simple-email', port: 8081, type: 'node-graphql' }, + { name: 'send-email-link', dir: 'send-email-link', port: 8082, type: 'node-graphql' }, + ], + }) + ); + const reg = loadFunctionRegistry({}, tmpDir); + expect(reg['simple-email']).toEqual({ + moduleName: '@constructive-io/simple-email-fn', + defaultPort: 8081, + }); + expect(reg['send-email-link']).toEqual({ + moduleName: '@constructive-io/send-email-link-fn', + defaultPort: 8082, + }); + }); + + it('honours FUNCTIONS_MANIFEST_PATH override', () => { + const manifestPath = path.join(tmpDir, 'custom-manifest.json'); + fs.writeFileSync( + manifestPath, + JSON.stringify({ functions: [{ name: 'foo', port: 9000 }] }) + ); + const reg = loadFunctionRegistry({ FUNCTIONS_MANIFEST_PATH: manifestPath }, tmpDir); + expect(reg.foo).toEqual({ moduleName: '@constructive-io/foo-fn', defaultPort: 9000 }); + }); + + it('respects an explicit moduleName field in the manifest', () => { + const manifestPath = path.join(tmpDir, 'm.json'); + fs.writeFileSync( + manifestPath, + JSON.stringify({ + functions: [{ name: 'foo', moduleName: '@my-org/foo-handler', port: 9000 }], + }) + ); + const reg = loadFunctionRegistry({ FUNCTIONS_MANIFEST_PATH: manifestPath }, tmpDir); + expect(reg.foo.moduleName).toBe('@my-org/foo-handler'); + }); + + it('parses FUNCTIONS_REGISTRY env var (priority over manifest)', () => { + const manifestPath = path.join(tmpDir, 'm.json'); + fs.writeFileSync(manifestPath, JSON.stringify({ functions: [{ name: 'foo', port: 1 }] })); + const reg = loadFunctionRegistry( + { + FUNCTIONS_REGISTRY: 'foo:@org/foo:8081,bar:@org/bar', + FUNCTIONS_MANIFEST_PATH: manifestPath, + }, + tmpDir + ); + expect(reg.foo).toEqual({ moduleName: '@org/foo', defaultPort: 8081 }); + // bar has no port → 0 + expect(reg.bar).toEqual({ moduleName: '@org/bar', defaultPort: 0 }); + }); + + it('uses convention when FUNCTIONS_REGISTRY entry omits moduleName', () => { + const reg = loadFunctionRegistry({ FUNCTIONS_REGISTRY: 'baz::8083' }, tmpDir); + expect(reg.baz).toEqual({ moduleName: '@constructive-io/baz-fn', defaultPort: 8083 }); + }); +}); From 433bddfb780c76327a2bf03bfa4f40e0ee93bfa5 Mon Sep 17 00:00:00 2001 From: Anmol1696 Date: Tue, 5 May 2026 17:15:20 +0800 Subject: [PATCH 05/10] ci(publish): add toolkit publish workflow + portable-functions doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 5a of the portable-functions toolkit. CI now publishes the six @constructive-io/fn-* packages to npm with provenance when an `fn-v*` tag is pushed. The workflow can also be triggered via workflow_dispatch with dry_run=true for pre-release verification. Publish order (deps first): fn-types → fn-app (knative-job-fn) → fn-runtime → fn-generator → fn-client → fn-cli Each package already has publishConfig.access=public and files[] set from its respective Wave 1/2/3 commit. Also adds docs/portable-functions-toolkit.md: package map, customer- repo flow, registry loader behaviour, release procedure, and a manual verification checklist for the first release. Documents the deferred follow-ups (Wave 4c k8s manifest migration, .ts config loader, fn init/dockerfile/k8s standalone subcommands, fn-templates packaging). --- .github/workflows/publish.yaml | 73 +++++++++++++++++++++++ docs/portable-functions-toolkit.md | 96 ++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 .github/workflows/publish.yaml create mode 100644 docs/portable-functions-toolkit.md diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..ced9d7b --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,73 @@ +name: Publish toolkit packages + +on: + push: + tags: + - 'fn-v*' # toolkit release tag, e.g. fn-v0.1.0 + workflow_dispatch: + inputs: + dry_run: + description: 'Run pnpm publish --dry-run only' + type: boolean + default: true + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + name: Publish @constructive-io/fn-* to npm + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # required for npm provenance + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + cache: 'pnpm' + + - name: Generate function packages + run: node --experimental-strip-types scripts/generate.ts + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build toolkit packages + run: | + pnpm --filter @constructive-io/fn-types build + pnpm --filter @constructive-io/knative-job-fn build + pnpm --filter @constructive-io/fn-runtime build + pnpm --filter @constructive-io/fn-generator build + pnpm --filter @constructive-io/fn-client build + pnpm --filter @constructive-io/fn-cli build + + - name: Verify generator snapshot + run: pnpm --filter @constructive-io/fn-generator test + + - name: Publish (dry run for workflow_dispatch when requested) + if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true' + run: | + for pkg in fn-types fn-app fn-runtime fn-generator fn-client fn-cli; do + (cd "packages/$pkg" && pnpm publish --dry-run --no-git-checks --access public) + done + + - name: Publish to npm with provenance + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'true') + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: 'true' + run: | + # Order matters: deps first, dependents last. + for pkg in fn-types fn-app fn-runtime fn-generator fn-client fn-cli; do + (cd "packages/$pkg" && pnpm publish --no-git-checks --access public) + done diff --git a/docs/portable-functions-toolkit.md b/docs/portable-functions-toolkit.md new file mode 100644 index 0000000..fa98ca4 --- /dev/null +++ b/docs/portable-functions-toolkit.md @@ -0,0 +1,96 @@ +# Portable Functions Toolkit + +The Constructive Functions toolkit lets any external repo `pnpm add` a small set of npm packages, drop in its own `functions/` directory, and get a full code-gen + Docker + k8s + manifest-registry pipeline — without git submodules or copy-paste. + +## Package layout (Starship V2 style) + +``` +fn-cli ──► fn-client ──► fn-generator ──► fn-types + └────────────────────► fn-types + └────────► fn-types +fn-runtime ──► fn-types (handlers import this + fn-types) +knative-job-fn (fn-app) (low-level Express middleware) +``` + +| Package | Single responsibility | +|---|---| +| `@constructive-io/fn-types` | Source-of-truth TS types: `FunctionHandler`, `FunctionContext`, `HandlerManifest`, `FnRegistry`, `FnConfig` + `defineConfig()`. No logic. | +| `@constructive-io/fn-runtime` | Express server factory + GraphQL clients + log + job-callback wiring. The contract handlers import. | +| `@constructive-io/knative-job-fn` | Low-level Express middleware for Knative job request/response shape. fn-runtime depends on it. | +| `@constructive-io/fn-generator` | Programmatic builders that emit Dockerfiles, k8s YAML, configmaps, skaffold profiles, manifest registry. Pure functions; idempotent file I/O at the boundary. | +| `@constructive-io/fn-client` | Importable `FnClient` API — config loading, manifest reading, `pnpm build`, child-process orchestration for `dev`. | +| `@constructive-io/fn-cli` | The `fn` executable. Subcommands: `generate`, `build`, `dev`, `manifest`, `verify`. | + +## Customer repo experience + +``` +my-app/ +├── functions/ +│ └── send-welcome/ +│ ├── handler.json # {"name":"send-welcome","version":"0.1.0","type":"node-graphql"} +│ └── handler.ts # default-exported FunctionHandler +├── fn.config.json # FnConfig (typed via fn-types) +└── package.json +``` + +```bash +pnpm add -D @constructive-io/fn-cli +pnpm add @constructive-io/fn-runtime + +pnpm fn generate # write generated// + manifest + skaffold +pnpm fn build # pnpm -r build +pnpm fn manifest # cat generated/functions-manifest.json +pnpm fn verify # check manifest matches functions/ +pnpm fn dev # spawn each function as a Node child +``` + +## Job-service registry (when running the `jobs-bundle` preset) + +The job-service no longer hardcodes function names. It loads its registry at startup from one of three sources, in priority order: + +1. `FUNCTIONS_REGISTRY` env var + Format: `name:moduleName:port,...` — `moduleName` and `port` are optional (missing `moduleName` falls back to `@constructive-io/-fn`). + +2. `FUNCTIONS_MANIFEST_PATH` env var pointing to a JSON file with the existing `functions-manifest.json` shape. Manifest entries can carry an optional `moduleName` field; otherwise convention applies. + +3. Default file: `/generated/functions-manifest.json` — what `fn generate` produces. + +Empty registry is allowed; lookups still throw `Unknown function ""` to preserve the legacy behaviour. + +## Releasing the toolkit (Wave 5) + +The CI workflow at `.github/workflows/publish.yaml` publishes all six packages with [npm provenance](https://docs.npmjs.com/generating-provenance-statements) when a `fn-v*` tag is pushed. Steps: + +1. Update versions in each `packages/fn-*/package.json` (and `packages/fn-app/package.json`). Bump in lock-step for now; we'll move to changesets later. +2. Verify locally: + ```bash + pnpm --filter '@constructive-io/fn-*' build + pnpm --filter @constructive-io/fn-generator test + pnpm --filter @constructive-io/fn-client test + for pkg in fn-types fn-app fn-runtime fn-generator fn-client fn-cli; do + (cd "packages/$pkg" && pnpm publish --dry-run --no-git-checks --access public) + done + ``` +3. Tag and push: + ```bash + git tag fn-v0.1.0 + git push origin fn-v0.1.0 + ``` +4. CI publishes in dependency order: `fn-types` → `fn-app` → `fn-runtime` → `fn-generator` → `fn-client` → `fn-cli`. + +You can also run the workflow with `workflow_dispatch` (default `dry_run: true`) to verify packing before tagging. + +## Verification checklist (manual, before first release) + +- [ ] **Snapshot regression**: `pnpm --filter @constructive-io/fn-generator test` passes (asserts byte-identical output vs `scripts/generate.ts`). +- [ ] **Job-registry tests**: `pnpm exec jest tests/integration/job-registry.test.ts` — six cases pass. +- [ ] **Brasilia E2E**: with the live k8s stack running (`make skaffold-dev`), `pnpm test:e2e` still picks up jobs end-to-end. +- [ ] **Scratch repo**: in a fresh `/tmp/test-fn-app` repo, `pnpm add -D @constructive-io/fn-cli && pnpm add @constructive-io/fn-runtime`, add `functions/hello/handler.{json,ts}`, run `fn generate && fn build && fn manifest`. Confirm output is sensible and `docker build -f generated/hello/Dockerfile .` succeeds. +- [ ] **Hub integration**: in `constructive-hub/istanbul`, `pnpm bootstrap && pnpm start` still launches `send-email-link` and processes a job (the hub does not yet consume the new toolkit; this confirms Wave 1-3 didn't regress the existing submodule path). + +## Deferred follow-ups (not in this branch) + +- **Wave 4c — replace hand-written `k8s/base/functions/*.yaml` with generator output**. The hand-written manifests carry mailgun secrets, dry-run env vars, and a different image strategy (single bundled image, args-driven entry vs per-function image with Dockerfile CMD). Migrating safely requires either teaching `KnativeServiceBuilder` to emit those fields or providing a Kustomize patch overlay. Tracked separately. +- **fn.config.ts/.js loading** — JSON only for now. Adding `.ts` requires an `esbuild`/`jiti` loader. +- **`fn init` and `fn dockerfile` / `fn k8s` standalone subcommands** — the underlying builders exist (`buildPackages`, `buildSkaffold`); these are thin CLI wrappers to add later. +- **Templates packaging** — currently `templatesDir` is a constructor option pointing at the host repo's `templates/`. A future change can ship templates inside `fn-generator` (or a separate `fn-templates` package) so customer repos don't need their own copy. From 720afafb396cc5feeaacbb9225de550410cec06f Mon Sep 17 00:00:00 2001 From: Anmol1696 Date: Thu, 7 May 2026 15:31:45 +0800 Subject: [PATCH 06/10] feat(fn-cli): add fn init subcommand using genomic Templatizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 6 of the portable-functions toolkit. Adds the user-facing way to scaffold a new handler — bundled templates + minimal glue. @constructive-io/fn-cli@0.1.0: - New runtime dep on genomic@^5.3.11 (same engine pgpm init uses). - Bundled templates at packages/fn-cli/templates/handler/: - node-graphql/ (.boilerplate.json, handler.json, handler.ts) - python/ (.boilerplate.json, handler.json, handler.py) - New `fn init ` subcommand: - Positional name OR --name flag - --type=node-graphql|python (default: node-graphql) - --description= (optional) - --force overwrites existing dir - --no-tty / CI=true detection (matches pgpm init's pattern) - Refuses to overwrite without --force; clean error - Honors functionsDir from fn.config.json - Tarball includes templates/ via files: ["dist", "templates", "README.md"] so the bundled handler templates ship with the published package. - Help text and README updated. Tests: packages/fn-cli/__tests__/init.test.ts — 7 unit cases covering node-graphql + python scaffolding, dir-exists guard, --force, unknown-type rejection, missing-name error, and custom functionsDir from fn.config.json. All pass. Verified end-to-end against /tmp: $ fn init hello --no-tty → functions/hello/{handler.json,ts} $ fn init pyfn --type python --no-tty → functions/pyfn/{handler.json,py} $ fn init hello --no-tty → refuses (exit 1) $ fn init hello --no-tty --force --description "second pass" → overwrites --- packages/fn-cli/README.md | 24 +++- packages/fn-cli/__tests__/init.test.ts | 106 ++++++++++++++++++ packages/fn-cli/jest.config.js | 6 + packages/fn-cli/package.json | 8 +- packages/fn-cli/src/cli.ts | 7 +- packages/fn-cli/src/commands.ts | 85 ++++++++++++++ .../handler/node-graphql/.boilerplate.json | 20 ++++ .../handler/node-graphql/handler.json | 6 + .../templates/handler/node-graphql/handler.ts | 16 +++ .../handler/python/.boilerplate.json | 20 ++++ .../templates/handler/python/handler.json | 6 + .../templates/handler/python/handler.py | 9 ++ pnpm-lock.yaml | 40 +++++++ 13 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 packages/fn-cli/__tests__/init.test.ts create mode 100644 packages/fn-cli/jest.config.js create mode 100644 packages/fn-cli/templates/handler/node-graphql/.boilerplate.json create mode 100644 packages/fn-cli/templates/handler/node-graphql/handler.json create mode 100644 packages/fn-cli/templates/handler/node-graphql/handler.ts create mode 100644 packages/fn-cli/templates/handler/python/.boilerplate.json create mode 100644 packages/fn-cli/templates/handler/python/handler.json create mode 100644 packages/fn-cli/templates/handler/python/handler.py diff --git a/packages/fn-cli/README.md b/packages/fn-cli/README.md index 3729337..0c11748 100644 --- a/packages/fn-cli/README.md +++ b/packages/fn-cli/README.md @@ -12,6 +12,7 @@ pnpm add @constructive-io/fn-runtime # for handlers ## Commands ```bash +fn init [--type=node-graphql|python] [--description=] [--force] [--no-tty] fn generate [--only=] [--packages-only] fn build [--only=] fn dev [--only=] @@ -22,4 +23,25 @@ fn help Common flags: `--root=`, `--config=`. -This wave (Wave 3) ships `generate`, `build`, `dev`, `manifest`, and `verify`. `init`, `dockerfile`, and `k8s` (standalone manifest emission) land in Waves 4–5. +### `fn init` + +Scaffolds `functions//handler.{json,ts|py}` from a bundled template. Powered by [`genomic`](https://www.npmjs.com/package/genomic) — the same engine `pgpm init` uses — so prompt conventions and `--no-tty` flag-mapping match the rest of the Constructive ecosystem. + +```bash +# Interactive (prompts for description) +fn init send-welcome + +# Non-interactive +fn init send-welcome --no-tty --description "User welcome email" + +# Python handler +fn init pyfn --type python --no-tty + +# Custom functionsDir (read from fn.config.json) +fn init my-fn --no-tty # writes to /my-fn/ + +# Overwrite an existing dir +fn init dup --no-tty --force +``` + +Bundled templates live at `templates/handler/{node-graphql,python}/` inside this package. After `fn init`, run `fn generate` to stamp out the workspace package, then `fn build` to compile. diff --git a/packages/fn-cli/__tests__/init.test.ts b/packages/fn-cli/__tests__/init.test.ts new file mode 100644 index 0000000..6f3d978 --- /dev/null +++ b/packages/fn-cli/__tests__/init.test.ts @@ -0,0 +1,106 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { run } from '../src/cli'; + +describe('fn init', () => { + let tmpRoot: string; + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fn-init-')); + }); + + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('scaffolds a node-graphql handler from the bundled template', async () => { + const code = await run(['init', 'send-welcome', '--no-tty', '--root', tmpRoot]); + expect(code).toBe(0); + const handlerJson = path.join(tmpRoot, 'functions/send-welcome/handler.json'); + const handlerTs = path.join(tmpRoot, 'functions/send-welcome/handler.ts'); + expect(fs.existsSync(handlerJson)).toBe(true); + expect(fs.existsSync(handlerTs)).toBe(true); + const json = JSON.parse(fs.readFileSync(handlerJson, 'utf-8')); + expect(json.name).toBe('send-welcome'); + expect(json.type).toBe('node-graphql'); + expect(json.version).toBe('0.1.0'); + const ts = fs.readFileSync(handlerTs, 'utf-8'); + expect(ts).toContain("ctx.log.info('send-welcome invoked'"); + expect(ts).toContain("import type { FunctionHandler } from '@constructive-io/fn-runtime'"); + }); + + it('scaffolds a python handler when --type=python', async () => { + const code = await run([ + 'init', + 'pyfn', + '--type', + 'python', + '--no-tty', + '--root', + tmpRoot, + ]); + expect(code).toBe(0); + expect(fs.existsSync(path.join(tmpRoot, 'functions/pyfn/handler.py'))).toBe(true); + const json = JSON.parse( + fs.readFileSync(path.join(tmpRoot, 'functions/pyfn/handler.json'), 'utf-8') + ); + expect(json.type).toBe('python'); + }); + + it('refuses to overwrite without --force', async () => { + await run(['init', 'dup', '--no-tty', '--root', tmpRoot]); + const code = await run(['init', 'dup', '--no-tty', '--root', tmpRoot]); + expect(code).toBe(1); + }); + + it('overwrites with --force', async () => { + await run(['init', 'dup', '--no-tty', '--root', tmpRoot]); + const code = await run([ + 'init', + 'dup', + '--no-tty', + '--force', + '--description', + 'second', + '--root', + tmpRoot, + ]); + expect(code).toBe(0); + const json = JSON.parse( + fs.readFileSync(path.join(tmpRoot, 'functions/dup/handler.json'), 'utf-8') + ); + expect(json.description).toBe('second'); + }); + + it('rejects an unknown --type', async () => { + const code = await run([ + 'init', + 'broken', + '--type', + 'rust', + '--no-tty', + '--root', + tmpRoot, + ]); + expect(code).toBe(1); + expect(fs.existsSync(path.join(tmpRoot, 'functions/broken'))).toBe(false); + }); + + it('errors when no name is given', async () => { + const code = await run(['init', '--no-tty', '--root', tmpRoot]); + expect(code).toBe(1); + }); + + it('honors a custom functionsDir from fn.config.json', async () => { + fs.writeFileSync( + path.join(tmpRoot, 'fn.config.json'), + JSON.stringify({ functionsDir: 'src/handlers', outputDir: 'generated' }) + ); + const code = await run(['init', 'cfg', '--no-tty', '--root', tmpRoot]); + expect(code).toBe(0); + expect( + fs.existsSync(path.join(tmpRoot, 'src/handlers/cfg/handler.json')) + ).toBe(true); + }); +}); diff --git a/packages/fn-cli/jest.config.js b/packages/fn-cli/jest.config.js new file mode 100644 index 0000000..184cbe3 --- /dev/null +++ b/packages/fn-cli/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/__tests__/**/*.test.ts'], + collectCoverageFrom: ['src/**/*.ts'], +}; diff --git a/packages/fn-cli/package.json b/packages/fn-cli/package.json index 9dfe352..1fdeed1 100644 --- a/packages/fn-cli/package.json +++ b/packages/fn-cli/package.json @@ -11,6 +11,7 @@ }, "files": [ "dist", + "templates", "README.md" ], "publishConfig": { @@ -18,16 +19,21 @@ }, "scripts": { "build": "tsc -p tsconfig.json && chmod +x dist/bin/fn.js", - "clean": "rimraf dist" + "clean": "rimraf dist", + "test": "jest" }, "dependencies": { "@constructive-io/fn-client": "workspace:^", + "genomic": "^5.3.11", "minimist": "^1.2.8" }, "devDependencies": { + "@types/jest": "^29.5.14", "@types/minimist": "^1.2.5", "@types/node": "^22.10.4", + "jest": "^29.7.0", "rimraf": "^5.0.5", + "ts-jest": "^29.2.5", "typescript": "^5.1.6" } } diff --git a/packages/fn-cli/src/cli.ts b/packages/fn-cli/src/cli.ts index 0582fe6..a84a620 100644 --- a/packages/fn-cli/src/cli.ts +++ b/packages/fn-cli/src/cli.ts @@ -4,6 +4,9 @@ import { commands, type CommandFn } from './commands'; const HELP = `Usage: fn [options] Commands: + init Scaffold a new function under functions// + Flags: --type=node-graphql|python (default: node-graphql) + --description= --force --no-tty generate Generate workspace packages, k8s YAML, configmaps, skaffold.yaml Flags: --only= --packages-only build Run \`pnpm -r build\` (optionally filtered) @@ -21,8 +24,8 @@ Common flags: export const run = async (argv: string[] = process.argv.slice(2)): Promise => { const parsed = minimist(argv, { - string: ['only', 'config', 'root'], - boolean: ['packages-only', 'help', 'version'], + string: ['only', 'config', 'root', 'type', 'name', 'description'], + boolean: ['packages-only', 'help', 'version', 'no-tty', 'force'], alias: { h: 'help', v: 'version' }, }); diff --git a/packages/fn-cli/src/commands.ts b/packages/fn-cli/src/commands.ts index d8025fa..52c2e9d 100644 --- a/packages/fn-cli/src/commands.ts +++ b/packages/fn-cli/src/commands.ts @@ -1,4 +1,7 @@ +import * as fs from 'fs'; +import * as path from 'path'; import { FnClient } from '@constructive-io/fn-client'; +import { Templatizer } from 'genomic'; import type { ParsedArgs } from 'minimist'; export type CommandFn = (args: ParsedArgs) => number | Promise; @@ -9,6 +12,23 @@ const buildClient = (args: ParsedArgs): FnClient => config: typeof args.config === 'string' ? args.config : undefined, }); +/** Bundled handler templates ship next to the compiled commands. */ +const TEMPLATES_ROOT = path.resolve(__dirname, '..', 'templates', 'handler'); + +const KNOWN_HANDLER_TYPES = ['node-graphql', 'python'] as const; +type HandlerType = (typeof KNOWN_HANDLER_TYPES)[number]; + +const isHandlerType = (s: string): s is HandlerType => + (KNOWN_HANDLER_TYPES as readonly string[]).includes(s); + +const detectNoTty = (args: ParsedArgs): boolean => + Boolean( + args['no-tty'] || + args.noTty || + args.tty === false || + process.env.CI === 'true' + ); + const cmdGenerate: CommandFn = (args) => { const client = buildClient(args); const result = client.generate({ @@ -74,7 +94,72 @@ const cmdVerify: CommandFn = (args) => { return 2; }; +const cmdInit: CommandFn = async (args) => { + // Positional name first, then --name flag. + const positional = typeof args._[1] === 'string' ? args._[1] : undefined; + const name = positional ?? (typeof args.name === 'string' ? args.name : ''); + if (!name) { + process.stderr.write( + 'fn init: function name is required (positional or --name=)\n' + ); + return 1; + } + + const type = typeof args.type === 'string' ? args.type : 'node-graphql'; + if (!isHandlerType(type)) { + process.stderr.write( + `Unknown type "${type}". Available: ${KNOWN_HANDLER_TYPES.join(', ')}\n` + ); + return 1; + } + + const templateDir = path.join(TEMPLATES_ROOT, type); + if (!fs.existsSync(templateDir)) { + process.stderr.write( + `Bundled template missing at ${templateDir}. Reinstall @constructive-io/fn-cli.\n` + ); + return 1; + } + + const client = buildClient(args); + const functionsDir = client.config.functionsDir + ? path.resolve(client.rootDir, client.config.functionsDir) + : path.resolve(client.rootDir, 'functions'); + const outDir = path.join(functionsDir, name); + + if (fs.existsSync(outDir) && !args.force) { + process.stderr.write( + `${path.relative(client.rootDir, outDir) || outDir} already exists. Pass --force to overwrite.\n` + ); + return 1; + } + + // Genomic strips the ____ wrapping when matching argv keys, so plain + // names ('name', 'description', …) are the right shape. version defaults + // to 0.1.0 from the .boilerplate.json; users can edit handler.json after. + const argv: Record = { + name, + version: '0.1.0', + description: typeof args.description === 'string' ? args.description : '', + }; + + const templatizer = new Templatizer(); + await templatizer.process(templateDir, outDir, { + argv, + noTty: detectNoTty(args), + }); + + const written = fs + .readdirSync(outDir) + .map((f) => path.join(path.relative(client.rootDir, outDir) || outDir, f)); + process.stdout.write(`Created ${name} (${type}):\n`); + for (const f of written) process.stdout.write(` + ${f}\n`); + process.stdout.write('Next: run `fn generate` to stamp out workspace packages.\n'); + return 0; +}; + export const commands: Record = { + init: cmdInit, generate: cmdGenerate, build: cmdBuild, dev: cmdDev, diff --git a/packages/fn-cli/templates/handler/node-graphql/.boilerplate.json b/packages/fn-cli/templates/handler/node-graphql/.boilerplate.json new file mode 100644 index 0000000..de2f272 --- /dev/null +++ b/packages/fn-cli/templates/handler/node-graphql/.boilerplate.json @@ -0,0 +1,20 @@ +{ + "type": "module", + "requiresWorkspace": false, + "questions": [ + { + "name": "____name____", + "message": "Function name (used as directory and module identifier)", + "required": true + }, + { + "name": "____version____", + "message": "Initial version", + "default": "0.1.0" + }, + { + "name": "____description____", + "message": "Short description" + } + ] +} diff --git a/packages/fn-cli/templates/handler/node-graphql/handler.json b/packages/fn-cli/templates/handler/node-graphql/handler.json new file mode 100644 index 0000000..4cde6e8 --- /dev/null +++ b/packages/fn-cli/templates/handler/node-graphql/handler.json @@ -0,0 +1,6 @@ +{ + "name": "____name____", + "version": "____version____", + "type": "node-graphql", + "description": "____description____" +} diff --git a/packages/fn-cli/templates/handler/node-graphql/handler.ts b/packages/fn-cli/templates/handler/node-graphql/handler.ts new file mode 100644 index 0000000..963e3c2 --- /dev/null +++ b/packages/fn-cli/templates/handler/node-graphql/handler.ts @@ -0,0 +1,16 @@ +import type { FunctionHandler } from '@constructive-io/fn-runtime'; + +interface Payload { + // TODO: shape your function input +} + +interface Result { + ok: true; +} + +const handler: FunctionHandler = async (params, ctx) => { + ctx.log.info('____name____ invoked', params); + return { ok: true }; +}; + +export default handler; diff --git a/packages/fn-cli/templates/handler/python/.boilerplate.json b/packages/fn-cli/templates/handler/python/.boilerplate.json new file mode 100644 index 0000000..de2f272 --- /dev/null +++ b/packages/fn-cli/templates/handler/python/.boilerplate.json @@ -0,0 +1,20 @@ +{ + "type": "module", + "requiresWorkspace": false, + "questions": [ + { + "name": "____name____", + "message": "Function name (used as directory and module identifier)", + "required": true + }, + { + "name": "____version____", + "message": "Initial version", + "default": "0.1.0" + }, + { + "name": "____description____", + "message": "Short description" + } + ] +} diff --git a/packages/fn-cli/templates/handler/python/handler.json b/packages/fn-cli/templates/handler/python/handler.json new file mode 100644 index 0000000..782ea85 --- /dev/null +++ b/packages/fn-cli/templates/handler/python/handler.json @@ -0,0 +1,6 @@ +{ + "name": "____name____", + "version": "____version____", + "type": "python", + "description": "____description____" +} diff --git a/packages/fn-cli/templates/handler/python/handler.py b/packages/fn-cli/templates/handler/python/handler.py new file mode 100644 index 0000000..9e89b4b --- /dev/null +++ b/packages/fn-cli/templates/handler/python/handler.py @@ -0,0 +1,9 @@ +"""____name____ — handler implementation.""" +from typing import Any + + +async def handler(params: dict[str, Any]) -> dict[str, Any]: + """Process a job payload and return a JSON-serialisable result.""" + # TODO: implement + print(f"____name____ invoked: {params}") + return {"ok": True} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44e5e52..dba77bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,19 +288,31 @@ importers: '@constructive-io/fn-client': specifier: workspace:^ version: link:../fn-client + genomic: + specifier: ^5.3.11 + version: 5.3.11 minimist: specifier: ^1.2.8 version: 1.2.8 devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 '@types/minimist': specifier: ^1.2.5 version: 1.2.5 '@types/node': specifier: ^22.10.4 version: 22.19.3 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.19.3) rimraf: specifier: ^5.0.5 version: 5.0.10 + ts-jest: + specifier: ^29.2.5 + version: 29.4.6(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.3))(typescript@5.9.3) typescript: specifier: ^5.1.6 version: 5.9.3 @@ -1412,6 +1424,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + appstash@0.7.0: + resolution: {integrity: sha512-UExc8kEseReJRbllAkQ/qeW+jHb4iVFR8bLfggSLvSO7LwiVjQWfnQxN+ToLkVBKqMbIENrLUTvynMSEC73xUg==} + argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -2014,6 +2029,9 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-and-require-package-json@0.9.1: + resolution: {integrity: sha512-jFpCL0XgjipSk109viUtfp+NyR/oW6a4Xus4tV3UYkmCbsjisEeZD1x5QnD1NDDK/hXas1WFs4yO13L4TPXWlQ==} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -2065,6 +2083,9 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + genomic@5.3.11: + resolution: {integrity: sha512-Db2GKcRqCd3jkgYikB23gJA5qzqaNEPmaDzXYzep3Zz8cBgPQD6aV+ZLlfMgsrj6WKCe+pjgT4qoQwHiEyUNkg==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -2241,6 +2262,9 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + inquirerer@4.8.1: + resolution: {integrity: sha512-X8cPy91JMH6EmUPUqgnxc+oYssHdQlitWR23youH2208F2enxElCKc6Mt/5H8KAupYDgOuRuyBO+SRaRXStj8A==} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -4948,6 +4972,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + appstash@0.7.0: {} + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -5706,6 +5732,8 @@ snapshots: transitivePeerDependencies: - supports-color + find-and-require-package-json@0.9.1: {} + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -5749,6 +5777,11 @@ snapshots: function-bind@1.1.2: {} + genomic@5.3.11: + dependencies: + appstash: 0.7.0 + inquirerer: 4.8.1 + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -5941,6 +5974,13 @@ snapshots: ini@1.3.8: {} + inquirerer@4.8.1: + dependencies: + deepmerge: 4.3.1 + find-and-require-package-json: 0.9.1 + minimist: 1.2.8 + yanse: 0.2.1 + ipaddr.js@1.9.1: {} is-arrayish@0.2.1: {} From bfc9e1530d0fc80cdc320034fb2a66dccf85e9e8 Mon Sep 17 00:00:00 2001 From: Anmol1696 Date: Thu, 7 May 2026 15:33:13 +0800 Subject: [PATCH 07/10] ci(test): run toolkit package tests + fn init e2e on every PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 7 of the portable-functions toolkit. The existing test workflow ran pnpm test:unit and pnpm test:integration only, which skipped the package-local jest suites (fn-generator's snapshot, fn-client's API tests, fn-cli's init tests). That gap is now closed. Two new jobs in .github/workflows/test.yaml: 1. **toolkit** — installs, builds all six fn-* packages in dependency order, then runs jest in fn-generator, fn-client, and fn-cli. This catches regressions in the toolkit code paths that the existing tests:unit/integration jobs don't reach. 2. **fn-init-e2e** — builds fn-cli, then runs the new binary integration suite at tests/integration/fn-init.test.ts. The suite spawns the compiled `dist/bin/fn.js` against a tmpdir and asserts: - node-graphql scaffolding produces handler.json + handler.ts - python scaffolding produces handler.py with type=python - duplicate scaffold without --force fails with exit 1 - --force overwrites and updates description - fn generate finds the just-scaffolded function This proves the bundled templates resolve correctly when fn is invoked from a non-package cwd — the load-bearing scenario for end-user installs. All 5 binary tests pass locally. Existing CI jobs (build/lint, unit, integration) untouched — these run in addition. --- .github/workflows/test.yaml | 49 ++++++++++++ tests/integration/fn-init.test.ts | 119 ++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 tests/integration/fn-init.test.ts diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index eaee500..84b9f03 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -40,3 +40,52 @@ jobs: - run: pnpm install - run: pnpm build - run: pnpm test:integration + + toolkit: + name: Toolkit package tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v5 + with: + node-version: '22' + cache: 'pnpm' + - run: node --experimental-strip-types scripts/generate.ts + - run: pnpm install + - name: Build toolkit packages + run: | + pnpm --filter @constructive-io/fn-types build + pnpm --filter @constructive-io/knative-job-fn build + pnpm --filter @constructive-io/fn-runtime build + pnpm --filter @constructive-io/fn-generator build + pnpm --filter @constructive-io/fn-client build + pnpm --filter @constructive-io/fn-cli build + - name: Run toolkit unit tests + run: | + pnpm --filter @constructive-io/fn-generator test + pnpm --filter @constructive-io/fn-client test + pnpm --filter @constructive-io/fn-cli test + + fn-init-e2e: + name: fn init end-to-end + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v5 + with: + node-version: '22' + cache: 'pnpm' + - run: node --experimental-strip-types scripts/generate.ts + - run: pnpm install + - name: Build fn-cli (transitive) + run: | + pnpm --filter @constructive-io/fn-types build + pnpm --filter @constructive-io/knative-job-fn build + pnpm --filter @constructive-io/fn-runtime build + pnpm --filter @constructive-io/fn-generator build + pnpm --filter @constructive-io/fn-client build + pnpm --filter @constructive-io/fn-cli build + - name: Binary integration test + run: pnpm exec jest tests/integration/fn-init.test.ts diff --git a/tests/integration/fn-init.test.ts b/tests/integration/fn-init.test.ts new file mode 100644 index 0000000..bfc89c7 --- /dev/null +++ b/tests/integration/fn-init.test.ts @@ -0,0 +1,119 @@ +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const CLI_BIN = path.resolve( + __dirname, + '..', + '..', + 'packages', + 'fn-cli', + 'dist', + 'bin', + 'fn.js' +); + +const runFn = (args: string[], cwd: string) => + spawnSync(process.execPath, [CLI_BIN, ...args], { + cwd, + env: { ...process.env, CI: 'true' }, + encoding: 'utf-8', + }); + +describe('fn init (binary integration)', () => { + let tmpRoot: string; + + beforeAll(() => { + if (!fs.existsSync(CLI_BIN)) { + throw new Error( + `fn-cli binary not built. Run \`pnpm --filter @constructive-io/fn-cli build\` first. Looked at ${CLI_BIN}.` + ); + } + }); + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fn-init-bin-')); + }); + + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('scaffolds a node-graphql handler when invoked as a binary', () => { + const result = runFn(['init', 'welcome', '--no-tty'], tmpRoot); + expect(result.status).toBe(0); + expect(result.stdout).toContain('Created welcome (node-graphql)'); + expect( + fs.existsSync(path.join(tmpRoot, 'functions/welcome/handler.json')) + ).toBe(true); + expect( + fs.existsSync(path.join(tmpRoot, 'functions/welcome/handler.ts')) + ).toBe(true); + }); + + it('scaffolds a python handler', () => { + const result = runFn( + ['init', 'pybot', '--type', 'python', '--no-tty'], + tmpRoot + ); + expect(result.status).toBe(0); + expect( + fs.existsSync(path.join(tmpRoot, 'functions/pybot/handler.py')) + ).toBe(true); + const json = JSON.parse( + fs.readFileSync( + path.join(tmpRoot, 'functions/pybot/handler.json'), + 'utf-8' + ) + ); + expect(json.type).toBe('python'); + }); + + it('refuses to overwrite an existing function without --force', () => { + expect(runFn(['init', 'a', '--no-tty'], tmpRoot).status).toBe(0); + const second = runFn(['init', 'a', '--no-tty'], tmpRoot); + expect(second.status).toBe(1); + expect(second.stderr).toContain('already exists'); + }); + + it('overwrites with --force', () => { + expect(runFn(['init', 'b', '--no-tty'], tmpRoot).status).toBe(0); + const second = runFn( + [ + 'init', + 'b', + '--no-tty', + '--force', + '--description', + 'overwritten', + ], + tmpRoot + ); + expect(second.status).toBe(0); + const json = JSON.parse( + fs.readFileSync(path.join(tmpRoot, 'functions/b/handler.json'), 'utf-8') + ); + expect(json.description).toBe('overwritten'); + }); + + it('fn generate finds the just-scaffolded function', () => { + runFn(['init', 'discoverme', '--no-tty'], tmpRoot); + // fn generate needs a templates/ dir to do its full pipeline. For + // this binary test we only verify discovery — the scaffolded + // handler.json is enough for the scanner to enumerate it. + fs.mkdirSync(path.join(tmpRoot, 'templates', 'node-graphql'), { + recursive: true, + }); + fs.mkdirSync(path.join(tmpRoot, 'templates', 'shared'), { + recursive: true, + }); + // Empty templates → generator runs but produces no per-fn files; + // it still emits the manifest. Use --packages-only to skip k8s. + const result = runFn( + ['generate', '--only', 'discoverme', '--packages-only'], + tmpRoot + ); + expect(result.status).toBe(0); + }); +}); From 070c727d14ecb68a497616f1e360b83818788968 Mon Sep 17 00:00:00 2001 From: Anmol1696 Date: Thu, 7 May 2026 15:33:58 +0800 Subject: [PATCH 08/10] docs: lead with fn init in the toolkit README and main README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 8 of the portable-functions toolkit. Updates user-facing docs so that `fn init` is the first thing a new user sees, not the last. - docs/portable-functions-toolkit.md: replace the "Customer repo experience" section with a "Quick start" that begins with `fn init send-welcome --no-tty` rather than assuming the user has hand-authored a handler.json. Add the full CLI surface table including `init` and its flags. Note the genomic-via-pgpm-init alignment so users who know `pgpm init` carry their knowledge over. - README.md: lead with the in-another-repo flow. The brasilia-as- dogfood instructions stay below as the second quick-start. New paragraph at top points at the toolkit guide. The starter-kit / pgpm init scaffold-the-whole-project flow is deliberately left as future work — the addendum in the plan file documents it but it lands in its own follow-on. --- README.md | 17 ++++++++++- docs/portable-functions-toolkit.md | 45 ++++++++++++++++++++++-------- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index af1a635..61eb900 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,22 @@ Functions playground for Constructive — isolated workspace for building, testing, and deploying Knative-style HTTP functions. -## Quick Start +This repo is also the source of the **Portable Functions Toolkit**: a set of `@constructive-io/fn-*` npm packages that any external repo can `pnpm add` to get the same code-gen + Docker + k8s pipeline against its own `functions/` directory. See [docs/portable-functions-toolkit.md](docs/portable-functions-toolkit.md) for the full toolkit guide. + +## Quick start (in another repo) + +```bash +pnpm add -D @constructive-io/fn-cli +pnpm add @constructive-io/fn-runtime + +pnpm fn init send-welcome --no-tty # scaffold functions/send-welcome/ +pnpm fn generate # stamp out generated// packages +pnpm install # link the new workspaces +pnpm fn build # compile +pnpm fn dev # run functions as local Node processes +``` + +## Quick start (this repo, dogfood) ```bash # Install dependencies diff --git a/docs/portable-functions-toolkit.md b/docs/portable-functions-toolkit.md index fa98ca4..655e3f3 100644 --- a/docs/portable-functions-toolkit.md +++ b/docs/portable-functions-toolkit.md @@ -19,9 +19,29 @@ knative-job-fn (fn-app) (low-level Express middleware) | `@constructive-io/knative-job-fn` | Low-level Express middleware for Knative job request/response shape. fn-runtime depends on it. | | `@constructive-io/fn-generator` | Programmatic builders that emit Dockerfiles, k8s YAML, configmaps, skaffold profiles, manifest registry. Pure functions; idempotent file I/O at the boundary. | | `@constructive-io/fn-client` | Importable `FnClient` API — config loading, manifest reading, `pnpm build`, child-process orchestration for `dev`. | -| `@constructive-io/fn-cli` | The `fn` executable. Subcommands: `generate`, `build`, `dev`, `manifest`, `verify`. | +| `@constructive-io/fn-cli` | The `fn` executable. Subcommands: `init`, `generate`, `build`, `dev`, `manifest`, `verify`. | -## Customer repo experience +## Quick start + +```bash +# In a fresh project +pnpm add -D @constructive-io/fn-cli +pnpm add @constructive-io/fn-runtime + +# Scaffold a function +pnpm fn init send-welcome --no-tty --description "Welcome email sender" +# → functions/send-welcome/{handler.json, handler.ts} + +# Stamp out the workspace package, build, run +pnpm fn generate +pnpm install # link the just-created generated/* workspaces +pnpm fn build +pnpm fn dev # functions run as local Node processes +``` + +`fn init` uses [`genomic`](https://www.npmjs.com/package/genomic) under the hood — the same template engine `pgpm init` uses — so the prompt conventions and `--no-tty` flag-mapping match the rest of the Constructive ecosystem. Two handler types ship today: `--type=node-graphql` (default) and `--type=python`. + +## Repo layout the toolkit expects ``` my-app/ @@ -29,21 +49,24 @@ my-app/ │ └── send-welcome/ │ ├── handler.json # {"name":"send-welcome","version":"0.1.0","type":"node-graphql"} │ └── handler.ts # default-exported FunctionHandler -├── fn.config.json # FnConfig (typed via fn-types) +├── fn.config.json # FnConfig (typed via fn-types) — optional └── package.json ``` -```bash -pnpm add -D @constructive-io/fn-cli -pnpm add @constructive-io/fn-runtime +## CLI surface -pnpm fn generate # write generated// + manifest + skaffold -pnpm fn build # pnpm -r build -pnpm fn manifest # cat generated/functions-manifest.json -pnpm fn verify # check manifest matches functions/ -pnpm fn dev # spawn each function as a Node child +```bash +fn init [--type=node-graphql|python] [--description=] [--force] [--no-tty] +fn generate [--only=] [--packages-only] +fn build [--only=] +fn dev [--only=] +fn manifest # print on-disk functions-manifest.json +fn verify # check manifest matches functions/ +fn --version # print fn-cli version ``` +Common flags: `--root=`, `--config=`. + ## Job-service registry (when running the `jobs-bundle` preset) The job-service no longer hardcodes function names. It loads its registry at startup from one of three sources, in priority order: From 7675bb0ba34b3a4a817e59b57d0e71e2676883fc Mon Sep 17 00:00:00 2001 From: Anmol1696 Date: Fri, 8 May 2026 14:09:11 +0800 Subject: [PATCH 09/10] ci(docker): drop unused pnpm setup that broke setup-node@v5 caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Build -fn matrix jobs were failing in "Post Setup Node.js" with: Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved. Root cause: pnpm/action-setup@v6 ran before actions/setup-node@v5, exposing PNPM_HOME. setup-node@v5 then auto-detected pnpm via the packageManager field in root package.json and enabled pnpm-store caching — but these jobs never run pnpm install (they only invoke node --experimental-strip-types scripts/generate.ts). The store path never gets created, and v5 promotes the missing-path warning to an error during cache save. Fix: remove pnpm/action-setup from docker.yaml since the workflow doesn't use pnpm on the runner. The actual function builds run inside Docker, which installs pnpm itself via "npm install -g pnpm@". simple-email had been passing only by accident (cache hit from a previous warm run); the other three were deterministic failures. --- .github/workflows/docker.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 70a5f06..39f2225 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -56,9 +56,12 @@ jobs: - name: Checkout uses: actions/checkout@v5 - - name: Setup pnpm - uses: pnpm/action-setup@v6 - + # No pnpm/action-setup here: this workflow only runs + # `node --experimental-strip-types scripts/generate.ts` on the + # runner (zero deps); the actual builds happen inside Docker. + # Including pnpm makes setup-node@v5 auto-detect it via + # packageManager + PNPM_HOME and try to save a cache for an + # empty store, which fails with "Path Validation Error". - name: Setup Node.js uses: actions/setup-node@v5 with: From 9af70f6c9b16a2cc5a1b65b5ecbabc28601c2399 Mon Sep 17 00:00:00 2001 From: Anmol1696 Date: Fri, 8 May 2026 14:11:43 +0800 Subject: [PATCH 10/10] ci(docker): pin setup-node to v4 to avoid v5 pnpm auto-cache breakage Previous commit removed pnpm/action-setup, but that broke a different thing: setup-node@v5 still tries to invoke pnpm at setup time when it auto-detects the packageManager field in package.json, and now no pnpm exists on PATH ("Unable to locate executable file: pnpm"). Restoring pnpm setup AND pinning setup-node to v4. v4 doesn't have the aggressive auto-cache behavior that caused the original "Path Validation Error" failure. The other workflows (ci.yaml, test.yaml, publish.yaml) keep v5 because they actually run `pnpm install` so the store path exists when v5 tries to cache it. --- .github/workflows/docker.yaml | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 39f2225..c8c6023 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -56,14 +56,17 @@ jobs: - name: Checkout uses: actions/checkout@v5 - # No pnpm/action-setup here: this workflow only runs - # `node --experimental-strip-types scripts/generate.ts` on the - # runner (zero deps); the actual builds happen inside Docker. - # Including pnpm makes setup-node@v5 auto-detect it via - # packageManager + PNPM_HOME and try to save a cache for an - # empty store, which fails with "Path Validation Error". + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + # Pinned to v4: setup-node@v5 auto-detects pnpm via the + # packageManager field in package.json and tries to cache the + # store, which fails here because this workflow doesn't run + # `pnpm install` on the runner — the store path doesn't exist + # and v5 promotes the "missing path" warning to an error. + # Sticking to v4 until v5's auto-cache can be opted out cleanly. - name: Setup Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v4 with: node-version: '22'