backend-skeleton 1.0.0-beta.1
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 +284 -0
- package/bin/bskel.mjs +2384 -0
- package/contracts/completeness.mjs +176 -0
- package/contracts/emit.mjs +287 -0
- package/contracts/export.mjs +325 -0
- package/contracts/openapi.mjs +869 -0
- package/contracts/validate.mjs +147 -0
- package/handles/_engine.mjs +281 -0
- package/handles/codec.mjs +119 -0
- package/handles/conformance.mjs +74 -0
- package/handles/providers/java-spring/ast-bridge.mjs +59 -0
- package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
- package/handles/providers/java-spring/ast-helper/gradlew +248 -0
- package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
- package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
- package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
- package/handles/providers/java-spring/emit.mjs +232 -0
- package/handles/providers/java-spring/patch-strategy.mjs +229 -0
- package/handles/providers/java-spring/plan.mjs +377 -0
- package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
- package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
- package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
- package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
- package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
- package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
- package/handles/providers/java-spring.mjs +21 -0
- package/handles/providers/python-fastapi/emit.mjs +171 -0
- package/handles/providers/python-fastapi/plan.mjs +186 -0
- package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
- package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
- package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
- package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
- package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
- package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
- package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
- package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
- package/handles/providers/python-fastapi.mjs +22 -0
- package/handles/providers/typescript-express/emit.mjs +128 -0
- package/handles/providers/typescript-express/plan.mjs +234 -0
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
- package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
- package/handles/providers/typescript-express.mjs +20 -0
- package/handles/registry.mjs +90 -0
- package/lib/cli.mjs +430 -0
- package/lib/doctor.mjs +200 -0
- package/lib/exit-codes.mjs +67 -0
- package/lib/featureid.mjs +55 -0
- package/lib/featurelifecycle.mjs +205 -0
- package/lib/fsutil.mjs +50 -0
- package/lib/gate-definitions.mjs +293 -0
- package/lib/gates.mjs +263 -0
- package/lib/handles-manifest.mjs +92 -0
- package/lib/lock.mjs +68 -0
- package/lib/patch-approvals.mjs +56 -0
- package/lib/paths.mjs +21 -0
- package/lib/repo.mjs +44 -0
- package/lib/schema-validate.mjs +56 -0
- package/lib/state.mjs +124 -0
- package/lib/template.mjs +35 -0
- package/lib/verify.mjs +206 -0
- package/lib/workflow.mjs +142 -0
- package/new/fastapi.mjs +165 -0
- package/new/index.mjs +62 -0
- package/new/params.mjs +233 -0
- package/new/spring.mjs +198 -0
- package/new/templates/fastapi/README.md +26 -0
- package/new/templates/fastapi/app/__init__.py +0 -0
- package/new/templates/fastapi/app/main.py +8 -0
- package/new/templates/fastapi/gitignore +6 -0
- package/new/templates/fastapi/pyproject.toml +14 -0
- package/package.json +50 -0
- package/scanners/adapters/_express-shared.mjs +238 -0
- package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
- package/scanners/adapters/generic-grep.mjs +128 -0
- package/scanners/adapters/java-spring.mjs +301 -0
- package/scanners/adapters/javascript-express.mjs +422 -0
- package/scanners/adapters/python-fastapi.mjs +348 -0
- package/scanners/adapters/typescript-express.mjs +299 -0
- package/scanners/capabilities.mjs +90 -0
- package/scanners/conformance.mjs +59 -0
- package/scanners/db/introspect.mjs +109 -0
- package/scanners/db/migrations.mjs +126 -0
- package/scanners/index.mjs +281 -0
- package/scanners/registry.mjs +130 -0
- package/scanners/render.mjs +136 -0
- package/scanners/text-util.mjs +8 -0
- package/schemas/adapter.schema.json +23 -0
- package/schemas/agent-envelope.schema.json +21 -0
- package/schemas/contract-resolution.schema.json +28 -0
- package/schemas/feature-contract.schema.json +78 -0
- package/schemas/feature-index.schema.json +25 -0
- package/schemas/feature.schema.json +17 -0
- package/schemas/gate-event.schema.json +19 -0
- package/schemas/handles-plan.schema.json +31 -0
- package/schemas/handles-provider.schema.json +26 -0
- package/schemas/patch-approvals.schema.json +28 -0
- package/schemas/scan-report.schema.json +102 -0
- package/schemas/stack-choice.schema.json +89 -0
- package/schemas/stack-record.schema.json +20 -0
- package/schemas/state.schema.json +43 -0
- package/scripts/preflight-base-ref.sh +226 -0
- package/stack/apply.mjs +159 -0
- package/stack/bootstrap/_lib.sh +73 -0
- package/stack/bootstrap/ngrok.sh +90 -0
- package/stack/catalog/ngrok.yml +63 -0
package/lib/workflow.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// D1: turns raw gate state into "what's blocking, what do I run next" -- `bskel verify` already
|
|
2
|
+
// computes everything this needs (per-gate blocking/status/stale_reason/changed_inputs via
|
|
3
|
+
// lib/verify.mjs's collectGateStatuses, missing artifacts via checkArtifacts), it just flattens
|
|
4
|
+
// all of that into one pass/fail verdict instead of answering "what's the next command". This
|
|
5
|
+
// module is presentation/sequencing logic layered on those same primitives -- reused directly,
|
|
6
|
+
// not re-derived.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { GATE_NAMES, GATE_DEFINITIONS, VERIFY_POLICY } from './gate-definitions.mjs';
|
|
10
|
+
import { collectGateStatuses, checkArtifacts, isBlockingGateResult } from './verify.mjs';
|
|
11
|
+
import { getGate } from './state.mjs';
|
|
12
|
+
import { requireNamedGate } from './gates.mjs';
|
|
13
|
+
|
|
14
|
+
// This repo's own known features -- .sbf/feature-index.json is keyed by feature_uid (not
|
|
15
|
+
// feature_id) and only ever holds a single-element array per uid, so it's not a convenient
|
|
16
|
+
// "list every feature_id" source. specs/<id>/feature.json (written once by `feature init`,
|
|
17
|
+
// never rewritten) is simpler and authoritative for what IDs actually exist.
|
|
18
|
+
export function listFeatures(root) {
|
|
19
|
+
const specsRoot = path.join(root, 'specs');
|
|
20
|
+
if (!fs.existsSync(specsRoot)) return [];
|
|
21
|
+
return fs.readdirSync(specsRoot)
|
|
22
|
+
.filter((name) => fs.existsSync(path.join(specsRoot, name, 'feature.json')))
|
|
23
|
+
.map((featureId) => JSON.parse(fs.readFileSync(path.join(specsRoot, featureId, 'feature.json'), 'utf8')))
|
|
24
|
+
.sort((a, b) => a.feature_id.localeCompare(b.feature_id));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The command that first establishes each gate -- same phrasing as the existing inline "blocked:
|
|
28
|
+
// ... -- run `bskel ...` first" messages in requirePreflightPassed/cmdContractEmit/cmdHandlesEmit,
|
|
29
|
+
// kept here as the one place both those call sites and this module could eventually share (not
|
|
30
|
+
// done in this slice -- see D-status-next's COST in DECISIONS.md).
|
|
31
|
+
const ESTABLISH_COMMAND = {
|
|
32
|
+
preflight: () => 'bskel preflight',
|
|
33
|
+
scan: (id) => `bskel scan --feature ${id} --terms <a,b,c>`,
|
|
34
|
+
contract: (id) => `bskel contract emit --feature ${id}`,
|
|
35
|
+
handles: (id) => `bskel handles plan --feature ${id} # then: bskel handles emit --feature ${id}`,
|
|
36
|
+
stack: () => 'bskel stack apply --choice <id> --apply',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// awaiting_disposition needs a genuinely different remediation per gate, not a re-run --
|
|
40
|
+
// cmdScanDisposition's own usage line and cmdHandlesEmit's awaiting_disposition hint
|
|
41
|
+
// (bin/bskel.mjs) already say exactly this; duplicated here rather than exported+imported to
|
|
42
|
+
// avoid pulling CLI-layer string-building into lib/ for two lines of text.
|
|
43
|
+
function awaitingDispositionCommand(gateName, featureId) {
|
|
44
|
+
if (gateName === 'scan') {
|
|
45
|
+
return `bskel scan disposition --feature ${featureId} --mode reuse|extend|replace|parallel --note "..."`;
|
|
46
|
+
}
|
|
47
|
+
if (gateName === 'contract') {
|
|
48
|
+
return `bskel contract waive --feature ${featureId} --code <CODE> (--subject "..."|--all) --reason "..." # or: bskel gate force contract --feature ${featureId} --reason "..." if intentional`;
|
|
49
|
+
}
|
|
50
|
+
return `bskel gate force ${gateName} --feature ${featureId} --reason "..."`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Whether a recommended command WRITES anything (gate state, generated files, applied files) as
|
|
54
|
+
// opposed to being a pure read (bskel verify, bskel status, bskel next itself). Matched by the
|
|
55
|
+
// command's own leading "bskel <verb...>" prefix so this can't silently drift from the actual
|
|
56
|
+
// command names above.
|
|
57
|
+
const MUTATING_PREFIXES = ['bskel preflight', 'bskel scan', 'bskel feature init', 'bskel contract emit', 'bskel contract waive', 'bskel gate force', 'bskel handles emit', 'bskel handles plan', 'bskel stack apply'];
|
|
58
|
+
|
|
59
|
+
function action(command, reason) {
|
|
60
|
+
return { command, reason, mutating: MUTATING_PREFIXES.some((p) => command.startsWith(p)) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// featureId === null means "repo scope only" -- feature-scoped gates (scan/contract/handles)
|
|
64
|
+
// cannot be meaningfully evaluated without a feature_id, so only `preflight` is considered.
|
|
65
|
+
export function computeWorkflowState(root, featureId) {
|
|
66
|
+
let gates;
|
|
67
|
+
if (featureId) {
|
|
68
|
+
gates = collectGateStatuses(root, featureId, { getGate, requireNamedGate });
|
|
69
|
+
} else {
|
|
70
|
+
// Same call requirePreflightPassed already makes -- preflight's recompute() ignores
|
|
71
|
+
// featureId entirely (it's repo-scoped), so `null` here is exactly correct, not a stand-in.
|
|
72
|
+
const result = requireNamedGate(root, 'preflight', null);
|
|
73
|
+
const def = GATE_DEFINITIONS.preflight;
|
|
74
|
+
gates = [{
|
|
75
|
+
gate: 'preflight', scope: def.scope, policy: def.verifyPolicy,
|
|
76
|
+
required: def.verifyPolicy === VERIFY_POLICY.REQUIRED,
|
|
77
|
+
blocking: isBlockingGateResult(def, result),
|
|
78
|
+
ran: getGate(root, '_repo', 'preflight') !== null,
|
|
79
|
+
...result,
|
|
80
|
+
}];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const blockedBy = gates.filter((g) => g.blocking).map((g) => g.gate);
|
|
84
|
+
const nextActions = [];
|
|
85
|
+
|
|
86
|
+
// featureId === null means `gates` only ever contains the preflight entry (see above) -- every
|
|
87
|
+
// other gate name is legitimately absent, not "not_run", so `!g` must fall through to the next
|
|
88
|
+
// gate name rather than being treated as blocking. This loop can therefore only ever produce an
|
|
89
|
+
// action from `preflight` when there's no featureId; the "what comes after preflight" case (no
|
|
90
|
+
// feature selected yet) is handled in the fallback block below, not in here.
|
|
91
|
+
for (const gateName of GATE_NAMES) {
|
|
92
|
+
const g = gates.find((x) => x.gate === gateName);
|
|
93
|
+
if (!g || !g.blocking) continue; // not_run on an optional (required-when-present) gate isn't blocking -- isBlockingGateResult already encodes that.
|
|
94
|
+
if (g.status === 'not_run') {
|
|
95
|
+
nextActions.push(action(ESTABLISH_COMMAND[gateName](featureId), `${gateName} gate has not run yet`));
|
|
96
|
+
} else if (g.status === 'awaiting_disposition') {
|
|
97
|
+
nextActions.push(action(awaitingDispositionCommand(gateName, featureId), `${gateName} gate is awaiting disposition`));
|
|
98
|
+
} else if (g.status === 'stale') {
|
|
99
|
+
// D-preflight-freshness (S3): ttl_expired carries age_seconds/max_age_seconds (see
|
|
100
|
+
// lib/gates.mjs's checkFreshness()) -- worth a word count here, since "stale (ttl_expired)"
|
|
101
|
+
// alone doesn't say whether the pass is 1 minute or 1 day past its limit.
|
|
102
|
+
const staleDetail = g.stale_reason === 'inputs_changed'
|
|
103
|
+
? `: ${g.changed_inputs.join(', ')}`
|
|
104
|
+
: g.stale_reason === 'ttl_expired'
|
|
105
|
+
? ` (ttl_expired, ${Math.round(g.age_seconds / 60)}m old > ${Math.round(g.max_age_seconds / 60)}m limit)`
|
|
106
|
+
: g.stale_reason ? ` (${g.stale_reason})` : '';
|
|
107
|
+
nextActions.push(action(ESTABLISH_COMMAND[gateName](featureId), `${gateName} gate is stale${staleDetail}`));
|
|
108
|
+
}
|
|
109
|
+
break; // GATE_NAMES order -- only the earliest blocking gate; `next` promises exactly one action.
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (nextActions.length === 0) {
|
|
113
|
+
if (featureId) {
|
|
114
|
+
// Every required gate passes -- verify is the natural next step. Optional gates
|
|
115
|
+
// (handles/stack) that never ran are surfaced separately, not as a blocking action.
|
|
116
|
+
nextActions.push(action(`bskel verify --feature ${featureId}`, 'all required gates pass -- verify the full feature state'));
|
|
117
|
+
} else {
|
|
118
|
+
// preflight passed and no --feature was given -- there is no gate to hook this off of
|
|
119
|
+
// (feature-scoped gates aren't even evaluated without a feature_id), so point at
|
|
120
|
+
// starting or selecting one directly.
|
|
121
|
+
const features = listFeatures(root);
|
|
122
|
+
if (features.length === 0) {
|
|
123
|
+
nextActions.push(action('bskel feature init --slug <name>', 'no feature exists yet -- create one to start the scan/contract/handles workflow'));
|
|
124
|
+
} else {
|
|
125
|
+
nextActions.push(action(
|
|
126
|
+
'bskel next --feature <id>',
|
|
127
|
+
`pick an existing feature to continue (known: ${features.map((f) => f.feature_id).join(', ')}), or run \`bskel feature init --slug <name>\` to start a new one`,
|
|
128
|
+
));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const optionalNotRun = gates.filter((g) => (g.gate === 'handles' || g.gate === 'stack') && g.status === 'not_run').map((g) => g.gate);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
gates,
|
|
137
|
+
artifacts: featureId ? checkArtifacts(root, featureId, gates) : [],
|
|
138
|
+
blocked_by: blockedBy,
|
|
139
|
+
next_actions: nextActions,
|
|
140
|
+
optional_not_run: optionalNotRun,
|
|
141
|
+
};
|
|
142
|
+
}
|
package/new/fastapi.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// P2 (D-greenfield-bootstrap): unlike Spring, FastAPI has no first-party scaffolding CLI/service
|
|
2
|
+
// with comparable official standing to Spring Initializr (confirmed while researching this item)
|
|
3
|
+
// -- so "pinned starter" here means a minimal, LOCAL, hand-written template matching the exact
|
|
4
|
+
// layout scanners/adapters/python-fastapi.mjs's own detectPythonFastApiRoot() already expects
|
|
5
|
+
// (a pyproject.toml declaring a `fastapi` dependency + a .py file that imports/instantiates it),
|
|
6
|
+
// not a third-party generator. No network call.
|
|
7
|
+
//
|
|
8
|
+
// P2b (D-greenfield-parameters): this is the half of `bskel new` with NO upstream authority at all
|
|
9
|
+
// -- there is no Initializr here to bounce a bad value back, so every value this file writes is one
|
|
10
|
+
// `bskel` itself is the last check on. Two consequences, both deliberate: the {{VAR}} renderer is
|
|
11
|
+
// now the shared one (lib/template.mjs), and every rendered file is scanned for a SURVIVING {{VAR}}
|
|
12
|
+
// before anything reaches disk -- a scaffold that would ship a literal `{{FOO}}` into a user's
|
|
13
|
+
// pyproject.toml fails CLOSED instead.
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { renderTemplateText, findResidualTemplateVars } from '../lib/template.mjs';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_TEMPLATE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'templates', 'fastapi');
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_PROJECT_VERSION = '0.1.0';
|
|
22
|
+
export const DEFAULT_REQUIRES_PYTHON = '>=3.11';
|
|
23
|
+
export const DEFAULT_PORT = '8000';
|
|
24
|
+
export const DEFAULT_DATABASE = 'none';
|
|
25
|
+
|
|
26
|
+
// P2b: the driver pin, and ONLY the driver pin. `--database` never generates engine/session wiring,
|
|
27
|
+
// a db.py, or a models package -- that would be `bskel` inventing the user's domain, the same line
|
|
28
|
+
// `D-resolver-scope` draws for patchField() and `D-javascript-express-adapter`'s Phase 2 draws for
|
|
29
|
+
// generated SQL. sqlite needs no third-party driver at all (CPython ships sqlite3, and SQLModel/
|
|
30
|
+
// SQLAlchemy drive it through the stdlib module), so it correctly adds nothing.
|
|
31
|
+
const DATABASE_DEPENDENCIES = Object.freeze({
|
|
32
|
+
postgres: ['psycopg[binary]>=3.2'],
|
|
33
|
+
sqlite: [],
|
|
34
|
+
none: [],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// TOML basic strings and Python double-quoted strings share the two escapes that matter here.
|
|
38
|
+
// Newlines/control characters are rejected upstream (new/params.mjs's requireSingleLineText), so a
|
|
39
|
+
// backslash and a double quote are the whole surface.
|
|
40
|
+
function escapeQuoted(value) {
|
|
41
|
+
return String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildVars({ slug, name, description, projectVersion, requiresPython, port, license, database }) {
|
|
45
|
+
const resolvedName = name ?? slug;
|
|
46
|
+
const resolvedDatabase = database ?? DEFAULT_DATABASE;
|
|
47
|
+
const driverLines = (DATABASE_DEPENDENCIES[resolvedDatabase] ?? [])
|
|
48
|
+
.map((dep) => ` "${escapeQuoted(dep)}",\n`)
|
|
49
|
+
.join('');
|
|
50
|
+
|
|
51
|
+
const databaseSection = resolvedDatabase === 'none' ? '' : [
|
|
52
|
+
'## Database',
|
|
53
|
+
'',
|
|
54
|
+
resolvedDatabase === 'postgres'
|
|
55
|
+
? '`--database postgres` pinned `psycopg[binary]` in `pyproject.toml`. That is all it did.'
|
|
56
|
+
: '`--database sqlite` pinned no extra driver -- CPython ships `sqlite3`, which SQLModel/SQLAlchemy drive directly.',
|
|
57
|
+
'',
|
|
58
|
+
'No engine, session, or connection code was generated: the connection URL, pooling, migrations',
|
|
59
|
+
'and session lifecycle are decisions about your application, not ones a scaffolder should make',
|
|
60
|
+
'for you. Wire them up yourself before adding models.',
|
|
61
|
+
'',
|
|
62
|
+
'',
|
|
63
|
+
].join('\n');
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
SLUG: slug,
|
|
67
|
+
NAME: escapeQuoted(resolvedName),
|
|
68
|
+
DESCRIPTION: escapeQuoted(description ?? ''),
|
|
69
|
+
PROJECT_VERSION: escapeQuoted(projectVersion ?? DEFAULT_PROJECT_VERSION),
|
|
70
|
+
REQUIRES_PYTHON: escapeQuoted(requiresPython ?? DEFAULT_REQUIRES_PYTHON),
|
|
71
|
+
// A whole line, including its trailing newline, so an absent --license leaves no blank line
|
|
72
|
+
// behind. There is no neutral default SPDX identifier -- emitting one would be this tool
|
|
73
|
+
// asserting a legal claim the user never made -- so the key is omitted entirely instead.
|
|
74
|
+
LICENSE_LINE: license == null ? '' : `license = "${escapeQuoted(license)}"\n`,
|
|
75
|
+
DATABASE_DEPENDENCY_LINES: driverLines,
|
|
76
|
+
DESCRIPTION_BLOCK: description ? `${description}\n\n` : '',
|
|
77
|
+
PORT: String(port ?? DEFAULT_PORT),
|
|
78
|
+
DATABASE_SECTION: databaseSection,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Renders the whole tree into memory FIRST, so the residual-variable check below can fail before a
|
|
83
|
+
// single byte reaches disk. Deliberately not "write, then verify, then delete": `dir` comes from
|
|
84
|
+
// user input (`--dir`), and an rm -rf of a user-supplied path to clean up after our own bug is a
|
|
85
|
+
// worse failure mode than the bug. Nothing partial ever exists.
|
|
86
|
+
function renderTree(srcDir, destDir, vars) {
|
|
87
|
+
const files = [];
|
|
88
|
+
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
89
|
+
const srcPath = path.join(srcDir, entry.name);
|
|
90
|
+
if (entry.isDirectory()) {
|
|
91
|
+
files.push(...renderTree(srcPath, path.join(destDir, entry.name), vars));
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// The template's own gitignore is named without a leading dot (see new/templates/fastapi/
|
|
95
|
+
// gitignore) so this repo's own tooling never treats it as a real, active .gitignore.
|
|
96
|
+
const destName = entry.name === 'gitignore' ? '.gitignore' : entry.name;
|
|
97
|
+
files.push({
|
|
98
|
+
destPath: path.join(destDir, destName),
|
|
99
|
+
content: renderTemplateText(fs.readFileSync(srcPath, 'utf8'), vars),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return files;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function scaffoldFastapi({
|
|
106
|
+
dir,
|
|
107
|
+
slug,
|
|
108
|
+
name = null,
|
|
109
|
+
description = null,
|
|
110
|
+
projectVersion = null,
|
|
111
|
+
requiresPython = null,
|
|
112
|
+
port = null,
|
|
113
|
+
license = null,
|
|
114
|
+
database = null,
|
|
115
|
+
// Internal test seam only -- never wired to a CLI flag. `test/new-cli.test.mjs` points this at a
|
|
116
|
+
// deliberately broken template to prove the fail-closed check below can actually fail.
|
|
117
|
+
templateDir = DEFAULT_TEMPLATE_DIR,
|
|
118
|
+
} = {}) {
|
|
119
|
+
if (fs.existsSync(dir) && fs.readdirSync(dir).length > 0) {
|
|
120
|
+
throw new Error(`${dir} already exists and is not empty -- refusing to scaffold into it`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const vars = buildVars({ slug, name, description, projectVersion, requiresPython, port, license, database });
|
|
124
|
+
const files = renderTree(templateDir, dir, vars);
|
|
125
|
+
|
|
126
|
+
// P2b: the fail-closed half. P4's `bskel catalog lint` has run this same check over
|
|
127
|
+
// stack/catalog/ templates since D-extension-conformance; new/templates/** never had it, so a
|
|
128
|
+
// template variable added without a matching entry in buildVars() above would have shipped a
|
|
129
|
+
// literal `{{FOO}}` into a real user's pyproject.toml with nothing to catch it.
|
|
130
|
+
const offenders = files
|
|
131
|
+
.map((f) => ({ rel: path.relative(dir, f.destPath), tokens: findResidualTemplateVars(f.content) }))
|
|
132
|
+
.filter((f) => f.tokens.length > 0);
|
|
133
|
+
if (offenders.length > 0) {
|
|
134
|
+
const detail = offenders.map((f) => `${f.rel}: ${f.tokens.join(', ')}`).join('; ');
|
|
135
|
+
throw new Error(`bskel-internal: the fastapi template left unsubstituted variable(s) after rendering (${detail}) -- refusing to write a project containing literal template tokens. Nothing was written.`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const f of files) {
|
|
139
|
+
fs.mkdirSync(path.dirname(f.destPath), { recursive: true });
|
|
140
|
+
fs.writeFileSync(f.destPath, f.content);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const resolvedDatabase = database ?? DEFAULT_DATABASE;
|
|
144
|
+
const postScaffoldNotes = [];
|
|
145
|
+
if (resolvedDatabase !== 'none') {
|
|
146
|
+
// Accurate per choice: postgres really did add a dependency line, sqlite deliberately added
|
|
147
|
+
// nothing at all (CPython ships sqlite3). Saying "pinned the driver" for sqlite would be a
|
|
148
|
+
// small lie in exactly the place this note exists to prevent one.
|
|
149
|
+
const did = resolvedDatabase === 'postgres'
|
|
150
|
+
? 'pinned psycopg[binary] in pyproject.toml'
|
|
151
|
+
: 'pinned no extra dependency (CPython ships sqlite3, which SQLModel/SQLAlchemy drive directly)';
|
|
152
|
+
postScaffoldNotes.push(`--database ${resolvedDatabase} ${did}. That is all it did. NOT done automatically: the engine, session and connection-URL wiring, or any migration setup -- see the "Database" section of the generated README.md.`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
dir,
|
|
157
|
+
name: name ?? slug,
|
|
158
|
+
projectVersion: projectVersion ?? DEFAULT_PROJECT_VERSION,
|
|
159
|
+
requiresPython: requiresPython ?? DEFAULT_REQUIRES_PYTHON,
|
|
160
|
+
port: String(port ?? DEFAULT_PORT),
|
|
161
|
+
license: license ?? null,
|
|
162
|
+
database: resolvedDatabase,
|
|
163
|
+
postScaffoldNotes,
|
|
164
|
+
};
|
|
165
|
+
}
|
package/new/index.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// P2 (D-greenfield-bootstrap): a plain dispatch map, NOT scanners/registry.mjs's/handles/
|
|
2
|
+
// registry.mjs's dynamic-load-and-validate machinery -- those exist for genuine THIRD-PARTY
|
|
3
|
+
// extensibility (G1/G4's explicit design goal, a schema-validated contract other adapters/
|
|
4
|
+
// providers can implement). `bskel new`'s two stacks are first-party-only with no such need; a
|
|
5
|
+
// simple object map is the honest amount of structure for two fixed choices, not an
|
|
6
|
+
// under-justified copy of a pattern built for a different problem.
|
|
7
|
+
//
|
|
8
|
+
// P2b (D-greenfield-parameters): the record shape is WIDER (each stack now declares which
|
|
9
|
+
// parameters it accepts and which it explicitly refuses), and deliberately still a plain object.
|
|
10
|
+
// The reason two first-party stacks don't justify a registry hasn't changed just because each entry
|
|
11
|
+
// grew three fields.
|
|
12
|
+
import { scaffoldSpring } from './spring.mjs';
|
|
13
|
+
import { scaffoldFastapi } from './fastapi.mjs';
|
|
14
|
+
|
|
15
|
+
// Parameters describing the generated project itself, meaningful to any stack.
|
|
16
|
+
const COMMON_PARAMS = ['name', 'description', 'project-version'];
|
|
17
|
+
|
|
18
|
+
// P2b: three Spring Initializr parameters this tool refuses OUTRIGHT rather than passing through --
|
|
19
|
+
// each for a specific, cited reason about what would break downstream, not "not supported yet".
|
|
20
|
+
// Declared here (rather than simply left undeclared) so a user who reasonably expects them -- they
|
|
21
|
+
// are all real controls on start.spring.io's own web UI -- gets the reason instead of a bare
|
|
22
|
+
// "unknown option". They are `hidden` in lib/cli.mjs's COMMANDS table, so they stay out of --help
|
|
23
|
+
// and out of the usage()<->COMMANDS drift test, exactly the mechanism `scan --db` used while it was
|
|
24
|
+
// a documented-but-inert placeholder.
|
|
25
|
+
const SPRING_REFUSED_PARAMS = Object.freeze({
|
|
26
|
+
type: '--type is not supported: `bskel new --stack spring` always requests a Gradle-Groovy project. handles/providers/java-spring/emit.mjs\'s detectJacksonPackage() reads build.gradle ONLY and falls back to the Jackson 2 package when it is absent -- a Maven (pom.xml) or Kotlin-DSL (build.gradle.kts) scaffold would therefore make a later `bskel handles emit` generate code importing com.fasterxml.jackson.databind.ObjectMapper, which is not on the classpath under Initializr\'s current default Spring Boot 4 (Jackson 3). A project that scaffolds fine and then fails to compile is worse than a refusal.',
|
|
27
|
+
language: '--language is not supported: `bskel new --stack spring` always requests Java. scanners/adapters/java-spring.mjs\'s listJavaFiles() globs *.java only and detectJavaSpringRoot() requires src/main/java, and every handles/providers/java-spring template emits .java -- a Kotlin or Groovy scaffold would fall straight through to the generic-grep fallback adapter (specificity 0, confidence "low", no codegen provider at all).',
|
|
28
|
+
'boot-version': '--boot-version is not supported: start.spring.io answers an unknown bootVersion with HTTP 500 and an internal Spring config-class error, which is not something worth surfacing to you, and pinning an exact Boot version is a maintenance liability this tool already argued against (see D-greenfield-bootstrap -- Initializr only serves actively-supported versions and ages old ones out on its own schedule, and detectJacksonPackage() already adapts to whichever major version comes back).',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const STACKS = Object.freeze({
|
|
32
|
+
spring: Object.freeze({
|
|
33
|
+
id: 'spring',
|
|
34
|
+
scaffold: scaffoldSpring,
|
|
35
|
+
requiresNetwork: true,
|
|
36
|
+
acceptedParams: Object.freeze([
|
|
37
|
+
...COMMON_PARAMS,
|
|
38
|
+
'group-id', 'artifact-id', 'package-name', 'java-version', 'packaging',
|
|
39
|
+
'dependencies', 'add-dependencies',
|
|
40
|
+
]),
|
|
41
|
+
refusedParams: SPRING_REFUSED_PARAMS,
|
|
42
|
+
}),
|
|
43
|
+
fastapi: Object.freeze({
|
|
44
|
+
id: 'fastapi',
|
|
45
|
+
scaffold: scaffoldFastapi,
|
|
46
|
+
requiresNetwork: false,
|
|
47
|
+
acceptedParams: Object.freeze([...COMMON_PARAMS, 'python-version', 'port', 'license', 'database']),
|
|
48
|
+
refusedParams: Object.freeze({}),
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Every parameter any stack knows about -- the set cmdNew checks a passed flag against to decide
|
|
53
|
+
// "wrong stack" vs. "not a stack parameter at all". Derived, never a hand-maintained third list.
|
|
54
|
+
export const ALL_STACK_PARAMS = Object.freeze([...new Set(
|
|
55
|
+
Object.values(STACKS).flatMap((s) => [...s.acceptedParams, ...Object.keys(s.refusedParams)]),
|
|
56
|
+
)]);
|
|
57
|
+
|
|
58
|
+
// Which stack DOES accept a given parameter -- so a cross-stack rejection can name the right one
|
|
59
|
+
// ("--group-id applies to --stack spring") instead of just saying no.
|
|
60
|
+
export function stacksAccepting(param) {
|
|
61
|
+
return Object.values(STACKS).filter((s) => s.acceptedParams.includes(param)).map((s) => s.id);
|
|
62
|
+
}
|
package/new/params.mjs
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// P2b (D-greenfield-parameters): local validation for the `bskel new` parameters where letting
|
|
2
|
+
// Spring Initializr's own HTTP 400 be the check is NOT safe.
|
|
3
|
+
//
|
|
4
|
+
// The split is measured, not assumed (see the validation matrix in DECISIONS.md's
|
|
5
|
+
// D-greenfield-parameters): `dependencies`/`type`/`packaging`/`language` all fail CLOSED at
|
|
6
|
+
// start.spring.io with a clean, quotable 400 message, so those stay pass-through. `groupId`/
|
|
7
|
+
// `packageName` fail OPEN -- `groupId=com.new` (a JLS reserved word as a package segment) and
|
|
8
|
+
// `groupId=has space` both return HTTP 200 and a project that cannot compile -- so they need a real
|
|
9
|
+
// local validator. `javaVersion` also fails OPEN (`javaVersion=99` returns 200 and writes
|
|
10
|
+
// `JavaLanguageVersion.of(99)` straight into build.gradle), but its valid set is a MOVING external
|
|
11
|
+
// fact, so it is checked against a live metadata fetch rather than a local list (see below).
|
|
12
|
+
//
|
|
13
|
+
// Why a local Java validator does not go stale the way a local dependency-id or javaVersion list
|
|
14
|
+
// would: the Java package-name grammar (JLS 3.8) and the reserved-keyword list (JLS 3.9) are
|
|
15
|
+
// language-specification constants, not a service's current catalog. Adding a keyword to Java is a
|
|
16
|
+
// language-version event; adding a starter to Initializr happens continuously.
|
|
17
|
+
import { SLUG_RE } from '../lib/featureid.mjs';
|
|
18
|
+
|
|
19
|
+
// JLS 3.9, complete: the 50 classic reserved keywords plus `_` (a keyword since Java 9) -- 51 in
|
|
20
|
+
// the current JLS 3.9 list -- plus the three
|
|
21
|
+
// reserved literals (`true`/`false`/`null` -- not keywords per the spec, but equally illegal as an
|
|
22
|
+
// identifier, which is the only property this validator cares about). Deliberately does NOT include
|
|
23
|
+
// contextual keywords (`var`, `record`, `yield`, `sealed`, `permits`, `module`, ...) -- those remain
|
|
24
|
+
// legal identifiers in general contexts, and rejecting them would be over-validation.
|
|
25
|
+
export const JAVA_RESERVED_WORDS = new Set([
|
|
26
|
+
'abstract', 'assert', 'boolean', 'break', 'byte', 'case', 'catch', 'char', 'class', 'const',
|
|
27
|
+
'continue', 'default', 'do', 'double', 'else', 'enum', 'extends', 'final', 'finally', 'float',
|
|
28
|
+
'for', 'goto', 'if', 'implements', 'import', 'instanceof', 'int', 'interface', 'long', 'native',
|
|
29
|
+
'new', 'package', 'private', 'protected', 'public', 'return', 'short', 'static', 'strictfp',
|
|
30
|
+
'super', 'switch', 'synchronized', 'this', 'throw', 'throws', 'transient', 'try', 'void',
|
|
31
|
+
'volatile', 'while', '_',
|
|
32
|
+
'true', 'false', 'null',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
// ASCII only, on purpose. The Java language itself accepts any `Character.isJavaIdentifierStart`
|
|
36
|
+
// codepoint (so `com.café` really is a legal package name), but this is a scaffolder writing a
|
|
37
|
+
// directory tree that has to survive whatever filesystem, zip extractor and CI runner the project
|
|
38
|
+
// later lands on -- and Initializr accepts a non-ASCII groupId with HTTP 200 either way, so nothing
|
|
39
|
+
// upstream is checking it. Narrower than the language, deliberately, and documented as such.
|
|
40
|
+
const JAVA_IDENTIFIER_SEGMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
41
|
+
|
|
42
|
+
// A control character or a newline in a value that gets written into a TOML/Python/Gradle string
|
|
43
|
+
// literal breaks the generated file rather than the request -- rejected before anything is written.
|
|
44
|
+
function hasControlCharacters(value) {
|
|
45
|
+
for (const ch of value) {
|
|
46
|
+
const code = ch.codePointAt(0);
|
|
47
|
+
if (code < 0x20 || code === 0x7f) return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function requireSingleLineText(value, flag) {
|
|
53
|
+
if (typeof value !== 'string') throw new Error(`--${flag} must be a string`);
|
|
54
|
+
if (hasControlCharacters(value)) {
|
|
55
|
+
throw new Error(`invalid --${flag} -- must not contain newlines or control characters (it is written verbatim into a generated file)`);
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Shared by --group-id and --package-name: same grammar, so one validator, per this project's own
|
|
61
|
+
// "one implementation, two call sites" habit (lib/template.mjs, scanners/text-util.mjs).
|
|
62
|
+
export function requireValidJavaPackageName(value, flag) {
|
|
63
|
+
if (typeof value !== 'string' || value === '') {
|
|
64
|
+
throw new Error(`invalid --${flag} "${value}" -- expected a dot-separated Java package name (e.g. com.example)`);
|
|
65
|
+
}
|
|
66
|
+
const segments = value.split('.');
|
|
67
|
+
for (const segment of segments) {
|
|
68
|
+
if (segment === '') {
|
|
69
|
+
throw new Error(`invalid --${flag} "${value}" -- empty package segment (a leading, trailing or doubled ".")`);
|
|
70
|
+
}
|
|
71
|
+
if (!JAVA_IDENTIFIER_SEGMENT_RE.test(segment)) {
|
|
72
|
+
throw new Error(`invalid --${flag} "${value}" -- segment "${segment}" is not a Java identifier (ASCII letters, digits and "_", never starting with a digit). start.spring.io accepts this with HTTP 200 and generates a project that will not compile, so it is checked here.`);
|
|
73
|
+
}
|
|
74
|
+
if (JAVA_RESERVED_WORDS.has(segment)) {
|
|
75
|
+
throw new Error(`invalid --${flag} "${value}" -- segment "${segment}" is a Java reserved word (JLS 3.9) and cannot be a package segment. start.spring.io accepts this with HTTP 200 and generates a project that will not compile, so it is checked here.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Reuses lib/featureid.mjs's own SLUG_RE -- the exact validator `--slug` (whose value is what
|
|
82
|
+
// `--artifact-id` defaults to) already passes through, rather than a second, subtly different
|
|
83
|
+
// artifact-id grammar.
|
|
84
|
+
export function requireValidArtifactId(value) {
|
|
85
|
+
if (typeof value !== 'string' || !SLUG_RE.test(value)) {
|
|
86
|
+
throw new Error(`invalid --artifact-id "${value}" -- expected lowercase-hyphenated words (e.g. my-service), the same shape --slug accepts`);
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ★ Found live, not reasoned about: the first working `bskel new --stack fastapi --name "Demo
|
|
92
|
+
// Service"` run produced a pyproject.toml with `name = "Demo Service"`, which is valid TOML and an
|
|
93
|
+
// INVALID project name -- `pip install -e .` rejects it outright. Unlike Spring, where Initializr
|
|
94
|
+
// sanitizes `name` into a main-class identifier on its own, nothing downstream of this tool checks
|
|
95
|
+
// a FastAPI project name at all. Same category as the Java package validator above: a fixed grammar
|
|
96
|
+
// from a published specification (PEP 508 / the PyPA name spec, reproduced verbatim below), so a
|
|
97
|
+
// local check here does not go stale the way a copy of a service's current catalog would.
|
|
98
|
+
//
|
|
99
|
+
// Consequence, documented rather than worked around: `--name` sets BOTH pyproject.toml's `name` and
|
|
100
|
+
// FastAPI's `title=`, so a prose title with spaces is rejected. Editing `app/main.py`'s one
|
|
101
|
+
// `FastAPI(title=...)` line afterwards is the escape hatch; splitting this into two flags would be
|
|
102
|
+
// inventing a distinction the user did not ask for.
|
|
103
|
+
const PYTHON_PROJECT_NAME_RE = /^([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9])$/;
|
|
104
|
+
|
|
105
|
+
export function requireValidPythonProjectName(value) {
|
|
106
|
+
if (typeof value !== 'string' || !PYTHON_PROJECT_NAME_RE.test(value)) {
|
|
107
|
+
throw new Error(`invalid --name "${value}" -- must be a valid Python project name (PEP 508: ASCII letters, digits, ".", "-" and "_", starting and ending alphanumeric). It is written into pyproject.toml's [project] name, which pip validates -- a name with spaces produces a project that scaffolds fine and cannot be installed.`);
|
|
108
|
+
}
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// `requires-python` is a PEP 440 version specifier. There is no live authority to check a Python
|
|
113
|
+
// version against the way start.spring.io's metadata is one for Java (python.org publishes no
|
|
114
|
+
// equivalent machine-readable "currently supported" document this tool could consult on demand),
|
|
115
|
+
// and the value is only ever substituted into pyproject.toml as a string -- so this validates that
|
|
116
|
+
// it is SYNTACTICALLY sane, never that the version exists.
|
|
117
|
+
//
|
|
118
|
+
// Accepted, deliberately narrow (one clause, no comma-joined specifier sets): a bare `3.12` /
|
|
119
|
+
// `3.12.1` (normalized to `>=3.12`), or an explicit single-operator floor (`>=3.12`, `>3.12`,
|
|
120
|
+
// `~=3.12`, `==3.12`). A bare upper bound (`<3.13`) is rejected because the flag is documented as a
|
|
121
|
+
// FLOOR -- silently accepting a specifier that says the opposite of what the flag is named would be
|
|
122
|
+
// the exact "the generated file encodes a claim the user did not state" failure this item's own
|
|
123
|
+
// safe/unsafe line rules out. A project needing a compound specifier edits one line of the
|
|
124
|
+
// generated pyproject.toml, which is a file it now owns outright.
|
|
125
|
+
const PYTHON_VERSION_RE = /^(>=|>|~=|==)?(\d+)\.(\d+)(?:\.(\d+))?$/;
|
|
126
|
+
|
|
127
|
+
// The shipped FastAPI template's own floor, not a claim about Python or FastAPI in general:
|
|
128
|
+
// new/templates/fastapi/app/main.py annotates `-> dict[str, str]`, a PEP 585 builtin generic that
|
|
129
|
+
// raises TypeError at runtime before 3.9, and FastAPI evaluates route return annotations.
|
|
130
|
+
export const FASTAPI_TEMPLATE_MIN_PYTHON = { major: 3, minor: 9 };
|
|
131
|
+
|
|
132
|
+
export function parsePythonVersion(value) {
|
|
133
|
+
if (typeof value !== 'string') return null;
|
|
134
|
+
const m = PYTHON_VERSION_RE.exec(value.trim());
|
|
135
|
+
if (!m) return null;
|
|
136
|
+
return {
|
|
137
|
+
operator: m[1] ?? '>=',
|
|
138
|
+
major: Number(m[2]),
|
|
139
|
+
minor: Number(m[3]),
|
|
140
|
+
patch: m[4] == null ? null : Number(m[4]),
|
|
141
|
+
release: m[4] == null ? `${m[2]}.${m[3]}` : `${m[2]}.${m[3]}.${m[4]}`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Returns `{ requiresPython, warnings }` -- the exact string to substitute into pyproject.toml's
|
|
146
|
+
// `requires-python`, plus any "this is legal but your generated code needs more" notes. Warns
|
|
147
|
+
// rather than refuses, matching --dependencies' own "warn loudly, then trust the user" register:
|
|
148
|
+
// a caller who intends to rewrite app/main.py is making a legitimate choice this tool should not
|
|
149
|
+
// veto.
|
|
150
|
+
export function requireValidPythonVersion(value) {
|
|
151
|
+
const parsed = parsePythonVersion(value);
|
|
152
|
+
if (!parsed) {
|
|
153
|
+
throw new Error(`invalid --python-version "${value}" -- expected a bare version (3.12, 3.12.1) or a single-operator floor (>=3.12, >3.12, ~=3.12, ==3.12); a compound specifier or a bare upper bound is not accepted (this flag sets pyproject.toml's requires-python FLOOR)`);
|
|
154
|
+
}
|
|
155
|
+
const warnings = [];
|
|
156
|
+
const belowTemplateFloor = parsed.major < FASTAPI_TEMPLATE_MIN_PYTHON.major
|
|
157
|
+
|| (parsed.major === FASTAPI_TEMPLATE_MIN_PYTHON.major && parsed.minor < FASTAPI_TEMPLATE_MIN_PYTHON.minor);
|
|
158
|
+
if (belowTemplateFloor) {
|
|
159
|
+
warnings.push(`--python-version ${value} sets requires-python below ${FASTAPI_TEMPLATE_MIN_PYTHON.major}.${FASTAPI_TEMPLATE_MIN_PYTHON.minor}, which the generated app/main.py itself needs: its \`-> dict[str, str]\` return annotation is a PEP 585 builtin generic that raises TypeError at runtime on older interpreters, and FastAPI evaluates route return annotations. Scaffolding anyway -- rewrite app/main.py's annotations if you really mean to target ${parsed.release}.`);
|
|
160
|
+
}
|
|
161
|
+
return { requiresPython: `${parsed.operator}${parsed.release}`, warnings };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// An SPDX identifier or short expression. Deliberately NOT checked against a bundled copy of the
|
|
165
|
+
// SPDX license list: that list is external truth that changes, and a stale local copy of external
|
|
166
|
+
// truth is the exact thing this project refuses everywhere else (D-greenfield-bootstrap's rejected
|
|
167
|
+
// `.bskel/config.yml`, `runScan()`'s fresh-every-run adapter detection). Unlike --java-version there
|
|
168
|
+
// is no cheap on-demand authority to consult either, so this validates SHAPE only -- enough to keep
|
|
169
|
+
// the value from breaking the TOML string it lands in.
|
|
170
|
+
const LICENSE_RE = /^[A-Za-z0-9][A-Za-z0-9.+ ()-]*$/;
|
|
171
|
+
|
|
172
|
+
export function requireValidLicense(value) {
|
|
173
|
+
if (typeof value !== 'string' || !LICENSE_RE.test(value)) {
|
|
174
|
+
throw new Error(`invalid --license "${value}" -- expected an SPDX identifier or short expression (e.g. MIT, Apache-2.0, "MIT OR Apache-2.0"). Not checked against the real SPDX list -- this tool keeps no local copy of external truth.`);
|
|
175
|
+
}
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export const DATABASE_CHOICES = Object.freeze(['postgres', 'sqlite', 'none']);
|
|
180
|
+
|
|
181
|
+
export function requireValidDatabase(value) {
|
|
182
|
+
if (!DATABASE_CHOICES.includes(value)) {
|
|
183
|
+
throw new Error(`invalid --database "${value}" -- expected one of: ${DATABASE_CHOICES.join(', ')}`);
|
|
184
|
+
}
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---- --java-version: on-demand live metadata, never cached, never persisted ----------------
|
|
189
|
+
//
|
|
190
|
+
// start.spring.io/metadata/client is the same document the start.spring.io web UI populates its own
|
|
191
|
+
// dropdowns from. Fetched ONLY when a non-default --java-version is actually passed (a user who
|
|
192
|
+
// doesn't ask pays no extra network call), and never written to disk -- consistent with
|
|
193
|
+
// D-greenfield-bootstrap's refusal to persist a `.bskel/config.yml`, and with the whole project's
|
|
194
|
+
// position that a local copy of a moving external fact is worse than no copy.
|
|
195
|
+
export const INITIALIZR_METADATA_URL = 'https://start.spring.io/metadata/client';
|
|
196
|
+
|
|
197
|
+
// Measured 2026-08-23: `javaVersion` is `{type, default, values: [{id, name}, ...]}` with ids
|
|
198
|
+
// ["26","25","21","17"] and default "17". Parsed defensively anyway -- a shape change upstream must
|
|
199
|
+
// produce a clean, actionable message here, not a TypeError.
|
|
200
|
+
export async function fetchSupportedJavaVersions({ fetchImpl = globalThis.fetch } = {}) {
|
|
201
|
+
let response;
|
|
202
|
+
try {
|
|
203
|
+
response = await fetchImpl(INITIALIZR_METADATA_URL, { headers: { Accept: 'application/json' } });
|
|
204
|
+
} catch (err) {
|
|
205
|
+
throw new Error(`could not reach start.spring.io to validate --java-version (${err.message}) -- check network access, or omit --java-version to use this tool's own default`);
|
|
206
|
+
}
|
|
207
|
+
if (!response.ok) {
|
|
208
|
+
throw new Error(`start.spring.io returned ${response.status} ${response.statusText} for its own version metadata -- the request was: ${INITIALIZR_METADATA_URL}`);
|
|
209
|
+
}
|
|
210
|
+
let body;
|
|
211
|
+
try {
|
|
212
|
+
body = await response.json();
|
|
213
|
+
} catch (err) {
|
|
214
|
+
throw new Error(`start.spring.io's version metadata was not readable JSON (${err.message}) -- the request was: ${INITIALIZR_METADATA_URL}`);
|
|
215
|
+
}
|
|
216
|
+
const values = body?.javaVersion?.values;
|
|
217
|
+
const ids = Array.isArray(values) ? values.map((v) => v?.id).filter((id) => typeof id === 'string' && id !== '') : [];
|
|
218
|
+
if (ids.length === 0) {
|
|
219
|
+
throw new Error(`start.spring.io's version metadata carried no javaVersion.values list -- cannot validate --java-version against it. The request was: ${INITIALIZR_METADATA_URL}`);
|
|
220
|
+
}
|
|
221
|
+
return ids;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function requireSupportedJavaVersion(javaVersion, opts = {}) {
|
|
225
|
+
if (typeof javaVersion !== 'string' || javaVersion === '') {
|
|
226
|
+
throw new Error(`invalid --java-version "${javaVersion}" -- expected a version id start.spring.io offers (e.g. 17, 21, 25)`);
|
|
227
|
+
}
|
|
228
|
+
const supported = await fetchSupportedJavaVersions(opts);
|
|
229
|
+
if (!supported.includes(javaVersion)) {
|
|
230
|
+
throw new Error(`--java-version ${javaVersion} is not one start.spring.io currently offers (it offers: ${supported.join(', ')}). This is checked here because start.spring.io accepts an unknown javaVersion with HTTP 200 and writes it straight into build.gradle as JavaLanguageVersion.of(${javaVersion}) -- a project that downloads fine and never compiles.`);
|
|
231
|
+
}
|
|
232
|
+
return javaVersion;
|
|
233
|
+
}
|