Skip to content

Commit 720afaf

Browse files
committed
feat(fn-cli): add fn init subcommand using genomic Templatizer
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 <name>` subcommand: - Positional name OR --name flag - --type=node-graphql|python (default: node-graphql) - --description=<d> (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
1 parent 433bddf commit 720afaf

13 files changed

Lines changed: 349 additions & 4 deletions

File tree

packages/fn-cli/README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pnpm add @constructive-io/fn-runtime # for handlers
1212
## Commands
1313

1414
```bash
15+
fn init <name> [--type=node-graphql|python] [--description=<d>] [--force] [--no-tty]
1516
fn generate [--only=<name>] [--packages-only]
1617
fn build [--only=<name>]
1718
fn dev [--only=<name>]
@@ -22,4 +23,25 @@ fn help
2223

2324
Common flags: `--root=<dir>`, `--config=<file>`.
2425

25-
This wave (Wave 3) ships `generate`, `build`, `dev`, `manifest`, and `verify`. `init`, `dockerfile`, and `k8s` (standalone manifest emission) land in Waves 4–5.
26+
### `fn init`
27+
28+
Scaffolds `functions/<name>/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.
29+
30+
```bash
31+
# Interactive (prompts for description)
32+
fn init send-welcome
33+
34+
# Non-interactive
35+
fn init send-welcome --no-tty --description "User welcome email"
36+
37+
# Python handler
38+
fn init pyfn --type python --no-tty
39+
40+
# Custom functionsDir (read from fn.config.json)
41+
fn init my-fn --no-tty # writes to <functionsDir>/my-fn/
42+
43+
# Overwrite an existing dir
44+
fn init dup --no-tty --force
45+
```
46+
47+
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.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import * as fs from 'fs';
2+
import * as os from 'os';
3+
import * as path from 'path';
4+
import { run } from '../src/cli';
5+
6+
describe('fn init', () => {
7+
let tmpRoot: string;
8+
9+
beforeEach(() => {
10+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fn-init-'));
11+
});
12+
13+
afterEach(() => {
14+
fs.rmSync(tmpRoot, { recursive: true, force: true });
15+
});
16+
17+
it('scaffolds a node-graphql handler from the bundled template', async () => {
18+
const code = await run(['init', 'send-welcome', '--no-tty', '--root', tmpRoot]);
19+
expect(code).toBe(0);
20+
const handlerJson = path.join(tmpRoot, 'functions/send-welcome/handler.json');
21+
const handlerTs = path.join(tmpRoot, 'functions/send-welcome/handler.ts');
22+
expect(fs.existsSync(handlerJson)).toBe(true);
23+
expect(fs.existsSync(handlerTs)).toBe(true);
24+
const json = JSON.parse(fs.readFileSync(handlerJson, 'utf-8'));
25+
expect(json.name).toBe('send-welcome');
26+
expect(json.type).toBe('node-graphql');
27+
expect(json.version).toBe('0.1.0');
28+
const ts = fs.readFileSync(handlerTs, 'utf-8');
29+
expect(ts).toContain("ctx.log.info('send-welcome invoked'");
30+
expect(ts).toContain("import type { FunctionHandler } from '@constructive-io/fn-runtime'");
31+
});
32+
33+
it('scaffolds a python handler when --type=python', async () => {
34+
const code = await run([
35+
'init',
36+
'pyfn',
37+
'--type',
38+
'python',
39+
'--no-tty',
40+
'--root',
41+
tmpRoot,
42+
]);
43+
expect(code).toBe(0);
44+
expect(fs.existsSync(path.join(tmpRoot, 'functions/pyfn/handler.py'))).toBe(true);
45+
const json = JSON.parse(
46+
fs.readFileSync(path.join(tmpRoot, 'functions/pyfn/handler.json'), 'utf-8')
47+
);
48+
expect(json.type).toBe('python');
49+
});
50+
51+
it('refuses to overwrite without --force', async () => {
52+
await run(['init', 'dup', '--no-tty', '--root', tmpRoot]);
53+
const code = await run(['init', 'dup', '--no-tty', '--root', tmpRoot]);
54+
expect(code).toBe(1);
55+
});
56+
57+
it('overwrites with --force', async () => {
58+
await run(['init', 'dup', '--no-tty', '--root', tmpRoot]);
59+
const code = await run([
60+
'init',
61+
'dup',
62+
'--no-tty',
63+
'--force',
64+
'--description',
65+
'second',
66+
'--root',
67+
tmpRoot,
68+
]);
69+
expect(code).toBe(0);
70+
const json = JSON.parse(
71+
fs.readFileSync(path.join(tmpRoot, 'functions/dup/handler.json'), 'utf-8')
72+
);
73+
expect(json.description).toBe('second');
74+
});
75+
76+
it('rejects an unknown --type', async () => {
77+
const code = await run([
78+
'init',
79+
'broken',
80+
'--type',
81+
'rust',
82+
'--no-tty',
83+
'--root',
84+
tmpRoot,
85+
]);
86+
expect(code).toBe(1);
87+
expect(fs.existsSync(path.join(tmpRoot, 'functions/broken'))).toBe(false);
88+
});
89+
90+
it('errors when no name is given', async () => {
91+
const code = await run(['init', '--no-tty', '--root', tmpRoot]);
92+
expect(code).toBe(1);
93+
});
94+
95+
it('honors a custom functionsDir from fn.config.json', async () => {
96+
fs.writeFileSync(
97+
path.join(tmpRoot, 'fn.config.json'),
98+
JSON.stringify({ functionsDir: 'src/handlers', outputDir: 'generated' })
99+
);
100+
const code = await run(['init', 'cfg', '--no-tty', '--root', tmpRoot]);
101+
expect(code).toBe(0);
102+
expect(
103+
fs.existsSync(path.join(tmpRoot, 'src/handlers/cfg/handler.json'))
104+
).toBe(true);
105+
});
106+
});

packages/fn-cli/jest.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = {
2+
preset: 'ts-jest',
3+
testEnvironment: 'node',
4+
testMatch: ['**/__tests__/**/*.test.ts'],
5+
collectCoverageFrom: ['src/**/*.ts'],
6+
};

packages/fn-cli/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,29 @@
1111
},
1212
"files": [
1313
"dist",
14+
"templates",
1415
"README.md"
1516
],
1617
"publishConfig": {
1718
"access": "public"
1819
},
1920
"scripts": {
2021
"build": "tsc -p tsconfig.json && chmod +x dist/bin/fn.js",
21-
"clean": "rimraf dist"
22+
"clean": "rimraf dist",
23+
"test": "jest"
2224
},
2325
"dependencies": {
2426
"@constructive-io/fn-client": "workspace:^",
27+
"genomic": "^5.3.11",
2528
"minimist": "^1.2.8"
2629
},
2730
"devDependencies": {
31+
"@types/jest": "^29.5.14",
2832
"@types/minimist": "^1.2.5",
2933
"@types/node": "^22.10.4",
34+
"jest": "^29.7.0",
3035
"rimraf": "^5.0.5",
36+
"ts-jest": "^29.2.5",
3137
"typescript": "^5.1.6"
3238
}
3339
}

packages/fn-cli/src/cli.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { commands, type CommandFn } from './commands';
44
const HELP = `Usage: fn <command> [options]
55
66
Commands:
7+
init <name> Scaffold a new function under functions/<name>/
8+
Flags: --type=node-graphql|python (default: node-graphql)
9+
--description=<d> --force --no-tty
710
generate Generate workspace packages, k8s YAML, configmaps, skaffold.yaml
811
Flags: --only=<name> --packages-only
912
build Run \`pnpm -r build\` (optionally filtered)
@@ -21,8 +24,8 @@ Common flags:
2124

2225
export const run = async (argv: string[] = process.argv.slice(2)): Promise<number> => {
2326
const parsed = minimist(argv, {
24-
string: ['only', 'config', 'root'],
25-
boolean: ['packages-only', 'help', 'version'],
27+
string: ['only', 'config', 'root', 'type', 'name', 'description'],
28+
boolean: ['packages-only', 'help', 'version', 'no-tty', 'force'],
2629
alias: { h: 'help', v: 'version' },
2730
});
2831

packages/fn-cli/src/commands.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
13
import { FnClient } from '@constructive-io/fn-client';
4+
import { Templatizer } from 'genomic';
25
import type { ParsedArgs } from 'minimist';
36

47
export type CommandFn = (args: ParsedArgs) => number | Promise<number>;
@@ -9,6 +12,23 @@ const buildClient = (args: ParsedArgs): FnClient =>
912
config: typeof args.config === 'string' ? args.config : undefined,
1013
});
1114

15+
/** Bundled handler templates ship next to the compiled commands. */
16+
const TEMPLATES_ROOT = path.resolve(__dirname, '..', 'templates', 'handler');
17+
18+
const KNOWN_HANDLER_TYPES = ['node-graphql', 'python'] as const;
19+
type HandlerType = (typeof KNOWN_HANDLER_TYPES)[number];
20+
21+
const isHandlerType = (s: string): s is HandlerType =>
22+
(KNOWN_HANDLER_TYPES as readonly string[]).includes(s);
23+
24+
const detectNoTty = (args: ParsedArgs): boolean =>
25+
Boolean(
26+
args['no-tty'] ||
27+
args.noTty ||
28+
args.tty === false ||
29+
process.env.CI === 'true'
30+
);
31+
1232
const cmdGenerate: CommandFn = (args) => {
1333
const client = buildClient(args);
1434
const result = client.generate({
@@ -74,7 +94,72 @@ const cmdVerify: CommandFn = (args) => {
7494
return 2;
7595
};
7696

97+
const cmdInit: CommandFn = async (args) => {
98+
// Positional name first, then --name flag.
99+
const positional = typeof args._[1] === 'string' ? args._[1] : undefined;
100+
const name = positional ?? (typeof args.name === 'string' ? args.name : '');
101+
if (!name) {
102+
process.stderr.write(
103+
'fn init: function name is required (positional or --name=<name>)\n'
104+
);
105+
return 1;
106+
}
107+
108+
const type = typeof args.type === 'string' ? args.type : 'node-graphql';
109+
if (!isHandlerType(type)) {
110+
process.stderr.write(
111+
`Unknown type "${type}". Available: ${KNOWN_HANDLER_TYPES.join(', ')}\n`
112+
);
113+
return 1;
114+
}
115+
116+
const templateDir = path.join(TEMPLATES_ROOT, type);
117+
if (!fs.existsSync(templateDir)) {
118+
process.stderr.write(
119+
`Bundled template missing at ${templateDir}. Reinstall @constructive-io/fn-cli.\n`
120+
);
121+
return 1;
122+
}
123+
124+
const client = buildClient(args);
125+
const functionsDir = client.config.functionsDir
126+
? path.resolve(client.rootDir, client.config.functionsDir)
127+
: path.resolve(client.rootDir, 'functions');
128+
const outDir = path.join(functionsDir, name);
129+
130+
if (fs.existsSync(outDir) && !args.force) {
131+
process.stderr.write(
132+
`${path.relative(client.rootDir, outDir) || outDir} already exists. Pass --force to overwrite.\n`
133+
);
134+
return 1;
135+
}
136+
137+
// Genomic strips the ____ wrapping when matching argv keys, so plain
138+
// names ('name', 'description', …) are the right shape. version defaults
139+
// to 0.1.0 from the .boilerplate.json; users can edit handler.json after.
140+
const argv: Record<string, string> = {
141+
name,
142+
version: '0.1.0',
143+
description: typeof args.description === 'string' ? args.description : '',
144+
};
145+
146+
const templatizer = new Templatizer();
147+
await templatizer.process(templateDir, outDir, {
148+
argv,
149+
noTty: detectNoTty(args),
150+
});
151+
152+
const written = fs
153+
.readdirSync(outDir)
154+
.map((f) => path.join(path.relative(client.rootDir, outDir) || outDir, f));
155+
process.stdout.write(`Created ${name} (${type}):\n`);
156+
for (const f of written) process.stdout.write(` + ${f}\n`);
157+
process.stdout.write('Next: run `fn generate` to stamp out workspace packages.\n');
158+
return 0;
159+
};
160+
77161
export const commands: Record<string, CommandFn> = {
162+
init: cmdInit,
78163
generate: cmdGenerate,
79164
build: cmdBuild,
80165
dev: cmdDev,
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"type": "module",
3+
"requiresWorkspace": false,
4+
"questions": [
5+
{
6+
"name": "____name____",
7+
"message": "Function name (used as directory and module identifier)",
8+
"required": true
9+
},
10+
{
11+
"name": "____version____",
12+
"message": "Initial version",
13+
"default": "0.1.0"
14+
},
15+
{
16+
"name": "____description____",
17+
"message": "Short description"
18+
}
19+
]
20+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "____name____",
3+
"version": "____version____",
4+
"type": "node-graphql",
5+
"description": "____description____"
6+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { FunctionHandler } from '@constructive-io/fn-runtime';
2+
3+
interface Payload {
4+
// TODO: shape your function input
5+
}
6+
7+
interface Result {
8+
ok: true;
9+
}
10+
11+
const handler: FunctionHandler<Payload, Result> = async (params, ctx) => {
12+
ctx.log.info('____name____ invoked', params);
13+
return { ok: true };
14+
};
15+
16+
export default handler;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"type": "module",
3+
"requiresWorkspace": false,
4+
"questions": [
5+
{
6+
"name": "____name____",
7+
"message": "Function name (used as directory and module identifier)",
8+
"required": true
9+
},
10+
{
11+
"name": "____version____",
12+
"message": "Initial version",
13+
"default": "0.1.0"
14+
},
15+
{
16+
"name": "____description____",
17+
"message": "Short description"
18+
}
19+
]
20+
}

0 commit comments

Comments
 (0)