create-paratix 0.0.1 → 0.2.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # create-paratix
2
2
 
3
- Scaffolds a new [Paratix](https://github.com/sebastian-software/paratix) server project. Run it once to get a working directory structure with a TypeScript playbook, then edit and apply.
3
+ Scaffolds a new [Paratix](https://github.com/sebastian-software/paratix) server project. The CLI now explains which initial SSH user Paratix needs for the very first connection and lets you choose the matching bootstrap path via arrow-key selection: explicit `root` bootstrap or direct admin-user hardening.
4
4
 
5
5
  ## Quick Start
6
6
 
@@ -20,6 +20,22 @@ yarn create paratix my-server
20
20
  bunx create-paratix my-server
21
21
  ```
22
22
 
23
+ Optional non-interactive bootstrap values:
24
+
25
+ ```sh
26
+ # npm
27
+ npm create paratix my-server -- --host example.com --initial-user root --admin-public-key-file ~/.ssh/id_ed25519.pub
28
+
29
+ # pnpm
30
+ pnpm create paratix my-server --host example.com --initial-user root --admin-public-key-file ~/.ssh/id_ed25519.pub
31
+
32
+ # yarn
33
+ yarn create paratix my-server --host deploy.example.com --initial-user deploy --admin-public-key "ssh-ed25519 AAAA... you@example.com"
34
+
35
+ # bun
36
+ bunx create-paratix my-server --host deploy.example.com --initial-user deploy --admin-public-key-file ~/.ssh/id_ed25519.pub
37
+ ```
38
+
23
39
  **Step 2 -- Enter the directory**
24
40
 
25
41
  Dependencies are installed automatically. If installation fails, run your package manager's install command manually.
@@ -28,7 +44,15 @@ Dependencies are installed automatically. If installation fails, run your packag
28
44
  cd my-server
29
45
  ```
30
46
 
31
- **Step 3 -- Edit `server.ts`** with your actual server address, SSH user, and the modules you want to apply.
47
+ **Step 3 -- Review `server.ts`** with your final hostname, admin username, selected public key or placeholder, and the modules you want to apply.
48
+
49
+ The scaffold also includes an explicit bootstrap switch driven by `PARATIX_FIRST_RUN`:
50
+
51
+ - first run: call `paratix apply ... --first-run`, which sets `PARATIX_FIRST_RUN=true`, keeps SSH on port `22`, opens firewall port `22`, and uses `strictHostKeyChecking: "accept-new"`
52
+ - root bootstrap path: the generated playbook also writes a dedicated `/etc/sudoers.d` drop-in so the new admin user can continue with `NOPASSWD sudo` after the first run
53
+ - later runs: call `paratix apply ...` without `--first-run`, so the generated playbook switches to port `2222`, closes SSH port `22` in the firewall, and returns to strict host-key checking
54
+ - the generated playbook still opens port `2222` before `sshd.port(2222)` runs, so the first real apply can reconnect safely
55
+ - the generated playbook now stops the explicit first run after SSH hardening, kernel hardening, and automatic security upgrades, so later application services run only on the hardened baseline
32
56
 
33
57
  **Step 4 -- Apply**
34
58
 
@@ -40,67 +64,218 @@ pnpm apply # apply to the server
40
64
  # npm
41
65
  npm run apply:dry
42
66
  npm run apply
67
+ npm run lint
68
+ npm run format:check
69
+ npm run format:fix
43
70
  ```
44
71
 
45
72
  ## Project Structure
46
73
 
47
- | File / Directory | Purpose |
48
- | ---------------- | ------------------------------------------------------------------------- |
49
- | `server.ts` | Your playbook. Edit this file. |
50
- | `package.json` | Includes `apply` and `apply:dry` scripts. |
51
- | `tsconfig.json` | TypeScript config (ES2024, NodeNext, strict). |
52
- | `.gitignore` | Excludes `node_modules/`, `dist/`, `.env`, and log files. |
53
- | `.env.example` | Template for secrets. Copy to `.env` and fill in values. |
54
- | `files/` | Place template files here. They get uploaded to the server at apply time. |
74
+ | File / Directory | Purpose |
75
+ | ------------------ | -------------------------------------------------------------------------------- |
76
+ | `server.ts` | Your playbook. Edit this file. |
77
+ | `package.json` | Includes `apply`, `apply:dry`, `lint`, `format:check`, and `format:fix` scripts. |
78
+ | `tsconfig.json` | TypeScript config (ES2024, ESNext/Bundler, strict) for direct `tsx` use. |
79
+ | `eslint.config.ts` | ESLint config via `eslint-config-setup` for Node-based scaffold projects. |
80
+ | `.prettierrc` | Prettier defaults for the scaffolded project. |
81
+ | `.prettierignore` | Excludes lockfiles from Prettier runs. |
82
+ | `.gitignore` | Excludes `node_modules/`, `dist/`, `.env`, and log files. |
83
+ | `.env.example` | Template for secrets. Copy to `.env` and fill in values. |
84
+ | `files/` | Place template files here. They get uploaded to the server at apply time. |
55
85
 
56
86
  ## Writing Your Playbook
57
87
 
58
- `server.ts` exports a server definition. The scaffolded file looks like this:
88
+ `server.ts` exports a server definition. The generated file depends on the initial SSH user you choose:
89
+
90
+ - `root`: Bootstrap once via `root`, create a dedicated admin user, then switch `ssh.user` to that admin user and disable root login.
91
+ - `admin`: Connect directly as the named admin user and scaffold the hardened end state immediately.
92
+
93
+ The direct admin-user path looks like this:
59
94
 
60
95
  ```typescript
61
- import { server, recipe } from "paratix"
62
- import { package as pkg, hostname, sshd, ufw, file, service, user } from "paratix/modules"
96
+ import { recipe, server } from "paratix"
97
+ import { hostname, net, package as packages, ssh, sshd, ufw, user } from "paratix/modules"
98
+
99
+ const adminUser = "paratix"
100
+ const adminPublicKey = "ssh-ed25519 REPLACE_ME_WITH_YOUR_PUBLIC_KEY"
101
+ const serverName = "my-server"
102
+ const FIRST_RUN = process.env["PARATIX_FIRST_RUN"] === "true"
103
+ const sshPorts = FIRST_RUN ? [22] : [2222]
104
+ const firewallTcpPorts = FIRST_RUN ? [22, 2222, 80, 443] : [2222, 80, 443]
105
+ const strictHostKeyChecking = FIRST_RUN ? "accept-new" : "yes"
63
106
 
64
107
  export default server({
65
- name: "my-server",
66
108
  host: "1.2.3.4",
67
- ssh: {
68
- user: "root",
69
- ports: [22],
70
- privateKey: "~/.ssh/id_ed25519",
71
- },
109
+ name: serverName,
72
110
  env: {
73
- SERVER_NAME: "my-server",
111
+ FIRST_RUN,
112
+ SERVER_NAME: serverName,
74
113
  SSH_PORT: 2222,
75
114
  },
115
+ ssh: {
116
+ ports: sshPorts,
117
+ privateKey: "~/.ssh/id_ed25519", // "~" is expanded by Paratix
118
+ user: adminUser,
119
+ // FIRST_RUN keeps the bootstrap path explicit:
120
+ // - pass `paratix apply ... --first-run` for the bootstrap run
121
+ // - later runs omit that flag and go through port 2222 with strict host-key checking again
122
+ strictHostKeyChecking,
123
+ // expectedHostFingerprint: "SHA256:REPLACE_ME_WITH_YOUR_HOST_FINGERPRINT",
124
+ // expectedHostPublicKey: "ssh-ed25519 REPLACE_ME_WITH_YOUR_HOST_PUBLIC_KEY",
125
+ },
76
126
  run: [
77
- hostname.set("my-server"),
78
- pkg.upgrade("2026-03-01"),
79
- pkg.installed("nginx", "curl", "htop"),
80
-
81
- recipe(
82
- "ssh-hardening",
83
- [
84
- sshd.port(2222),
85
- sshd.config({
86
- PermitRootLogin: "no",
87
- PasswordAuthentication: "no",
88
- }),
89
- ],
90
- {
91
- signals: [service.restart("sshd")],
92
- }
93
- ),
94
-
95
- recipe("firewall", [ufw.rule("allow", [22, 2222, 80, 443]), ufw.enabled()]),
127
+ net.hosts("127.0.1.1", [serverName]),
128
+ hostname.set(serverName),
129
+ packages.upgrade("2026-03-01"),
130
+ packages.installed("curl", "htop", "ufw"),
131
+
132
+ recipe("admin-access", [
133
+ user.present(adminUser, {
134
+ groups: ["sudo"],
135
+ shell: "/bin/bash",
136
+ }),
137
+ ssh.authorizedKeys(adminUser, adminPublicKey),
138
+ ]),
139
+
140
+ recipe("firewall", [ufw.rule("allow", firewallTcpPorts), ufw.enabled()]),
141
+
142
+ recipe("ssh-hardening", [
143
+ sshd.port(2222),
144
+ sshd.config({
145
+ PasswordAuthentication: "no",
146
+ PermitRootLogin: "no",
147
+ }),
148
+ ]),
149
+
150
+ recipe("kernel-hardening", [
151
+ sysctl.set("fs.protected_hardlinks", "1"),
152
+ sysctl.set("fs.protected_symlinks", "1"),
153
+ sysctl.set("kernel.dmesg_restrict", "1"),
154
+ sysctl.set("kernel.kptr_restrict", "2"),
155
+ sysctl.set("net.ipv4.conf.all.rp_filter", "1"),
156
+ sysctl.set("net.ipv4.conf.default.rp_filter", "1"),
157
+ sysctl.set("net.ipv4.tcp_syncookies", "1"),
158
+ ]),
159
+
160
+ recipe("automatic-security-upgrades", [
161
+ packages.installed("unattended-upgrades"),
162
+ file.copy("/etc/apt/apt.conf.d/20auto-upgrades", "./files/20auto-upgrades", {
163
+ mode: "0644",
164
+ owner: "root:root",
165
+ }),
166
+ file.copy("/etc/apt/apt.conf.d/50unattended-upgrades", "./files/50unattended-upgrades", {
167
+ mode: "0644",
168
+ owner: "root:root",
169
+ }),
170
+ ]),
171
+
172
+ firstRun.stop("Bootstrap foundation complete; rerun without --first-run to continue."),
173
+
174
+ // Add application and user-facing services below this line.
96
175
  ],
97
176
  })
98
177
  ```
99
178
 
179
+ The generated `tsconfig.json` is intentionally DX-oriented for `tsx`-executed TypeScript projects:
180
+
181
+ - `module: "ESNext"`
182
+ - `moduleResolution: "Bundler"`
183
+ - `include: ["**/*.ts"]`
184
+
185
+ That means extensionless relative imports in your `.ts` sources work without NodeNext-style `.js` suffixes. This is a deliberate trade-off in favor of authoring ergonomics over strict Node-ESM path checking.
186
+
187
+ The scaffold also includes Prettier out of the box:
188
+
189
+ - `format:check` runs `prettier --check .`
190
+ - `format:fix` runs `prettier --write .`
191
+ - `.prettierignore` excludes lockfiles such as `pnpm-lock.yaml`
192
+
193
+ The scaffold also includes ESLint:
194
+
195
+ - `lint` runs `eslint .`
196
+ - `eslint.config.ts` uses `await getEslintConfig({ node: true })`
197
+
198
+ Wenn dein Server initial nur `root` per SSH anbietet, wähle im Prompt `root` oder rufe das Scaffold nicht-interaktiv mit `--initial-user root` auf. Dieses Template bleibt bewusst als temporärer Bootstrap markiert, erstellt den dedizierten Admin-User, legt einen `NOPASSWD sudo`-Eintrag für ihn unter `/etc/sudoers.d/` an und lässt Root-Login nur vorübergehend auf `prohibit-password`, bis du `ssh.user` auf den Admin-User umgestellt hast.
199
+
200
+ Wenn bereits ein Admin-User wie `deploy`, `ubuntu` oder `admin` existiert, wähle diesen Namen direkt. Dann erzeugt `create-paratix` keinen Root-Bootstrap-Pfad, sondern scaffoldet sofort den gehärteten Zielzustand für genau diesen User.
201
+
202
+ ### Initial user selection
203
+
204
+ Standardmäßig fragt `create-paratix` interaktiv:
205
+
206
+ 1. Welche Domain oder IP soll als Zielhost in `server.ts` stehen?
207
+ 2. Ist der initiale SSH-User `root` oder ein Admin-User?
208
+ 3. Falls Admin-User: Wie heißt dieser User konkret?
209
+ 4. Soll ein vorhandener Public Key aus `~/.ssh` direkt übernommen werden?
210
+
211
+ Im interaktiven Modus zeigt `create-paratix` dafür eine kurze Erklärung und eine Auswahl per Pfeiltasten:
212
+
213
+ - `Root user`: frischer Server mit SSH nur als `root`; Paratix bootstrapt zuerst einen dedizierten Admin-User
214
+ - `Admin user`: ein konkreter Admin-User existiert bereits; Paratix verbindet sich direkt als dieser User
215
+
216
+ Nicht-interaktiv funktioniert derselbe Vertrag über `--host`, `--initial-user` und optional einen Admin-Public-Key:
217
+
218
+ ```sh
219
+ # Root bootstrap
220
+ pnpm create paratix my-server --host example.com --initial-user root --admin-public-key-file ~/.ssh/id_ed25519.pub
221
+
222
+ # Existing admin user
223
+ pnpm create paratix my-server --host deploy.example.com --initial-user deploy --admin-public-key "ssh-ed25519 AAAA... you@example.com"
224
+ ```
225
+
226
+ Wichtig für den ersten echten Lauf: Das Scaffold liest `FIRST_RUN` aus `process.env.PARATIX_FIRST_RUN`. Für den Bootstrap rufst du Paratix explizit mit `--first-run` auf. Danach lässt du den Flag bei normalen Läufen weg; dann verwendet dasselbe Playbook Port `2222`, entfernt Port `22` aus der Firewall und kehrt zu strengem Host-Key-Checking zurück. Die Firewall-Freigabe für `2222` bleibt bewusst vor dem eigentlichen SSH-Portwechsel, damit Paratix nach `sshd.port(...)` sofort sicher reconnecten kann.
227
+
228
+ ### Public key bootstrap
229
+
230
+ Im interaktiven Modus bietet `create-paratix` zusätzlich an, einen vorhandenen Public Key aus
231
+ `~/.ssh` direkt in `server.ts` zu übernehmen.
232
+
233
+ - Wenn du zustimmst und mehrere `.pub`-Dateien existieren, kannst du den gewünschten Key per
234
+ Pfeiltasten auswählen.
235
+ - Wenn keine lesbaren `.pub`-Dateien gefunden werden, bleibt das bestehende Placeholder-Template
236
+ erhalten.
237
+ - Nicht-interaktiv kannst du denselben Wert über `--admin-public-key` oder
238
+ `--admin-public-key-file` setzen.
239
+ - Wenn kein CLI-Key gesetzt ist, bleibt in nicht-interaktiven Aufrufen weiter der Placeholder
240
+ erhalten.
241
+
242
+ ### Host-key bootstrap
243
+
244
+ Paratix verwendet standardmäßig striktes Host-Key-Checking. `create-paratix` bietet deshalb interaktiv an, den aktuell auf SSH-Port `22` präsentierten Host-Key direkt per `ssh2` auszulesen und als `expectedHostFingerprint` in `server.ts` zu pinnen.
245
+
246
+ Wenn du diesen Schritt bestätigst, erzeugt das Scaffold direkt einen expliziten Trust Anchor:
247
+
248
+ ```ts
249
+ const strictHostKeyChecking = "yes"
250
+ // ...
251
+ expectedHostFingerprint: "SHA256:..."
252
+ ```
253
+
254
+ Das ist ein bewusster TOFU-Schritt beim Scaffold-Zeitpunkt: Der Fingerprint stammt von dem Host-Key, den der Server im Moment des Scaffoldings auf Port `22` präsentiert. Du kannst ihn später jederzeit manuell prüfen oder ersetzen.
255
+
256
+ Wenn du den Abruf ablehnst oder er fehlschlägt, bleibt der bisherige Fallback erhalten:
257
+
258
+ ```ts
259
+ const FIRST_RUN = process.env["PARATIX_FIRST_RUN"] === "true"
260
+ const strictHostKeyChecking = FIRST_RUN ? "accept-new" : "yes"
261
+ ```
262
+
263
+ Das ist ein bewusst markierter Übergangsmodus für den ersten verifizierten Kontakt mit einem frischen Host. Direkt daneben enthält das generierte `server.ts` weiterhin kommentierte Platzhalter für:
264
+
265
+ - `expectedHostFingerprint`
266
+ - `expectedHostPublicKey`
267
+
268
+ Empfohlener Ablauf:
269
+
270
+ 1. Verifiziere den Host-Key deines Servers out of band.
271
+ 2. Führe den ersten `apply:dry` und `apply` mit `--first-run` aus.
272
+ 3. Führe spätere Runs ohne `--first-run` aus.
273
+ 4. Optional: pinne zusätzlich `expectedHostFingerprint` oder `expectedHostPublicKey`.
274
+
100
275
  Key concepts:
101
276
 
102
277
  - **Modules** -- each item in `run` is a module. A module checks the current server state and applies changes only when needed (idempotent).
103
- - **Recipes** -- `recipe()` groups related modules under a name. If any module in the group changes something, signals fire after the group completes (e.g. `service.restart("sshd")`).
278
+ - **Recipes** -- `recipe()` groups related modules under a name. If any module in the group changes something, signals fire after the group completes.
104
279
  - **Signals** -- actions that run after a recipe when at least one module in it made a change. Useful for reloading services.
105
280
  - **Env** -- values in the `env` field are available in template files as `{{KEY}}`. See [Environment Variables](#environment-variables) below.
106
281
 
@@ -163,10 +338,10 @@ The example contains:
163
338
  Env values come from three sources, merged in this order (last wins):
164
339
 
165
340
  1. `--env-file <path>`
166
- 2. `--env <key=value>` flags
167
- 3. The `env` field in `server()` -- this has the highest priority
341
+ 2. The `env` field in `server()`
342
+ 3. `--env <key=value>` flags -- these have the highest priority
168
343
 
169
- > **Note:** Because `server({ env })` has the highest priority, values defined there cannot be overridden from the CLI. Do not put secrets (passwords, tokens) or values you need to change per run in `server({ env })` -- use `.env` files or `--env` flags for those. Reserve the `env` field in `server()` for static defaults that are the same across every run.
344
+ > **Note:** CLI `--env` flags override both `.env` files and `server({ env })`. Put stable project defaults in `server({ env })`, environment-specific values in `.env` files, and one-off overrides on the CLI.
170
345
 
171
346
  ### Template files
172
347
 
@@ -0,0 +1,57 @@
1
+ type PackageManager = {
2
+ command: string;
3
+ name: string;
4
+ };
5
+
6
+ type InitialUserConfig = {
7
+ kind: "admin";
8
+ user: string;
9
+ } | {
10
+ kind: "root";
11
+ };
12
+
13
+ type SelectOption<TValue extends string> = {
14
+ description: string;
15
+ label: string;
16
+ value: TValue;
17
+ };
18
+ type SelectFunction<TValue extends string> = (prompt: string, options: Array<SelectOption<TValue>>) => Promise<TValue>;
19
+
20
+ type PromptFunction = (question: string) => Promise<string>;
21
+ declare function promptForHost(prompt?: PromptFunction, closePrompt?: () => void): Promise<string>;
22
+ declare function promptForInitialUserConfig(prompt?: PromptFunction, select?: SelectFunction<"admin" | "root">): Promise<InitialUserConfig>;
23
+ declare function promptForAdminPublicKey(select?: SelectFunction<string>, publicKeys?: Array<{
24
+ key: string;
25
+ label: string;
26
+ path: string;
27
+ }>): Promise<string | undefined>;
28
+ declare function promptForHostFingerprint(host: string, select?: SelectFunction<"placeholder" | "scan">, scanner?: (host: string) => Promise<string>): Promise<string | undefined>;
29
+
30
+ declare function normalizeInitialUserName(name: string): string;
31
+ declare function isValidInitialUserName(name: string): boolean;
32
+ declare function normalizeHost(value: string): string;
33
+ declare function isValidHost(value: string): boolean;
34
+
35
+ type ScaffoldOptions = {
36
+ adminPublicKey?: string;
37
+ expectedHostFingerprint?: string;
38
+ host?: string;
39
+ initialUser?: InitialUserConfig;
40
+ installer?: (projectDirectory: string, packageManager: PackageManager) => boolean;
41
+ };
42
+ declare function writeProjectFiles(projectDirectory: string, options?: ScaffoldOptions): void;
43
+ declare function isValidProjectName(name: string): boolean;
44
+ declare function normalizeProjectName(name: string): string;
45
+ declare function parseCliArguments(argv: string[]): {
46
+ adminPublicKey: string | undefined;
47
+ adminPublicKeyFile: string | undefined;
48
+ host: string | undefined;
49
+ initialUser: string | undefined;
50
+ projectName: string | undefined;
51
+ };
52
+ declare function parseInitialUserConfig(value: string): InitialUserConfig;
53
+ declare function validateHost(value: string): string;
54
+ declare function scaffoldProject(projectName: string, pm: PackageManager, options?: ScaffoldOptions): boolean;
55
+ declare function isDirectExecution(moduleUrl: string, argv1: null | string | undefined): boolean;
56
+
57
+ export { type InitialUserConfig, isDirectExecution, isValidHost, isValidInitialUserName, isValidProjectName, normalizeHost, normalizeInitialUserName, normalizeProjectName, parseCliArguments, parseInitialUserConfig, promptForAdminPublicKey, promptForHost, promptForHostFingerprint, promptForInitialUserConfig, scaffoldProject, validateHost, writeProjectFiles };