create-rakomi-app 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CRE8EVE Sp. z o.o.
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 ADDED
@@ -0,0 +1,83 @@
1
+ # create-rakomi-app
2
+
3
+ Scaffold a [Rakomi](https://rakomi.com) quickstart app in seconds. Rakomi is EU-native
4
+ authentication as a service.
5
+
6
+ ```sh
7
+ npx create-rakomi-app@latest --template nextjs my-app
8
+ ```
9
+
10
+ The `@latest` tag sidesteps a stale `npx` cache. The scoped `npm create` form also works:
11
+
12
+ ```sh
13
+ npm create @rakomi/rakomi-app@latest -- --template nextjs my-app
14
+ ```
15
+
16
+ > The unscoped `npm create rakomi-app` form is **not** supported — `npm create` cannot resolve a
17
+ > scoped package from the bare name. Use one of the two forms above. (An unscoped alias may be
18
+ > offered in the future.)
19
+
20
+ ## Templates
21
+
22
+ | Slug | Description |
23
+ | -------- | ----------------- |
24
+ | `nextjs` | Next.js quickstart |
25
+ | `react` | React quickstart |
26
+ | `node` | Node quickstart |
27
+ | `expo` | Expo quickstart |
28
+
29
+ The template list is **not** hardcoded — it is generated from a shared quickstart registry that is
30
+ the single source of truth. Adding a quickstart there makes it available here automatically.
31
+
32
+ ## Options
33
+
34
+ | Option | Meaning |
35
+ | ------------------------- | --------------------------------------------------------- |
36
+ | `--template <slug>` | which quickstart to scaffold (required) |
37
+ | `--region <value>` | data region (default: `eu-central`) |
38
+ | `--tenant-id <value>` | your tenant id |
39
+ | `--template-source <url>` | override the archive base (mirror / offline) |
40
+ | `--yes` | accept defaults, never prompt (non-interactive) |
41
+ | `-h`, `--help` | show help |
42
+ | `-V`, `--version` | print the version |
43
+
44
+ The CLI prompts for `RAKOMI_REGION`, `RAKOMI_TENANT_ID`, and `RAKOMI_API_KEY` and writes them to a
45
+ local `.env` in the new project. Value precedence is: flag > environment variable > prompt >
46
+ default. In a non-interactive context (a pipe, a continuous-integration job, or `--yes`) it never
47
+ blocks — it uses flags / environment values / defaults and leaves the rest for you to fill in.
48
+
49
+ The `RAKOMI_API_KEY` is a credential: it is read only from the prompt or the environment (never a
50
+ command-line flag, which would persist in shell history), is written only to your local `.env`, is
51
+ never printed back, and the new project's `.gitignore` is updated so you do not commit it.
52
+
53
+ ### EU-region default
54
+
55
+ `RAKOMI_REGION` defaults to `eu-central` — a visible data-residency stance, not a mandate. Override
56
+ it with `--region` or the `RAKOMI_REGION` environment variable for any other region.
57
+
58
+ ## Intentional choices
59
+
60
+ - **No telemetry.** The CLI collects nothing and phones home about nothing. There is no usage
61
+ analytics and nothing to opt out of.
62
+ - **Refuses to overwrite.** Scaffolding into a directory that already contains files is refused
63
+ outright — your existing work is never clobbered. Pick a fresh directory name.
64
+ - **Scoped package name.** The published name is scoped, which is why the bare `npm create
65
+ rakomi-app` form does not resolve; use the two supported invocations above.
66
+
67
+ ## Data handling
68
+
69
+ This tool **stores nothing and transmits nothing**. The only data involved is the values you type,
70
+ written to your own local `.env` file on your own machine — exporting or deleting that data is
71
+ simply reading or removing that file. Adoption is observable only through public package-registry
72
+ download counts, which are first-party to the registry, not to this tool.
73
+
74
+ ## Continuity
75
+
76
+ The package, its publishing credentials, and the shared template registry are company-held with a
77
+ two-person backup, and the package is published by the release pipeline. The pipeline performs the
78
+ public release once the public package registry serves the Rakomi SDK and the template repositories
79
+ are public.
80
+
81
+ ## License
82
+
83
+ [MIT](./LICENSE) © CRE8EVE Sp. z o.o.
package/dist/env.js ADDED
@@ -0,0 +1,39 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ /** Keys the scaffolder collects, in written order. Names follow `RAKOMI_[A-Z0-9_]+`. */
5
+ export const ENV_KEYS = ['RAKOMI_REGION', 'RAKOMI_TENANT_ID', 'RAKOMI_API_KEY'];
6
+ /** The default EU region — a visible, overridable data-residency stance, not a mandate. */
7
+ export const DEFAULT_REGION = 'eu-central';
8
+ /** Keys whose value is a credential and must never be echoed to stdout / logs / summaries. */
9
+ export const SECRET_KEYS = new Set(['RAKOMI_API_KEY']);
10
+ // Matches every ASCII control character (0x00-0x1F and 0x7F), including CR and LF.
11
+ // eslint-disable-next-line no-control-regex -- deliberately strips ALL control chars from values
12
+ const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
13
+ /**
14
+ * Serialise a single `KEY=value` line in canonical dotenv form:
15
+ * - control characters (incl. CR/LF) are stripped from the value first;
16
+ * - no `export ` prefix, no inline comment;
17
+ * - the value is double-quoted only when it contains whitespace, `#` or `"`.
18
+ */
19
+ export function dotenvLine(key, rawValue) {
20
+ const value = rawValue.replace(CONTROL_CHARS, '');
21
+ const needsQuote = /[\s#"]/.test(value);
22
+ if (!needsQuote)
23
+ return `${key}=${value}`;
24
+ const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
25
+ return `${key}="${escaped}"`;
26
+ }
27
+ /**
28
+ * Render a full `.env` body from collected values. Always LF-terminated, one key per line,
29
+ * in `ENV_KEYS` order. A missing value is written as an empty assignment so the file lists
30
+ * every key for the user to complete.
31
+ */
32
+ export function renderDotenv(values) {
33
+ const lines = ENV_KEYS.map((key) => dotenvLine(key, values[key] ?? ''));
34
+ return lines.join('\n') + '\n';
35
+ }
36
+ /** Write the `.env` file into the target project directory. */
37
+ export async function writeEnvFile(targetDir, values) {
38
+ await writeFile(join(targetDir, '.env'), renderDotenv(values), 'utf8');
39
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,31 @@
1
+ // SPDX-License-Identifier: MIT
2
+ /**
3
+ * Fixed POSIX exit-code map for the CLI.
4
+ *
5
+ * - `OK` (0): success.
6
+ * - `FAIL` (1): a runtime failure (fetch / extract / write).
7
+ * - `USAGE` (2): a usage error (unknown slug, bad arg, non-empty target dir).
8
+ *
9
+ * `--help` / `--version` always exit 0. The CLI never uses codes >= 126.
10
+ */
11
+ export const EXIT = { OK: 0, FAIL: 1, USAGE: 2 };
12
+ /**
13
+ * A user-facing CLI error. The `message` is already user-safe (no stack traces, internal
14
+ * paths, or dependency versions) and is printed to stderr; `exitCode` drives the process
15
+ * exit. A dedicated subclass keeps the codebase free of bare `throw new Error()`.
16
+ */
17
+ export class CliError extends Error {
18
+ exitCode;
19
+ constructor(message, exitCode = EXIT.FAIL) {
20
+ super(message);
21
+ this.name = 'CliError';
22
+ this.exitCode = exitCode;
23
+ }
24
+ }
25
+ /** A usage error — bad/unknown argument, unknown slug, or non-empty target (exit 2). */
26
+ export class UsageError extends CliError {
27
+ constructor(message) {
28
+ super(message, EXIT.USAGE);
29
+ this.name = 'UsageError';
30
+ }
31
+ }
package/dist/index.js ADDED
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: MIT
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { appendFile, readFile } from 'node:fs/promises';
5
+ import { isAbsolute, join, relative, resolve } from 'node:path';
6
+ import process from 'node:process';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { parseArgs } from 'node:util';
9
+ import { writeEnvFile } from './env.js';
10
+ import { CliError, EXIT, UsageError } from './errors.js';
11
+ import { collectEnv, createTtyAsk } from './prompt.js';
12
+ import { assertTargetWritable, GithubCodeloadSource, materializeArchive } from './source.js';
13
+ import { findTemplate, slugList } from './templates.js';
14
+ import { detectPackageManager, helpText, postInstallMessage, usageLine } from './usage.js';
15
+ /**
16
+ * Run the CLI with the given argv and dependencies, returning the POSIX exit code. Never throws
17
+ * for a known failure class — usage/runtime errors are turned into a user-safe stderr message
18
+ * and the matching exit code.
19
+ */
20
+ export async function run(args, deps) {
21
+ try {
22
+ return await dispatch(args, deps);
23
+ }
24
+ catch (e) {
25
+ if (e instanceof UsageError) {
26
+ deps.stderr.write(`${e.message}\n${usageLine()}\n`);
27
+ return e.exitCode;
28
+ }
29
+ if (e instanceof CliError) {
30
+ deps.stderr.write(`${e.message}\n`);
31
+ return e.exitCode;
32
+ }
33
+ // Unknown error — never leak a stack trace, path, or version.
34
+ deps.stderr.write('An unexpected error occurred.\n');
35
+ return EXIT.FAIL;
36
+ }
37
+ }
38
+ async function dispatch(args, deps) {
39
+ let values;
40
+ let positionals;
41
+ try {
42
+ const parsed = parseArgs({
43
+ args: [...args],
44
+ allowPositionals: true,
45
+ options: {
46
+ template: { type: 'string' },
47
+ region: { type: 'string' },
48
+ 'tenant-id': { type: 'string' },
49
+ 'template-source': { type: 'string' },
50
+ yes: { type: 'boolean' },
51
+ help: { type: 'boolean', short: 'h' },
52
+ version: { type: 'boolean', short: 'V' },
53
+ },
54
+ });
55
+ values = parsed.values;
56
+ positionals = parsed.positionals;
57
+ }
58
+ catch {
59
+ throw new UsageError('Unknown or malformed argument.');
60
+ }
61
+ // --help / -h / bare invocation → usage to stdout, exit 0.
62
+ if (values.help === true || args.length === 0) {
63
+ deps.stdout.write(helpText());
64
+ return EXIT.OK;
65
+ }
66
+ // --version / -V → version to stdout, exit 0.
67
+ if (values.version === true) {
68
+ deps.stdout.write(`${deps.version}\n`);
69
+ return EXIT.OK;
70
+ }
71
+ const slug = typeof values.template === 'string' ? values.template : undefined;
72
+ if (!slug) {
73
+ throw new UsageError(`Missing --template. Valid templates: ${slugList()}.`);
74
+ }
75
+ // Allow-list membership check — the slug is never sanitised or used in a path/URL before this.
76
+ const template = findTemplate(slug);
77
+ if (!template) {
78
+ throw new UsageError(`Unknown template "${slug}". Valid templates: ${slugList()}.`);
79
+ }
80
+ const rawTarget = positionals[0] ?? template.slug;
81
+ const targetDir = resolveTarget(rawTarget, deps.cwd);
82
+ await assertTargetWritable(targetDir);
83
+ // Resolve env values by precedence: flag > env var > prompt > default.
84
+ const flags = {};
85
+ if (typeof values.region === 'string')
86
+ flags.RAKOMI_REGION = values.region;
87
+ if (typeof values['tenant-id'] === 'string')
88
+ flags.RAKOMI_TENANT_ID = values['tenant-id'];
89
+ const interactive = deps.isTTY && values.yes !== true && !deps.env.CI;
90
+ const envValues = await collectEnv({ flags, env: deps.env, interactive, ask: deps.ask });
91
+ // Fetch + extract the template (safe, atomic) then write the local .env.
92
+ const source = deps.source ?? makeDefaultSource(values, deps.env);
93
+ const archive = await source.fetchArchive(template);
94
+ await materializeArchive(archive, targetDir);
95
+ // Git-ignore `.env` BEFORE writing the secret, so a failure between the two steps never leaves an
96
+ // un-ignored secret on disk. The two calls touch independent files (.gitignore vs .env), so the
97
+ // order is otherwise free.
98
+ await ensureEnvIgnored(targetDir);
99
+ await writeEnvFile(targetDir, envValues);
100
+ const pm = detectPackageManager(deps.env.npm_config_user_agent);
101
+ deps.stdout.write(postInstallMessage(template.slug, rawTarget, pm));
102
+ return EXIT.OK;
103
+ }
104
+ function makeDefaultSource(values, env) {
105
+ const base = (typeof values['template-source'] === 'string' ? values['template-source'] : undefined) ??
106
+ env.CREATE_RAKOMI_TEMPLATE_BASE;
107
+ return new GithubCodeloadSource(base ? { base } : {});
108
+ }
109
+ /** Resolve and constrain the target directory to within the current working directory. */
110
+ function resolveTarget(raw, cwd) {
111
+ const resolved = resolve(cwd, raw);
112
+ const rel = relative(cwd, resolved);
113
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
114
+ throw new UsageError('Target directory must be a new path inside the current directory.');
115
+ }
116
+ return resolved;
117
+ }
118
+ /** Make sure the scaffolded project ignores its `.env` so the user never commits secrets. */
119
+ async function ensureEnvIgnored(targetDir) {
120
+ const gitignore = join(targetDir, '.gitignore');
121
+ let content = '';
122
+ if (existsSync(gitignore))
123
+ content = await readFile(gitignore, 'utf8');
124
+ const alreadyIgnored = content.split(/\r?\n/).some((line) => line.trim() === '.env');
125
+ if (alreadyIgnored)
126
+ return;
127
+ const separator = content && !content.endsWith('\n') ? '\n' : '';
128
+ await appendFile(gitignore, `${separator}.env\n`);
129
+ }
130
+ async function main() {
131
+ const major = Number(process.versions.node.split('.')[0]);
132
+ if (Number.isFinite(major) && major < 22) {
133
+ process.stderr.write('create-rakomi-app needs Node.js 22 or newer.\n');
134
+ process.exitCode = EXIT.FAIL;
135
+ return;
136
+ }
137
+ let version = '0.0.0';
138
+ try {
139
+ version = String(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
140
+ }
141
+ catch {
142
+ // Fall back to a placeholder version rather than crash.
143
+ }
144
+ process.exitCode = await run(process.argv.slice(2), {
145
+ stdout: { write: (text) => void process.stdout.write(text) },
146
+ stderr: { write: (text) => void process.stderr.write(text) },
147
+ // A standalone published CLI reads the real environment at its entry point; there is no
148
+ // app config.ts to route through (that pattern is for the server). The whole env object is
149
+ // handed to the injectable `run`, which is what the test suite exercises.
150
+ // eslint-disable-next-line no-restricted-syntax -- CLI entry must read process.env directly
151
+ env: process.env,
152
+ cwd: process.cwd(),
153
+ version,
154
+ isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
155
+ ask: createTtyAsk(),
156
+ });
157
+ }
158
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
159
+ void main();
160
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,58 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { stdin, stdout } from 'node:process';
3
+ import { createInterface } from 'node:readline/promises';
4
+ import { DEFAULT_REGION, ENV_KEYS, SECRET_KEYS } from './env.js';
5
+ export const FIELDS = [
6
+ { key: 'RAKOMI_REGION', label: 'Data region', defaultValue: DEFAULT_REGION },
7
+ { key: 'RAKOMI_TENANT_ID', label: 'Tenant ID' },
8
+ { key: 'RAKOMI_API_KEY', label: 'API key' },
9
+ ];
10
+ /**
11
+ * Resolve all env values by precedence: explicit flag > `RAKOMI_*` env var > interactive
12
+ * prompt > documented default. Never prompts in non-interactive mode (so a CI pipe never
13
+ * hangs); there it falls back to env/flag/default, leaving the rest empty for the user.
14
+ * Secret values are never echoed back.
15
+ */
16
+ export async function collectEnv(deps) {
17
+ const out = {};
18
+ for (const field of FIELDS) {
19
+ const fromFlag = deps.flags[field.key];
20
+ if (fromFlag !== undefined && fromFlag !== '') {
21
+ out[field.key] = fromFlag;
22
+ continue;
23
+ }
24
+ const fromEnv = deps.env[field.key];
25
+ if (fromEnv !== undefined && fromEnv !== '') {
26
+ out[field.key] = fromEnv;
27
+ continue;
28
+ }
29
+ if (deps.interactive && deps.ask) {
30
+ const answer = (await deps.ask(promptText(field))).trim();
31
+ out[field.key] = answer !== '' ? answer : (field.defaultValue ?? '');
32
+ continue;
33
+ }
34
+ // Non-interactive: use the default if any, otherwise leave the key for the user to fill.
35
+ if (field.defaultValue !== undefined)
36
+ out[field.key] = field.defaultValue;
37
+ }
38
+ return out;
39
+ }
40
+ function promptText(field) {
41
+ const secret = SECRET_KEYS.has(field.key) ? ' (kept local, never sent anywhere)' : '';
42
+ const dflt = field.defaultValue !== undefined ? ` [${field.defaultValue}]` : '';
43
+ return `${field.label}${secret}${dflt}: `;
44
+ }
45
+ /** A real-TTY prompt backed by `node:readline/promises`. */
46
+ export function createTtyAsk() {
47
+ return async (question) => {
48
+ const rl = createInterface({ input: stdin, output: stdout });
49
+ try {
50
+ return await rl.question(question);
51
+ }
52
+ finally {
53
+ rl.close();
54
+ }
55
+ };
56
+ }
57
+ /** All keys, in written order — re-exported for the orchestrator. */
58
+ export { ENV_KEYS };
package/dist/source.js ADDED
@@ -0,0 +1,334 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, mkdtemp, readdir, rename, rm, rmdir, writeFile } from 'node:fs/promises';
4
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
+ import { createGunzip } from 'node:zlib';
6
+ import { CliError, EXIT, UsageError } from './errors.js';
7
+ import { archiveUrl, DEFAULT_TEMPLATE_BASE } from './templates.js';
8
+ // --- Safety caps (constants, not configurable — a hardened scaffolder, not a general extractor) ---
9
+ /** Max compressed bytes downloaded before aborting the stream. */
10
+ export const COMPRESSED_CAP = 50 * 1024 * 1024;
11
+ /** Max total decompressed bytes — aborts mid-inflate to defeat a decompression bomb. */
12
+ export const DECOMPRESSED_CAP = 100 * 1024 * 1024;
13
+ /** Max bytes for any single archive entry. */
14
+ export const MAX_ENTRY_SIZE = 50 * 1024 * 1024;
15
+ /** Max number of materialised entries. */
16
+ export const MAX_ENTRIES = 20000;
17
+ /** Fetch timeout — also the no-hang guarantee. */
18
+ export const FETCH_TIMEOUT_MS = 30_000;
19
+ /** Bounded retry for transient failures (timeout / 5xx / 429 / reset). */
20
+ export const MAX_RETRIES = 2;
21
+ /** Internal fetch error carrying whether the failure class is worth a retry. */
22
+ class FetchError extends CliError {
23
+ retryable;
24
+ constructor(message, retryable) {
25
+ super(message, EXIT.FAIL);
26
+ this.name = 'FetchError';
27
+ this.retryable = retryable;
28
+ }
29
+ }
30
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
31
+ /**
32
+ * The default source: GitHub codeload tarball over `fetch`, no `git` subprocess. HTTPS-only,
33
+ * host-pinned to the base's host, refuses redirects, bounded timeout + retry, and sniffs the
34
+ * body before it ever reaches the extractor.
35
+ */
36
+ export class GithubCodeloadSource {
37
+ base;
38
+ fetchImpl;
39
+ timeoutMs;
40
+ maxRetries;
41
+ backoffMs;
42
+ constructor(opts = {}) {
43
+ this.base = opts.base ?? DEFAULT_TEMPLATE_BASE;
44
+ this.fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, init));
45
+ this.timeoutMs = opts.timeoutMs ?? FETCH_TIMEOUT_MS;
46
+ this.maxRetries = opts.maxRetries ?? MAX_RETRIES;
47
+ this.backoffMs = opts.backoffMs ?? 500;
48
+ }
49
+ async fetchArchive(template) {
50
+ const url = archiveUrl(template, this.base);
51
+ let pinnedHost;
52
+ try {
53
+ const baseUrl = new URL(this.base);
54
+ if (baseUrl.protocol !== 'https:') {
55
+ throw new UsageError('Template source must be an https:// URL.');
56
+ }
57
+ pinnedHost = baseUrl.host;
58
+ }
59
+ catch (e) {
60
+ if (e instanceof UsageError)
61
+ throw e;
62
+ throw new UsageError('Template source is not a valid URL.');
63
+ }
64
+ let lastError = new FetchError('Could not download the template.', false);
65
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
66
+ try {
67
+ return await this.attempt(url, pinnedHost, template);
68
+ }
69
+ catch (e) {
70
+ if (e instanceof FetchError && e.retryable && attempt < this.maxRetries) {
71
+ lastError = e;
72
+ await delay(this.backoffMs * (attempt + 1));
73
+ continue;
74
+ }
75
+ if (e instanceof CliError)
76
+ throw e;
77
+ // An unexpected network error (reset, DNS) — retryable, user-safe message.
78
+ lastError = new FetchError('Network error while downloading the template.', true);
79
+ if (attempt < this.maxRetries) {
80
+ await delay(this.backoffMs * (attempt + 1));
81
+ continue;
82
+ }
83
+ }
84
+ }
85
+ throw lastError;
86
+ }
87
+ async attempt(url, pinnedHost, template) {
88
+ const controller = new AbortController();
89
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
90
+ let response;
91
+ try {
92
+ response = await this.fetchImpl(url, { redirect: 'error', signal: controller.signal });
93
+ }
94
+ catch {
95
+ // Aborted (timeout) or a transport error — both retryable.
96
+ throw new FetchError('Timed out or failed while downloading the template.', true);
97
+ }
98
+ finally {
99
+ clearTimeout(timer);
100
+ }
101
+ if (response.status >= 300 && response.status < 400) {
102
+ throw new FetchError('The template source attempted an unexpected redirect; refusing.', false);
103
+ }
104
+ if (response.status === 404) {
105
+ throw new FetchError(`Template "${template.slug}" is not available yet.`, false);
106
+ }
107
+ if (response.status === 429) {
108
+ throw new FetchError('The template source is rate-limiting requests; please retry shortly.', true);
109
+ }
110
+ if (response.status >= 500) {
111
+ throw new FetchError('The template source is temporarily unavailable.', true);
112
+ }
113
+ if (!response.ok) {
114
+ throw new FetchError('Could not download the template.', false);
115
+ }
116
+ const finalHost = new URL(response.url || url).host;
117
+ if (finalHost !== pinnedHost) {
118
+ throw new FetchError('The template download resolved to an unexpected host; refusing.', false);
119
+ }
120
+ const contentType = response.headers.get('content-type') ?? '';
121
+ if (/html|text\/plain/i.test(contentType)) {
122
+ throw new FetchError('The template source returned an unexpected response; refusing.', false);
123
+ }
124
+ const body = await readBodyCapped(response, COMPRESSED_CAP);
125
+ if (!isGzip(body)) {
126
+ throw new FetchError('The template source returned a non-archive response; refusing.', false);
127
+ }
128
+ return body;
129
+ }
130
+ }
131
+ /** A fixture source for tests — returns pre-built gzip'd tar bytes with no network. */
132
+ export class LocalFixtureSource {
133
+ bySlug;
134
+ constructor(bySlug) {
135
+ this.bySlug = bySlug;
136
+ }
137
+ async fetchArchive(template) {
138
+ const bytes = this.bySlug[template.slug];
139
+ if (!bytes)
140
+ throw new CliError(`No fixture for template "${template.slug}".`, EXIT.FAIL);
141
+ return bytes;
142
+ }
143
+ }
144
+ /** True if the buffer begins with the gzip magic bytes. */
145
+ export function isGzip(buf) {
146
+ return buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b;
147
+ }
148
+ async function readBodyCapped(response, cap) {
149
+ const reader = response.body?.getReader();
150
+ if (!reader) {
151
+ const ab = await response.arrayBuffer();
152
+ if (ab.byteLength > cap)
153
+ throw new FetchError('The template download is too large; refusing.', false);
154
+ return Buffer.from(ab);
155
+ }
156
+ const chunks = [];
157
+ let total = 0;
158
+ for (;;) {
159
+ const { done, value } = await reader.read();
160
+ if (done)
161
+ break;
162
+ total += value.byteLength;
163
+ if (total > cap) {
164
+ await reader.cancel();
165
+ throw new FetchError('The template download is too large; refusing.', false);
166
+ }
167
+ chunks.push(Buffer.from(value));
168
+ }
169
+ return Buffer.concat(chunks);
170
+ }
171
+ /** Gunzip with a hard cap on decompressed bytes, aborting mid-inflate on breach. */
172
+ export function gunzipCapped(input, maxBytes) {
173
+ return new Promise((resolvePromise, reject) => {
174
+ const gunzip = createGunzip();
175
+ const chunks = [];
176
+ let total = 0;
177
+ gunzip.on('data', (chunk) => {
178
+ total += chunk.length;
179
+ if (total > maxBytes) {
180
+ gunzip.destroy();
181
+ reject(new CliError('The template archive is too large to extract safely.', EXIT.FAIL));
182
+ return;
183
+ }
184
+ chunks.push(chunk);
185
+ });
186
+ gunzip.on('end', () => resolvePromise(Buffer.concat(chunks)));
187
+ gunzip.on('error', () => reject(new CliError('The template archive is corrupt or truncated.', EXIT.FAIL)));
188
+ gunzip.end(input);
189
+ });
190
+ }
191
+ function readCString(block, start, len) {
192
+ const slice = block.subarray(start, start + len);
193
+ const nul = slice.indexOf(0);
194
+ return slice.toString('utf8', 0, nul === -1 ? len : nul);
195
+ }
196
+ function parseOctal(block, start, len) {
197
+ const raw = readCString(block, start, len).trim();
198
+ if (raw === '')
199
+ return 0;
200
+ if (!/^[0-7]+$/.test(raw)) {
201
+ throw new CliError('The template archive has a malformed header.', EXIT.FAIL);
202
+ }
203
+ return parseInt(raw, 8);
204
+ }
205
+ /** Parse a (decompressed) tar buffer, rejecting link and special-file entries outright. */
206
+ export function parseTar(buf) {
207
+ const entries = [];
208
+ let offset = 0;
209
+ let count = 0;
210
+ while (offset + 512 <= buf.length) {
211
+ const block = buf.subarray(offset, offset + 512);
212
+ if (block.every((b) => b === 0))
213
+ break; // end-of-archive marker
214
+ const name = readCString(block, 0, 100);
215
+ const size = parseOctal(block, 124, 12);
216
+ const typeflag = String.fromCharCode(block[156] ?? 0);
217
+ const prefix = readCString(block, 345, 155);
218
+ offset += 512;
219
+ const dataBlocks = Math.ceil(size / 512);
220
+ // GNU/pax metadata entries — skip their payload, materialise nothing.
221
+ if (typeflag === 'x' || typeflag === 'g' || typeflag === 'L' || typeflag === 'K') {
222
+ offset += dataBlocks * 512;
223
+ continue;
224
+ }
225
+ // Reject symlink / hardlink (CWE-59) — independent of the traversal check below.
226
+ if (typeflag === '1' || typeflag === '2') {
227
+ throw new CliError('The template archive contains a link entry; refusing for safety.', EXIT.FAIL);
228
+ }
229
+ // Reject character/block device and FIFO entries.
230
+ if (typeflag === '3' || typeflag === '4' || typeflag === '6') {
231
+ throw new CliError('The template archive contains a special-file entry; refusing for safety.', EXIT.FAIL);
232
+ }
233
+ if (size > MAX_ENTRY_SIZE) {
234
+ throw new CliError('The template archive contains an oversized entry; refusing.', EXIT.FAIL);
235
+ }
236
+ if (++count > MAX_ENTRIES) {
237
+ throw new CliError('The template archive contains too many entries; refusing.', EXIT.FAIL);
238
+ }
239
+ const fullName = prefix ? `${prefix}/${name}` : name;
240
+ if (typeflag === '5') {
241
+ entries.push({ type: 'dir', path: fullName });
242
+ }
243
+ else {
244
+ // '0' or NUL typeflag → regular file.
245
+ entries.push({ type: 'file', path: fullName, data: Buffer.from(buf.subarray(offset, offset + size)) });
246
+ }
247
+ offset += dataBlocks * 512;
248
+ }
249
+ return entries;
250
+ }
251
+ /** Normalise an archive path: forward slashes, leading slashes stripped, `..` preserved. */
252
+ function normalizeEntryPath(p) {
253
+ return p.replace(/\\/g, '/').replace(/^\/+/, '');
254
+ }
255
+ /**
256
+ * Materialise a gzip'd tar archive into `targetDir`: gunzip (capped), parse (link/special
257
+ * rejected), strip the leading codeload wrapper dir, reject any entry that escapes the target,
258
+ * extract into a temp sibling, then atomically rename into place. Any failure cleans up the
259
+ * partial temp output (one `rm`, not a walk).
260
+ */
261
+ export async function materializeArchive(gzipBytes, targetDir) {
262
+ if (!isGzip(gzipBytes)) {
263
+ throw new CliError('The template archive is not a gzip archive; refusing.', EXIT.FAIL);
264
+ }
265
+ const tar = await gunzipCapped(gzipBytes, DECOMPRESSED_CAP);
266
+ const entries = parseTar(tar);
267
+ if (entries.length === 0) {
268
+ throw new CliError('The template archive is empty; refusing.', EXIT.FAIL);
269
+ }
270
+ // The codeload tarball wraps everything in a single leading `<repo>-<sha>/` dir.
271
+ const wrapper = normalizeEntryPath(entries[0].path).split('/')[0] ?? '';
272
+ const target = resolve(targetDir);
273
+ const parent = dirname(target);
274
+ await mkdir(parent, { recursive: true });
275
+ const tmpDir = await mkdtemp(join(parent, '.create-rakomi-app-'));
276
+ try {
277
+ for (const entry of entries) {
278
+ const norm = normalizeEntryPath(entry.path);
279
+ // Every entry must live under the detected wrapper dir; anything else is an escape attempt
280
+ // (absolute path, sibling-of-wrapper, escape-after-strip-1).
281
+ if (norm !== wrapper && !norm.startsWith(`${wrapper}/`)) {
282
+ throw new CliError('A template archive entry escapes the template root; refusing.', EXIT.FAIL);
283
+ }
284
+ const stripped = norm === wrapper ? '' : norm.slice(wrapper.length + 1);
285
+ if (stripped === '')
286
+ continue; // the wrapper dir itself
287
+ const dest = resolve(tmpDir, stripped);
288
+ const rel = relative(tmpDir, dest);
289
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
290
+ throw new CliError('A template archive entry escapes the template root; refusing.', EXIT.FAIL);
291
+ }
292
+ if (entry.type === 'dir') {
293
+ await mkdir(dest, { recursive: true });
294
+ }
295
+ else {
296
+ await mkdir(dirname(dest), { recursive: true });
297
+ await writeFile(dest, entry.data ?? Buffer.alloc(0));
298
+ }
299
+ }
300
+ await moveIntoPlace(tmpDir, target);
301
+ }
302
+ catch (e) {
303
+ await rm(tmpDir, { recursive: true, force: true });
304
+ throw e;
305
+ }
306
+ }
307
+ async function moveIntoPlace(tmpDir, target) {
308
+ // The clobber check guarantees `target` is empty or absent before we get here.
309
+ if (existsSync(target)) {
310
+ await rmdir(target);
311
+ }
312
+ await rename(tmpDir, target);
313
+ }
314
+ /**
315
+ * Refuse to scaffold into a non-empty target (no clobber, no partial write). The chosen policy
316
+ * is strict-refuse: any existing entry makes the directory ineligible. This keeps the atomic
317
+ * extract-to-temp-then-rename safe (the target is always empty or absent at materialise time)
318
+ * and is the most fail-closed reading of the safety requirement. Throws a `UsageError` (exit 2).
319
+ */
320
+ export async function assertTargetWritable(targetDir) {
321
+ const target = resolve(targetDir);
322
+ if (!existsSync(target))
323
+ return;
324
+ let entries;
325
+ try {
326
+ entries = await readdir(target);
327
+ }
328
+ catch {
329
+ throw new UsageError(`Cannot read the target directory "${targetDir}".`);
330
+ }
331
+ if (entries.length > 0) {
332
+ throw new UsageError(`Target directory "${targetDir}" is not empty; refusing to overwrite.`);
333
+ }
334
+ }
@@ -0,0 +1,12 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // GENERATED FILE — do not edit by hand. Regenerate with `node scripts/gen-templates.mjs`
4
+ // (runs automatically on build/typecheck). The data mirrors the shared quickstart manifest;
5
+ // the parity test asserts they stay in sync.
6
+ /** The full set of templates, in manifest order. */
7
+ export const TEMPLATES = [
8
+ { slug: "nextjs", label: "Next.js quickstart", publicRepo: "rakomidev/rakomi-nextjs-quickstart" },
9
+ { slug: "react", label: "React quickstart", publicRepo: "rakomidev/rakomi-react-quickstart" },
10
+ { slug: "node", label: "Node quickstart", publicRepo: "rakomidev/rakomi-node-quickstart" },
11
+ { slug: "expo", label: "Expo quickstart", publicRepo: "rakomidev/rakomi-expo-quickstart" },
12
+ ];
@@ -0,0 +1,44 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { TEMPLATES } from './templates.generated.js';
3
+ /** The public portal host where each quickstart's walkthrough lives (post-install URL). */
4
+ export const PORTAL_HOST = 'examples.rakomi.com';
5
+ /**
6
+ * The default base for fetching template archives. Overridable at runtime via
7
+ * `--template-source` / `CREATE_RAKOMI_TEMPLATE_BASE` (mirror, offline, or emergency
8
+ * repoint). The host of this base is the only host the fetch is allowed to reach.
9
+ */
10
+ export const DEFAULT_TEMPLATE_BASE = 'https://codeload.github.com';
11
+ /**
12
+ * Return every scaffoldable template, in manifest order. The data is baked in at build
13
+ * time (see `templates.generated.ts`) — there is no runtime read of the manifest, so the
14
+ * published bin works for an npm consumer who never has the repo's `examples/` tree.
15
+ */
16
+ export function loadTemplates() {
17
+ return TEMPLATES;
18
+ }
19
+ /**
20
+ * Resolve a slug to its template by exact membership check. Returns `undefined` for an
21
+ * unknown slug — the slug is never sanitised or coerced, only matched against the
22
+ * allow-list, and is never concatenated into a URL or path before this check passes.
23
+ */
24
+ export function findTemplate(slug) {
25
+ return TEMPLATES.find((t) => t.slug === slug);
26
+ }
27
+ /** The comma-separated list of valid slugs, for usage and error messages. */
28
+ export function slugList() {
29
+ return loadTemplates()
30
+ .map((t) => t.slug)
31
+ .join(', ');
32
+ }
33
+ /** The post-install walkthrough URL for a template. */
34
+ export function portalUrl(slug) {
35
+ return `https://${PORTAL_HOST}/quickstart/${slug}`;
36
+ }
37
+ /**
38
+ * The codeload archive URL for a template, derived from its `publicRepo` and the given base.
39
+ * Pinned to the `main` branch ref (a future enhancement could pin a tagged ref per template).
40
+ */
41
+ export function archiveUrl(template, base = DEFAULT_TEMPLATE_BASE) {
42
+ const trimmed = base.replace(/\/+$/, '');
43
+ return `${trimmed}/${template.publicRepo}/tar.gz/refs/heads/main`;
44
+ }
package/dist/usage.js ADDED
@@ -0,0 +1,76 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { loadTemplates, PORTAL_HOST, portalUrl } from './templates.js';
3
+ /**
4
+ * Detect the package manager that invoked the CLI from `npm_config_user_agent`
5
+ * (e.g. "pnpm/9.0.0 npm/? node/v22 …"). Defaults to npm when unknown.
6
+ */
7
+ export function detectPackageManager(userAgent) {
8
+ const head = (userAgent ?? '').split('/')[0]?.toLowerCase();
9
+ if (head === 'pnpm' || head === 'yarn' || head === 'bun')
10
+ return head;
11
+ return 'npm';
12
+ }
13
+ /** The install command for a package manager. */
14
+ export function installCommand(pm) {
15
+ return pm === 'yarn' ? 'yarn' : `${pm} install`;
16
+ }
17
+ /** The dev/run command for a package manager. */
18
+ export function runCommand(pm) {
19
+ return pm === 'yarn' ? 'yarn dev' : `${pm} run dev`;
20
+ }
21
+ /** The full usage/help block, listing templates and prompts from the manifest. */
22
+ export function helpText() {
23
+ const templates = loadTemplates();
24
+ const list = templates.map((t) => ` ${t.slug.padEnd(8)} ${t.label}`).join('\n');
25
+ return [
26
+ 'create-rakomi-app — scaffold a Rakomi quickstart app',
27
+ '',
28
+ 'Usage:',
29
+ ' npx create-rakomi-app@latest --template <slug> [directory]',
30
+ ' npm create @rakomi/rakomi-app@latest -- --template <slug> [directory]',
31
+ '',
32
+ 'Templates:',
33
+ list,
34
+ '',
35
+ 'Options:',
36
+ ' --template <slug> which quickstart to scaffold (required)',
37
+ ' --region <value> data region (default: eu-central)',
38
+ ' --tenant-id <value> your tenant id',
39
+ ' --template-source <url> override the archive base (mirror / offline)',
40
+ ' --yes accept defaults, never prompt (non-interactive)',
41
+ ' -h, --help show this help and exit',
42
+ ' -V, --version print the version and exit',
43
+ '',
44
+ 'Environment variables collected into the new project\'s .env:',
45
+ ' RAKOMI_REGION, RAKOMI_TENANT_ID, RAKOMI_API_KEY',
46
+ ' (RAKOMI_API_KEY is read from the prompt or the environment, never a flag,',
47
+ ' and is written only to your local .env — never transmitted.)',
48
+ '',
49
+ `After scaffolding, the next-step walkthrough lives at ${PORTAL_HOST}/quickstart/<slug>.`,
50
+ '',
51
+ ].join('\n');
52
+ }
53
+ /** Short usage line printed to stderr on a usage error. */
54
+ export function usageLine() {
55
+ return 'Usage: npx create-rakomi-app@latest --template <slug> [directory] (see --help)';
56
+ }
57
+ /**
58
+ * The post-install "Next steps" block: the portal walkthrough URL plus a numbered, package-
59
+ * manager-aware command list. The install step is framed as the user's own next step that
60
+ * depends on the public npm registry, not a guarantee. No color is emitted (NO_COLOR-safe by
61
+ * construction) and the copy is stack-neutral so a tutorial can quote it verbatim.
62
+ */
63
+ export function postInstallMessage(slug, directory, pm) {
64
+ return [
65
+ '',
66
+ `Done. Your Rakomi ${slug} app is ready in ${directory}`,
67
+ '',
68
+ 'Next steps:',
69
+ ` 1. cd ${directory}`,
70
+ ` 2. ${installCommand(pm)} (installs @rakomi/node from the public npm registry)`,
71
+ ` 3. ${runCommand(pm)}`,
72
+ '',
73
+ `Walkthrough: ${portalUrl(slug)}`,
74
+ '',
75
+ ].join('\n');
76
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "create-rakomi-app",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold a Rakomi quickstart app. EU-native auth-as-a-service. npx create-rakomi-app --template <slug>",
5
+ "keywords": [
6
+ "rakomi",
7
+ "create",
8
+ "scaffold",
9
+ "cli",
10
+ "starter",
11
+ "quickstart",
12
+ "authentication",
13
+ "auth"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/rakomidev/rakomi-js.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/rakomidev/rakomi-js/issues"
21
+ },
22
+ "homepage": "https://github.com/rakomidev/rakomi-js#readme",
23
+ "license": "MIT",
24
+ "publishConfig": {
25
+ "provenance": true
26
+ },
27
+ "type": "module",
28
+ "bin": {
29
+ "create-rakomi-app": "./dist/index.js"
30
+ },
31
+ "engines": {
32
+ "node": ">=22"
33
+ },
34
+ "author": "CRE8EVE Sp. z o.o.",
35
+ "files": [
36
+ "dist",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "devDependencies": {
41
+ "@types/node": "22.19.13",
42
+ "typescript": "5.9.3",
43
+ "vitest": "4.0.18"
44
+ },
45
+ "scripts": {
46
+ "build": "node scripts/gen-templates.mjs && tsc",
47
+ "typecheck": "node scripts/gen-templates.mjs && tsc --noEmit",
48
+ "lint": "eslint src/ test/ --max-warnings=0",
49
+ "test": "vitest run"
50
+ }
51
+ }