figala 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +57 -2
- package/bin/figala.js +105 -0
- package/package.json +28 -4
- package/src/detect.js +92 -0
- package/src/names.js +30 -0
- package/src/toml.js +24 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The Figala authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,58 @@
|
|
|
1
|
-
#
|
|
1
|
+
# figala
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Command line tool for [Figala](https://github.com/getfigala), a European app platform.
|
|
4
|
+
|
|
5
|
+
**Status:** early version. The platform is not publicly available yet, so `figala deploy` and the other platform
|
|
6
|
+
commands do not exist in this release. What works today runs entirely on your machine and sends nothing anywhere.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install -g figala
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Requires Node.js 20 or newer. No dependencies.
|
|
15
|
+
|
|
16
|
+
## Commands
|
|
17
|
+
|
|
18
|
+
### `figala init [dir]`
|
|
19
|
+
|
|
20
|
+
Looks at a project folder and writes a `figala.toml` describing how to build and run it.
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
figala init
|
|
24
|
+
figala init ./apps/web --project recipes
|
|
25
|
+
figala init --json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
It recognises Next.js, Nuxt, Astro, SvelteKit, Remix, Vite, Express, Fastify, NestJS, Django, Flask, FastAPI and
|
|
29
|
+
projects with a `Dockerfile`, and suggests a Postgres database when it finds a Postgres client or ORM. It reads
|
|
30
|
+
`package.json`, `requirements.txt`, `pyproject.toml` and the lockfile names. It never reads `.env` files, and secrets do
|
|
31
|
+
not belong in `figala.toml`.
|
|
32
|
+
|
|
33
|
+
An existing `figala.toml` is left alone unless you pass `--force`. Check the result before you commit it: detection
|
|
34
|
+
is a suggestion.
|
|
35
|
+
|
|
36
|
+
| Option | Meaning |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `--project <name>` | project name; by default it comes from `package.json` or the folder name |
|
|
39
|
+
| `--force` | overwrite an existing `figala.toml` |
|
|
40
|
+
| `--stdout` | print the file instead of writing it |
|
|
41
|
+
| `--json` | machine-readable output |
|
|
42
|
+
|
|
43
|
+
### `figala check-name <project>`
|
|
44
|
+
|
|
45
|
+
Checks a project name against the naming rules: 3 to 24 characters, `a-z`, `0-9` and `-`, starts with a letter, does
|
|
46
|
+
not end with `-`, no `--`, and not a reserved name. It does not check whether the name is taken.
|
|
47
|
+
|
|
48
|
+
## Exit codes
|
|
49
|
+
|
|
50
|
+
| Code | Meaning |
|
|
51
|
+
|---|---|
|
|
52
|
+
| 0 | success |
|
|
53
|
+
| 1 | the command failed, for example `figala.toml` already exists |
|
|
54
|
+
| 2 | wrong usage or invalid name |
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
MIT
|
package/bin/figala.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { basename, join, resolve } from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { detectProject } from '../src/detect.js';
|
|
6
|
+
import { projectNameProblems, suggestProjectName } from '../src/names.js';
|
|
7
|
+
import { renderToml } from '../src/toml.js';
|
|
8
|
+
|
|
9
|
+
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
10
|
+
|
|
11
|
+
const HELP = `figala ${version}
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
figala init [dir] [--project <name>] [--force] [--stdout] [--json]
|
|
15
|
+
figala check-name <project> [--json]
|
|
16
|
+
figala --version
|
|
17
|
+
|
|
18
|
+
This early version works locally and sends nothing anywhere. The platform is not
|
|
19
|
+
publicly available yet, so deploy and the other platform commands are not included.
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
class UsageError extends Error {}
|
|
23
|
+
|
|
24
|
+
function report(json, data, text) {
|
|
25
|
+
console.log(json ? JSON.stringify(data) : text);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function init(args) {
|
|
29
|
+
const { values, positionals } = parseArgs({
|
|
30
|
+
args,
|
|
31
|
+
allowPositionals: true,
|
|
32
|
+
options: {
|
|
33
|
+
project: { type: 'string' },
|
|
34
|
+
force: { type: 'boolean', default: false },
|
|
35
|
+
stdout: { type: 'boolean', default: false },
|
|
36
|
+
json: { type: 'boolean', default: false },
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
if (positionals.length > 1) throw new UsageError('init takes one folder');
|
|
40
|
+
const dir = resolve(positionals[0] ?? '.');
|
|
41
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new UsageError(`not a folder: ${dir}`);
|
|
42
|
+
|
|
43
|
+
const found = detectProject(dir);
|
|
44
|
+
const project = values.project ?? suggestProjectName(found.packageName ?? '') ?? suggestProjectName(basename(dir));
|
|
45
|
+
if (!project) throw new UsageError('could not derive a valid project name; pass --project <name>');
|
|
46
|
+
const problems = projectNameProblems(project);
|
|
47
|
+
if (problems.length > 0) throw new UsageError(`project name "${project}" ${problems.join(', ')}`);
|
|
48
|
+
|
|
49
|
+
const text = renderToml(project, found);
|
|
50
|
+
const target = join(dir, 'figala.toml');
|
|
51
|
+
const data = { project, framework: found.framework, runtime: found.runtime, type: found.type, postgres: found.wantsPostgres };
|
|
52
|
+
if (values.stdout) {
|
|
53
|
+
if (values.json) report(true, { ...data, toml: text });
|
|
54
|
+
else process.stdout.write(text);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
if (existsSync(target) && !values.force) {
|
|
58
|
+
report(values.json, { ...data, written: false, error: 'figala.toml already exists' },
|
|
59
|
+
'figala.toml already exists; pass --force to overwrite it.');
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
|
62
|
+
writeFileSync(target, text);
|
|
63
|
+
report(values.json, { ...data, written: true, path: target },
|
|
64
|
+
`Wrote ${target}\nDetected: ${found.framework ?? found.runtime ?? 'nothing specific'}. Check the file before you commit it.`);
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function checkName(args) {
|
|
69
|
+
const { values, positionals } = parseArgs({
|
|
70
|
+
args,
|
|
71
|
+
allowPositionals: true,
|
|
72
|
+
options: { json: { type: 'boolean', default: false } },
|
|
73
|
+
});
|
|
74
|
+
if (positionals.length !== 1) throw new UsageError('check-name takes one name');
|
|
75
|
+
const problems = projectNameProblems(positionals[0]);
|
|
76
|
+
report(values.json, { name: positionals[0], valid: problems.length === 0, problems },
|
|
77
|
+
problems.length === 0 ? `${positionals[0]} follows the naming rules.` : `${positionals[0]} ${problems.join(', ')}.`);
|
|
78
|
+
return problems.length === 0 ? 0 : 2;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function main(argv) {
|
|
82
|
+
const [command, ...rest] = argv;
|
|
83
|
+
if (command === undefined || command === 'help' || command === '--help' || command === '-h') {
|
|
84
|
+
console.log(HELP);
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
if (command === '--version' || command === '-v') {
|
|
88
|
+
console.log(version);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
if (command === 'init') return init(rest);
|
|
93
|
+
if (command === 'check-name') return checkName(rest);
|
|
94
|
+
throw new UsageError(`unknown command: ${command}`);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (error instanceof UsageError || error.code?.startsWith('ERR_PARSE_ARGS')) {
|
|
97
|
+
console.error(`${error.message}\n\n${HELP}`);
|
|
98
|
+
return 2;
|
|
99
|
+
}
|
|
100
|
+
console.error(error.message);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
process.exitCode = main(process.argv.slice(2));
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "figala",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command line tool for Figala, a European app platform. This early version generates and checks project configuration.",
|
|
5
|
+
"keywords": ["figala", "deploy", "paas", "cli", "figala.toml"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Figala",
|
|
8
|
+
"homepage": "https://github.com/getfigala/cli#readme",
|
|
9
|
+
"bugs": "https://github.com/getfigala/cli/issues",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/getfigala/cli.git"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"bin": {
|
|
16
|
+
"figala": "bin/figala.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin",
|
|
20
|
+
"src",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/detect.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// The first match wins, so meta-frameworks come before the libraries they are built on.
|
|
5
|
+
const NODE_FRAMEWORKS = [
|
|
6
|
+
{ dep: 'next', framework: 'nextjs', type: 'web', port: 3000 },
|
|
7
|
+
{ dep: 'nuxt', framework: 'nuxt', type: 'web', port: 3000 },
|
|
8
|
+
{ dep: '@remix-run/node', framework: 'remix', type: 'web', port: 3000 },
|
|
9
|
+
{ dep: '@sveltejs/kit', framework: 'sveltekit', type: 'web', port: 3000 },
|
|
10
|
+
{ dep: 'astro', framework: 'astro', type: 'static' },
|
|
11
|
+
{ dep: '@nestjs/core', framework: 'nestjs', type: 'web', port: 3000 },
|
|
12
|
+
{ dep: 'fastify', framework: 'fastify', type: 'web', port: 3000 },
|
|
13
|
+
{ dep: 'express', framework: 'express', type: 'web', port: 3000 },
|
|
14
|
+
{ dep: 'vite', framework: 'vite', type: 'static' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const PYTHON_FRAMEWORKS = [
|
|
18
|
+
{ dep: 'django', framework: 'django', type: 'web', port: 8000 },
|
|
19
|
+
{ dep: 'fastapi', framework: 'fastapi', type: 'web', port: 8000 },
|
|
20
|
+
{ dep: 'flask', framework: 'flask', type: 'web', port: 8000 },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const POSTGRES_NODE = ['pg', 'postgres', 'prisma', '@prisma/client', 'drizzle-orm', 'typeorm', 'sequelize', 'knex'];
|
|
24
|
+
const POSTGRES_PYTHON = ['psycopg', 'psycopg2', 'psycopg2-binary', 'asyncpg', 'sqlalchemy', 'django'];
|
|
25
|
+
|
|
26
|
+
const LOCKFILES = [
|
|
27
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
28
|
+
['yarn.lock', 'yarn'],
|
|
29
|
+
['bun.lock', 'bun'],
|
|
30
|
+
['bun.lockb', 'bun'],
|
|
31
|
+
['package-lock.json', 'npm'],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function readJson(path) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function pythonDependencies(dir) {
|
|
43
|
+
const names = new Set();
|
|
44
|
+
for (const file of ['requirements.txt', 'pyproject.toml']) {
|
|
45
|
+
const path = join(dir, file);
|
|
46
|
+
if (!existsSync(path)) continue;
|
|
47
|
+
for (const match of readFileSync(path, 'utf8').matchAll(/^[\s"']*([A-Za-z][A-Za-z0-9._-]*)/gm)) {
|
|
48
|
+
names.add(match[1].toLowerCase());
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return names;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Inspects a folder and returns what it found. Reads only manifests, never .env files. */
|
|
55
|
+
export function detectProject(dir) {
|
|
56
|
+
const result = {
|
|
57
|
+
runtime: null,
|
|
58
|
+
framework: null,
|
|
59
|
+
type: 'web',
|
|
60
|
+
port: null,
|
|
61
|
+
packageManager: null,
|
|
62
|
+
start: null,
|
|
63
|
+
build: null,
|
|
64
|
+
wantsPostgres: false,
|
|
65
|
+
packageName: null,
|
|
66
|
+
dockerfile: existsSync(join(dir, 'Dockerfile')),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const pkg = readJson(join(dir, 'package.json'));
|
|
70
|
+
if (pkg) {
|
|
71
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
72
|
+
result.runtime = 'node';
|
|
73
|
+
result.packageName = typeof pkg.name === 'string' ? pkg.name : null;
|
|
74
|
+
result.packageManager = LOCKFILES.find(([file]) => existsSync(join(dir, file)))?.[1] ?? 'npm';
|
|
75
|
+
const run = result.packageManager === 'npm' ? 'npm run' : result.packageManager;
|
|
76
|
+
const known = NODE_FRAMEWORKS.find(({ dep }) => dep in deps);
|
|
77
|
+
if (known) Object.assign(result, { framework: known.framework, type: known.type, port: known.port ?? null });
|
|
78
|
+
if (pkg.scripts?.build) result.build = `${run} build`;
|
|
79
|
+
if (pkg.scripts?.start) result.start = result.packageManager === 'npm' ? 'npm start' : `${run} start`;
|
|
80
|
+
result.wantsPostgres = POSTGRES_NODE.some((name) => name in deps);
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const python = pythonDependencies(dir);
|
|
85
|
+
if (python.size > 0) {
|
|
86
|
+
result.runtime = 'python';
|
|
87
|
+
const known = PYTHON_FRAMEWORKS.find(({ dep }) => python.has(dep));
|
|
88
|
+
if (known) Object.assign(result, { framework: known.framework, type: known.type, port: known.port });
|
|
89
|
+
result.wantsPostgres = POSTGRES_PYTHON.some((name) => python.has(name));
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
package/src/names.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const RESERVED = new Set([
|
|
2
|
+
'www', 'api', 'app', 'admin', 'status', 'mail', 'db', 'cdn', 'cname', 'docs', 'figala', 'getfigala',
|
|
3
|
+
]);
|
|
4
|
+
|
|
5
|
+
const SHAPE = /^[a-z](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
6
|
+
|
|
7
|
+
/** Returns a list of problems; an empty list means the project name is acceptable. */
|
|
8
|
+
export function projectNameProblems(name) {
|
|
9
|
+
const problems = [];
|
|
10
|
+
if (name.length < 3 || name.length > 24) problems.push('must be 3 to 24 characters');
|
|
11
|
+
if (!SHAPE.test(name)) problems.push('may use a-z, 0-9 and -, must start with a letter and not end with -');
|
|
12
|
+
if (name.includes('--')) problems.push('must not contain --');
|
|
13
|
+
if (RESERVED.has(name)) problems.push('is a reserved name');
|
|
14
|
+
return problems;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Turns a folder or package name into the closest valid project name, or null if there is none. */
|
|
18
|
+
export function suggestProjectName(raw) {
|
|
19
|
+
const cleaned = String(raw)
|
|
20
|
+
.replace(/^@[^/]+\//, '')
|
|
21
|
+
.normalize('NFKD')
|
|
22
|
+
.replace(/[̀-ͯ]/g, '')
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
25
|
+
.replace(/-{2,}/g, '-')
|
|
26
|
+
.replace(/^[^a-z]+/, '')
|
|
27
|
+
.slice(0, 24)
|
|
28
|
+
.replace(/-+$/, '');
|
|
29
|
+
return projectNameProblems(cleaned).length === 0 ? cleaned : null;
|
|
30
|
+
}
|
package/src/toml.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// JSON string escaping is valid for TOML basic strings.
|
|
2
|
+
const quote = (value) => JSON.stringify(value);
|
|
3
|
+
|
|
4
|
+
/** Writes the figala.toml text for a detected project. */
|
|
5
|
+
export function renderToml(project, found) {
|
|
6
|
+
const lines = [
|
|
7
|
+
'# Generated by figala init. Detection is a suggestion, so check this file before you commit it.',
|
|
8
|
+
'# Secrets do not belong here; set them with the platform instead.',
|
|
9
|
+
`project = ${quote(project)}`,
|
|
10
|
+
'',
|
|
11
|
+
'[services.web]',
|
|
12
|
+
`type = ${quote(found.type)}`,
|
|
13
|
+
'primary = true',
|
|
14
|
+
];
|
|
15
|
+
if (found.build) lines.push(`build = ${quote(found.build)}`);
|
|
16
|
+
if (found.start && found.type !== 'static') lines.push(`start = ${quote(found.start)}`);
|
|
17
|
+
if (found.port && found.type !== 'static') lines.push(`port = ${found.port}`);
|
|
18
|
+
lines.push('size = "512mb"');
|
|
19
|
+
if (found.dockerfile) lines.push('# A Dockerfile was found; it is used for the build when present.');
|
|
20
|
+
if (found.wantsPostgres) {
|
|
21
|
+
lines.push('', '[services.web.env]', 'DATABASE_URL = "${{db.DATABASE_URL}}"', '', '[databases.db]', 'engine = "postgres"');
|
|
22
|
+
}
|
|
23
|
+
return `${lines.join('\n')}\n`;
|
|
24
|
+
}
|