supabase-saas-kit 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 Mateus Zingano
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,64 @@
1
+ # saas-kit — the companion CLI for the SaaS boilerplate
2
+
3
+ Four commands that make the boilerplate a one-liner to start, verify, grow, and
4
+ ship — so the safe path (RLS on, env wired right, checks green) is the default.
5
+
6
+ ```bash
7
+ npm install -g @mateuszingano/saas-kit # or prefix each command with: npx @mateuszingano/saas-kit
8
+
9
+ saas-kit new my-app # scaffold (free starter)
10
+ saas-kit doctor # env + Supabase + RLS
11
+ saas-kit gen:migration add_projects # RLS-safe migration
12
+ saas-kit check # pre-deploy gate
13
+ ```
14
+
15
+ ## Commands
16
+
17
+ ### `new <name> (--repo <url> | --from <dir>)`
18
+ Scaffolds a fresh project: clones/copies the template, strips `.git`, renames
19
+ `package.json`, and seeds `.env` from `.env.example` — the first thing you do is
20
+ run, not hunt for what to fill.
21
+
22
+ - **(default)** — clones the free public
23
+ [nextjs-supabase-starter](https://github.com/mateuszingano/nextjs-supabase-starter).
24
+ - `--repo <git-url>` — clones another repo, **public or private** (uses your git
25
+ credentials, so a buyer who owns the paid boilerplate scaffolds from it).
26
+ - `--from <dir>` — copies a local template (offline).
27
+ - or set `SAAS_KIT_TEMPLATE`.
28
+
29
+ The default is the **free** starter, never a paid repo — a free CLI shouldn't
30
+ hand out a paid boilerplate. Point `--repo` at the paid one when you own it.
31
+
32
+ ### `doctor [--env <path>]`
33
+ Answers "is this wired up right?" without deploying to find out:
34
+ - checks the env vars the app actually reads;
35
+ - flags a service-role key exposed to the browser as an **error**, not a warning;
36
+ - pings Supabase over REST to confirm it's reachable;
37
+ - points you at [Airlock](../16-CI-SEGURANCA-SUPABASE/airlock) for a real RLS audit.
38
+
39
+ Exits non-zero when required vars are missing — safe to drop in CI.
40
+
41
+ ### `gen:migration <name>`
42
+ Creates `supabase/migrations/<timestamp>_<slug>.sql` pre-filled with the
43
+ boilerplate's RLS-first pattern: RLS enabled, API roles granted, and all four
44
+ verbs scoped to the user's workspaces. Shipping a table with RLS off is the #1
45
+ Supabase footgun — this makes it the hard thing to do by accident.
46
+
47
+ ### `check [--skip-e2e]`
48
+ The pre-deploy gate. Runs the quality steps the project ships — `typecheck`,
49
+ `lint`, `test`, `e2e` — in order, stopping at the first failure, then runs an
50
+ RLS audit via Airlock when `SUPABASE_DB_URL` is set. One command answers "is
51
+ this safe to ship?" instead of remembering four. Non-zero on any failure.
52
+
53
+ ## Why it's free
54
+ This CLI is the front door to the boilerplate. It's MIT and standalone; the paid
55
+ products are the boilerplate it scaffolds, the Pro tier, and Airlock for
56
+ continuous RLS monitoring. Use it on anything.
57
+
58
+ ## Develop
59
+ ```bash
60
+ npm test # 29 tests, offline
61
+ node bin/cli.mjs --help
62
+ ```
63
+
64
+ MIT © Mateus Zingano
package/bin/cli.mjs ADDED
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ // saas-kit — companion CLI for the SaaS boilerplate.
3
+ // saas-kit new <name> (--repo <url> | --from <dir>) scaffold a new project
4
+ // saas-kit doctor [--env <path>] check env + Supabase + RLS
5
+ // saas-kit gen:migration <name> RLS-safe migration
6
+ // saas-kit check [--skip-e2e] pre-deploy gate
7
+
8
+ import { resolve, join } from 'node:path';
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { parseArgs } from '../src/args.mjs';
11
+ import { genMigration } from '../src/gen-migration.mjs';
12
+ import { scaffold } from '../src/new.mjs';
13
+ import { loadEnv, checkEnv } from '../src/doctor.mjs';
14
+ import { runCheck } from '../src/check.mjs';
15
+
16
+ const HELP = `saas-kit — companion CLI for the SaaS boilerplate
17
+
18
+ Usage:
19
+ saas-kit new <name> [--repo <url> | --from <dir>] Scaffold a new project
20
+ saas-kit doctor [--env <path>] Check env, Supabase, RLS
21
+ saas-kit gen:migration <name> Create an RLS-safe migration
22
+ saas-kit check [--skip-e2e] Run the pre-deploy gate
23
+ saas-kit --help Show this help
24
+
25
+ Template source for \`new\` (defaults to the free public starter):
26
+ (default) the free nextjs-supabase-starter
27
+ --repo <git-url> clone another repo (public or private — uses your git auth)
28
+ --from <dir> copy a local template
29
+ or set SAAS_KIT_TEMPLATE
30
+
31
+ Examples:
32
+ saas-kit new my-app # from the free starter
33
+ saas-kit new my-app --repo <paid-repo> # from the paid boilerplate you own
34
+ saas-kit gen:migration add_projects
35
+ SUPABASE_DB_URL=postgres://... saas-kit check
36
+ `;
37
+
38
+ // Best-effort online check: can the anon key reach REST?
39
+ async function probeSupabase(env) {
40
+ const url = env.NEXT_PUBLIC_SUPABASE_URL;
41
+ const key = env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
42
+ if (!url || !key) return { skipped: true };
43
+ try {
44
+ const res = await fetch(`${url.replace(/\/$/, '')}/rest/v1/`, {
45
+ headers: { apikey: key, Authorization: `Bearer ${key}` },
46
+ });
47
+ return { reachable: res.ok || res.status === 404, status: res.status };
48
+ } catch (err) {
49
+ return { reachable: false, error: err.message };
50
+ }
51
+ }
52
+
53
+ async function cmdDoctor(flags) {
54
+ const envPath = typeof flags.env === 'string' ? flags.env : '.env';
55
+ const env = loadEnv(envPath, process.env);
56
+ const { results, ok } = checkEnv(env);
57
+
58
+ console.log(`\nsaas-kit doctor — ${existsSync(envPath) ? envPath : 'process env'}\n`);
59
+ for (const r of results) {
60
+ const mark = r.status === 'ok' ? '✔' : r.status === 'warn' ? '!' : '✖';
61
+ console.log(` ${mark} ${r.key}${r.present ? '' : ' (missing)'}`);
62
+ if (r.status !== 'ok') console.log(` ${r.hint}`);
63
+ }
64
+
65
+ const probe = await probeSupabase(env);
66
+ if (probe.skipped) {
67
+ console.log('\n · Supabase probe skipped (fill the env first).');
68
+ } else if (probe.reachable) {
69
+ console.log(`\n ✔ Supabase reachable (HTTP ${probe.status}).`);
70
+ console.log(' · For a real RLS audit, run: npx airlock-rls "$SUPABASE_DB_URL"');
71
+ } else {
72
+ console.log(`\n ✖ Could not reach Supabase (${probe.error || 'HTTP ' + probe.status}).`);
73
+ }
74
+
75
+ console.log('');
76
+ process.exit(ok && (probe.skipped || probe.reachable) ? 0 : 1);
77
+ }
78
+
79
+ function cmdNew(positional, flags) {
80
+ const name = positional[0];
81
+ if (!name) throw new Error('usage: saas-kit new <name> (--repo <url> | --from <dir>)');
82
+ const dest = resolve(process.cwd(), name);
83
+ scaffold({ name, dest, flags, env: process.env });
84
+ console.log(`\n✔ Scaffolded ${name} at ${dest}`);
85
+ console.log('\nNext:');
86
+ console.log(` cd ${name}`);
87
+ console.log(' npm install');
88
+ console.log(' # fill .env, then: supabase start && npm run dev');
89
+ console.log(' saas-kit doctor\n');
90
+ }
91
+
92
+ function cmdGenMigration(positional) {
93
+ const name = positional.join(' ').trim();
94
+ const path = genMigration({ name });
95
+ console.log(`✔ Created ${path}`);
96
+ console.log(' Edit the columns; keep the RLS block. Then: supabase db push');
97
+ }
98
+
99
+ function cmdCheck(flags) {
100
+ const pkgPath = resolve(process.cwd(), 'package.json');
101
+ if (!existsSync(pkgPath)) throw new Error('no package.json here — run inside a project');
102
+ const scripts = JSON.parse(readFileSync(pkgPath, 'utf8')).scripts || {};
103
+ const dbUrl = process.env.SUPABASE_DB_URL || '';
104
+ console.log('\nsaas-kit check — pre-deploy gate\n');
105
+ const { ok } = runCheck(scripts, { dbUrl, skipE2e: !!flags['skip-e2e'] });
106
+ console.log('');
107
+ process.exit(ok ? 0 : 1);
108
+ }
109
+
110
+ async function main() {
111
+ const [command, ...rest] = process.argv.slice(2);
112
+ const { flags, positional } = parseArgs(rest);
113
+
114
+ if (!command || command === '--help' || command === '-h' || flags.help) {
115
+ console.log(HELP);
116
+ return;
117
+ }
118
+
119
+ switch (command) {
120
+ case 'new':
121
+ return cmdNew(positional, flags);
122
+ case 'doctor':
123
+ return cmdDoctor(flags);
124
+ case 'gen:migration':
125
+ case 'migration':
126
+ return cmdGenMigration(positional);
127
+ case 'check':
128
+ return cmdCheck(flags);
129
+ default:
130
+ console.error(`Unknown command: ${command}\n`);
131
+ console.log(HELP);
132
+ process.exit(1);
133
+ }
134
+ }
135
+
136
+ main().catch((err) => {
137
+ console.error(`✖ ${err.message}`);
138
+ process.exit(1);
139
+ });
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "supabase-saas-kit",
3
+ "version": "0.1.0",
4
+ "description": "The companion CLI for the SaaS boilerplate: scaffold a new project, check your Supabase setup (RLS included), and generate RLS-safe migrations.",
5
+ "type": "module",
6
+ "bin": {
7
+ "saas-kit": "bin/cli.mjs",
8
+ "supabase-saas-kit": "bin/cli.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "start": "node bin/cli.mjs",
18
+ "test": "node --test",
19
+ "prepublishOnly": "node --test"
20
+ },
21
+ "keywords": [
22
+ "supabase",
23
+ "nextjs",
24
+ "saas",
25
+ "boilerplate",
26
+ "scaffold",
27
+ "rls",
28
+ "cli",
29
+ "migrations"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "author": "Mateus Zingano",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/mateuszingano/saas-kit.git"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/mateuszingano/saas-kit/issues"
41
+ },
42
+ "homepage": "https://github.com/mateuszingano/saas-kit#readme",
43
+ "license": "MIT"
44
+ }
package/src/args.mjs ADDED
@@ -0,0 +1,23 @@
1
+ // Tiny arg parser shared by the CLI and its tests. Pulls `--key value` and
2
+ // `--flag` (boolean) out of argv, leaving positionals in order.
3
+
4
+ export function parseArgs(argv) {
5
+ const flags = {};
6
+ const positional = [];
7
+ for (let i = 0; i < argv.length; i++) {
8
+ const arg = argv[i];
9
+ if (arg.startsWith('--')) {
10
+ const key = arg.slice(2);
11
+ const next = argv[i + 1];
12
+ if (next !== undefined && !next.startsWith('--')) {
13
+ flags[key] = next;
14
+ i++;
15
+ } else {
16
+ flags[key] = true;
17
+ }
18
+ } else {
19
+ positional.push(arg);
20
+ }
21
+ }
22
+ return { flags, positional };
23
+ }
package/src/check.mjs ADDED
@@ -0,0 +1,69 @@
1
+ // check — the pre-deploy gate. Runs the quality steps the boilerplate ships
2
+ // (typecheck, lint, test, e2e) plus a real RLS audit via Airlock, and stops at
3
+ // the first failure. One command answers "is this safe to ship?" instead of
4
+ // remembering four.
5
+
6
+ import { execSync } from 'node:child_process';
7
+
8
+ // Canonical order: cheapest/fastest signal first, so failures surface early.
9
+ const STEP_ORDER = ['typecheck', 'lint', 'test', 'e2e'];
10
+
11
+ /**
12
+ * Pure: given a package.json `scripts` object and options, produce the ordered
13
+ * list of steps to run. Only includes scripts that actually exist; appends an
14
+ * RLS audit when a DB url is available (skips it otherwise, and says so).
15
+ * Returns [{ name, cmd }].
16
+ */
17
+ export function buildCheckSteps(scripts = {}, { dbUrl = '', skipE2e = false } = {}) {
18
+ const steps = STEP_ORDER.filter((s) => {
19
+ if (s === 'e2e' && skipE2e) return false;
20
+ return typeof scripts[s] === 'string';
21
+ }).map((s) => ({ name: s, cmd: `npm run ${s}` }));
22
+
23
+ if (dbUrl) {
24
+ steps.push({ name: 'rls-audit', cmd: `npx airlock-rls "${dbUrl}"` });
25
+ }
26
+ return steps;
27
+ }
28
+
29
+ /** Human summary of what will run and what's skipped, for the header. */
30
+ export function planSummary(scripts = {}, opts = {}) {
31
+ const steps = buildCheckSteps(scripts, opts);
32
+ const names = steps.map((s) => s.name);
33
+ const missing = STEP_ORDER.filter((s) => typeof scripts[s] !== 'string');
34
+ const notes = [];
35
+ if (!opts.dbUrl) notes.push('rls-audit skipped (set SUPABASE_DB_URL to enable)');
36
+ if (missing.length) notes.push(`no script for: ${missing.join(', ')}`);
37
+ return { steps, names, notes };
38
+ }
39
+
40
+ /**
41
+ * Run the steps in order, stopping at the first non-zero exit.
42
+ * `run` is injectable for tests. Returns { ok, ran, failed }.
43
+ */
44
+ export function runCheck(scripts, opts = {}, run = defaultRun) {
45
+ const { steps, notes } = planSummary(scripts, opts);
46
+ for (const note of notes) console.log(` · ${note}`);
47
+ if (!steps.length) {
48
+ console.log('\n ✖ nothing to run — is this a boilerplate project?');
49
+ return { ok: false, ran: [], failed: null };
50
+ }
51
+
52
+ const ran = [];
53
+ for (const step of steps) {
54
+ console.log(`\n▶ ${step.name}: ${step.cmd}`);
55
+ try {
56
+ run(step.cmd);
57
+ ran.push(step.name);
58
+ } catch {
59
+ console.log(`\n ✖ ${step.name} failed — stopping.`);
60
+ return { ok: false, ran, failed: step.name };
61
+ }
62
+ }
63
+ console.log(`\n ✔ all checks passed (${ran.join(', ')}).`);
64
+ return { ok: true, ran, failed: null };
65
+ }
66
+
67
+ function defaultRun(cmd) {
68
+ execSync(cmd, { stdio: 'inherit' });
69
+ }
package/src/doctor.mjs ADDED
@@ -0,0 +1,82 @@
1
+ // doctor — is this project wired up right? Checks the env vars the boilerplate
2
+ // needs, then (best effort, online) pings Supabase and confirms the anon role
3
+ // is NOT reading a table it shouldn't. The RLS check is what makes this more
4
+ // than a spellchecker: it catches the exposed-table footgun before deploy.
5
+
6
+ import { readFileSync, existsSync } from 'node:fs';
7
+
8
+ // The vars the app actually reads (grounded in the boilerplate .env.example).
9
+ export const ENV_SPEC = [
10
+ {
11
+ key: 'NEXT_PUBLIC_SUPABASE_URL',
12
+ level: 'error',
13
+ hint: 'Supabase project URL. Local: `supabase start` prints it. Prod: dashboard → Settings → API.',
14
+ },
15
+ {
16
+ key: 'NEXT_PUBLIC_SUPABASE_ANON_KEY',
17
+ level: 'error',
18
+ hint: 'Anon public key from the same place. Safe to expose to the browser.',
19
+ },
20
+ {
21
+ key: 'SUPABASE_SERVICE_ROLE_KEY',
22
+ level: 'warn',
23
+ hint: 'Server-only, bypasses RLS. Optional until you write privileged server code. NEVER prefix with NEXT_PUBLIC_.',
24
+ },
25
+ ];
26
+
27
+ /** Parse a .env file into a plain object. Tolerates comments, blanks, quotes. */
28
+ export function parseEnv(text) {
29
+ const out = {};
30
+ for (const raw of text.split(/\r?\n/)) {
31
+ const line = raw.trim();
32
+ if (!line || line.startsWith('#')) continue;
33
+ const eq = line.indexOf('=');
34
+ if (eq === -1) continue;
35
+ const key = line.slice(0, eq).trim();
36
+ let val = line.slice(eq + 1).trim();
37
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
38
+ val = val.slice(1, -1);
39
+ }
40
+ out[key] = val;
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /** Merge process.env over a .env file at `envPath` (real env wins). */
46
+ export function loadEnv(envPath = '.env', processEnv = {}) {
47
+ const fromFile = existsSync(envPath) ? parseEnv(readFileSync(envPath, 'utf8')) : {};
48
+ return { ...fromFile, ...processEnv };
49
+ }
50
+
51
+ /**
52
+ * Pure check: given an env object, return one result per spec'd var.
53
+ * A NEXT_PUBLIC_-prefixed service role key is flagged as an error (leak),
54
+ * even though it's "present" — being set the wrong way is worse than unset.
55
+ */
56
+ export function checkEnv(env, spec = ENV_SPEC) {
57
+ const results = spec.map(({ key, level, hint }) => {
58
+ const value = env[key];
59
+ const present = typeof value === 'string' && value.length > 0;
60
+ return {
61
+ key,
62
+ present,
63
+ level,
64
+ hint,
65
+ status: present ? 'ok' : level, // 'ok' | 'error' | 'warn'
66
+ };
67
+ });
68
+
69
+ // Extra guard: the service key must never be public.
70
+ if (env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY) {
71
+ results.push({
72
+ key: 'NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY',
73
+ present: true,
74
+ level: 'error',
75
+ hint: 'The service role key is exposed to the browser. Remove the NEXT_PUBLIC_ prefix immediately.',
76
+ status: 'error',
77
+ });
78
+ }
79
+
80
+ const hasError = results.some((r) => r.status === 'error');
81
+ return { results, ok: !hasError };
82
+ }
@@ -0,0 +1,108 @@
1
+ // gen:migration — create a new Supabase migration that follows the boilerplate's
2
+ // RLS-first convention. A table never ships without Row Level Security here:
3
+ // the skeleton enables RLS, grants the API roles, and scopes all four verbs to
4
+ // the user's workspaces. Copy-paste-shipping a table with RLS off is the #1
5
+ // Supabase footgun; this template makes the safe path the default path.
6
+
7
+ import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+
10
+ // Two-digit zero-pad.
11
+ const p2 = (n) => String(n).padStart(2, '0');
12
+
13
+ /**
14
+ * Build the `YYYYMMDDHHmmss` timestamp Supabase orders migrations by.
15
+ * `date` is injectable so tests are deterministic.
16
+ */
17
+ export function migrationTimestamp(date = new Date()) {
18
+ return (
19
+ date.getUTCFullYear().toString() +
20
+ p2(date.getUTCMonth() + 1) +
21
+ p2(date.getUTCDate()) +
22
+ p2(date.getUTCHours()) +
23
+ p2(date.getUTCMinutes()) +
24
+ p2(date.getUTCSeconds())
25
+ );
26
+ }
27
+
28
+ /** Turn a human name into a snake_case slug safe for a filename. */
29
+ export function slugify(name) {
30
+ const slug = String(name)
31
+ .trim()
32
+ .toLowerCase()
33
+ .replace(/[^a-z0-9]+/g, '_')
34
+ .replace(/^_+|_+$/g, '');
35
+ if (!slug) throw new Error('migration name is empty after slugifying');
36
+ return slug;
37
+ }
38
+
39
+ /** `20260707123000_add_projects.sql` */
40
+ export function migrationFilename(name, date = new Date()) {
41
+ return `${migrationTimestamp(date)}_${slugify(name)}.sql`;
42
+ }
43
+
44
+ /**
45
+ * The RLS-first skeleton. `table` defaults to the slug so a name like
46
+ * "projects" produces a ready-to-edit `public.projects` table; anything the
47
+ * dev must decide is a clearly-marked TODO, never a silent gap.
48
+ */
49
+ export function migrationSkeleton(name) {
50
+ const table = slugify(name);
51
+ return `-- ${name}
52
+ -- RLS-first: this table is scoped to a workspace and can't ship exposed.
53
+ -- Adjust the columns; keep the security block. The isolation test in the
54
+ -- boilerplate proves tenant A can't touch tenant B's rows.
55
+
56
+ create table public.${table} (
57
+ id uuid primary key default gen_random_uuid(),
58
+ workspace_id uuid not null references public.workspaces (id) on delete cascade,
59
+ author_id uuid not null references auth.users (id) on delete cascade,
60
+ -- TODO: your columns here
61
+ created_at timestamptz not null default now(),
62
+ updated_at timestamptz not null default now()
63
+ );
64
+
65
+ create index ${table}_workspace_id_idx on public.${table} (workspace_id);
66
+
67
+ alter table public.${table} enable row level security;
68
+
69
+ -- Table privileges for the API roles (RLS below governs which rows).
70
+ grant select on public.${table} to anon;
71
+ grant select, insert, update, delete on public.${table} to authenticated;
72
+ grant all on public.${table} to service_role;
73
+
74
+ create policy "read ${table} in my workspaces"
75
+ on public.${table} for select
76
+ using (workspace_id in (select public.user_workspace_ids()));
77
+
78
+ create policy "create ${table} in my workspaces"
79
+ on public.${table} for insert
80
+ with check (
81
+ workspace_id in (select public.user_workspace_ids())
82
+ and author_id = (select auth.uid())
83
+ );
84
+
85
+ create policy "update ${table} in my workspaces"
86
+ on public.${table} for update
87
+ using (workspace_id in (select public.user_workspace_ids()))
88
+ with check (workspace_id in (select public.user_workspace_ids()));
89
+
90
+ create policy "delete ${table} in my workspaces"
91
+ on public.${table} for delete
92
+ using (workspace_id in (select public.user_workspace_ids()));
93
+ `;
94
+ }
95
+
96
+ /**
97
+ * Write the migration. Returns the absolute path written.
98
+ * Throws if the target file already exists (never clobber).
99
+ */
100
+ export function genMigration({ name, dir = 'supabase/migrations', date = new Date() }) {
101
+ if (!name) throw new Error('usage: saas-kit gen:migration <name>');
102
+ const filename = migrationFilename(name, date);
103
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
104
+ const path = join(dir, filename);
105
+ if (existsSync(path)) throw new Error(`refusing to overwrite existing migration: ${path}`);
106
+ writeFileSync(path, migrationSkeleton(name), 'utf8');
107
+ return path;
108
+ }
package/src/new.mjs ADDED
@@ -0,0 +1,135 @@
1
+ // new — scaffold a fresh project from a template. Works three ways:
2
+ // (default) clone the free public starter (DEFAULT_TEMPLATE below)
3
+ // --from <dir> copy a local template (offline; used in dev/tests)
4
+ // --repo <url> git clone any template (public OR private — uses the caller's
5
+ // git credentials, so a buyer invited to the paid private repo
6
+ // can scaffold straight from it)
7
+ // The default is the FREE starter, never the paid boilerplate — a free CLI must
8
+ // not hand out a paid repo. Point --repo at the paid one when you own it.
9
+
10
+ // The free, public top-of-funnel starter. Cloning this is the zero-arg path.
11
+ export const DEFAULT_TEMPLATE = 'https://github.com/mateuszingano/nextjs-supabase-starter.git';
12
+
13
+ import {
14
+ cpSync,
15
+ existsSync,
16
+ readFileSync,
17
+ writeFileSync,
18
+ rmSync,
19
+ } from 'node:fs';
20
+ import { join, resolve } from 'node:path';
21
+ import { execFileSync } from 'node:child_process';
22
+
23
+ // Never carry build artifacts, deps, or local secrets into a new project.
24
+ const IGNORE = new Set([
25
+ 'node_modules',
26
+ '.next',
27
+ '.git',
28
+ 'coverage',
29
+ 'test-results',
30
+ '.env',
31
+ '.env.local',
32
+ '.env.test',
33
+ 'tsconfig.tsbuildinfo',
34
+ ]);
35
+
36
+ /** Pure: should this top-level entry be skipped when scaffolding? */
37
+ export function ignoreEntry(name) {
38
+ return IGNORE.has(name);
39
+ }
40
+
41
+ /** Set the "name" field of a package.json string to `name`, reset version. */
42
+ export function renamePackage(pkgJson, name) {
43
+ const pkg = JSON.parse(pkgJson);
44
+ pkg.name = name;
45
+ pkg.version = '0.1.0';
46
+ return JSON.stringify(pkg, null, 2) + '\n';
47
+ }
48
+
49
+ /**
50
+ * Pure: decide where the template comes from, from flags + env.
51
+ * A value that looks like a git URL is treated as a repo, otherwise local dir.
52
+ * Returns { kind: 'local' | 'repo' | 'none', value }.
53
+ */
54
+ export function resolveSource(flags = {}, env = {}) {
55
+ if (typeof flags.from === 'string') return { kind: 'local', value: flags.from };
56
+ if (typeof flags.repo === 'string') return { kind: 'repo', value: flags.repo };
57
+ const tmpl = env.SAAS_KIT_TEMPLATE;
58
+ if (tmpl) {
59
+ const looksGit = /^https?:\/\/|^git@|\.git$/.test(tmpl);
60
+ return { kind: looksGit ? 'repo' : 'local', value: tmpl };
61
+ }
62
+ // No explicit source → default to the free public starter.
63
+ return { kind: 'repo', value: DEFAULT_TEMPLATE };
64
+ }
65
+
66
+ /** Post-clone/copy cleanup: strip .git, rename package, seed .env. */
67
+ export function finalize(dest, name) {
68
+ const gitDir = join(dest, '.git');
69
+ if (existsSync(gitDir)) rmSync(gitDir, { recursive: true, force: true });
70
+
71
+ const pkgPath = join(dest, 'package.json');
72
+ if (existsSync(pkgPath)) {
73
+ writeFileSync(pkgPath, renamePackage(readFileSync(pkgPath, 'utf8'), name), 'utf8');
74
+ }
75
+
76
+ const envPath = join(dest, '.env');
77
+ const examplePath = join(dest, '.env.example');
78
+ if (!existsSync(envPath) && existsSync(examplePath)) {
79
+ writeFileSync(envPath, readFileSync(examplePath, 'utf8'), 'utf8');
80
+ }
81
+ return dest;
82
+ }
83
+
84
+ /** Copy a local template dir into dest, honoring IGNORE. */
85
+ export function scaffoldLocal({ name, from, dest }) {
86
+ if (!name) throw new Error('usage: saas-kit new <name>');
87
+ if (!from || !existsSync(from)) throw new Error(`template not found: ${from}`);
88
+ if (existsSync(dest)) throw new Error(`refusing to overwrite existing directory: ${dest}`);
89
+
90
+ cpSync(from, dest, {
91
+ recursive: true,
92
+ filter: (src) => {
93
+ const rel = src.slice(resolve(from).length).replace(/^[\\/]/, '');
94
+ const top = rel.split(/[\\/]/)[0];
95
+ return !(top && ignoreEntry(top));
96
+ },
97
+ });
98
+ return finalize(dest, name);
99
+ }
100
+
101
+ /** Shallow git clone a template repo into dest, then finalize. */
102
+ export function scaffoldRepo({ name, repo, dest, clone = defaultClone }) {
103
+ if (!name) throw new Error('usage: saas-kit new <name>');
104
+ if (existsSync(dest)) throw new Error(`refusing to overwrite existing directory: ${dest}`);
105
+ clone(repo, dest);
106
+ return finalize(dest, name);
107
+ }
108
+
109
+ // Real clone (injectable so tests don't hit the network).
110
+ function defaultClone(repo, dest) {
111
+ try {
112
+ execFileSync('git', ['clone', '--depth', '1', repo, dest], { stdio: 'pipe' });
113
+ } catch (err) {
114
+ const detail = err.stderr ? err.stderr.toString().trim() : err.message;
115
+ throw new Error(`git clone failed for ${repo}\n ${detail}`);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Top-level scaffold: resolves the source and dispatches. `dest` is absolute.
121
+ * Throws a helpful error when no source is configured.
122
+ */
123
+ export function scaffold({ name, dest, flags = {}, env = {}, clone }) {
124
+ const source = resolveSource(flags, env);
125
+ if (source.kind === 'none') {
126
+ throw new Error(
127
+ 'no template source. Pass --repo <git-url> or --from <dir>,\n' +
128
+ ' or set SAAS_KIT_TEMPLATE. (A free CLI ships no default paid repo.)'
129
+ );
130
+ }
131
+ if (source.kind === 'local') {
132
+ return scaffoldLocal({ name, from: resolve(source.value), dest });
133
+ }
134
+ return scaffoldRepo({ name, repo: source.value, dest, clone });
135
+ }