cronus-ui 0.6.1 → 0.6.2
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 +2 -2
- package/dist/commands/add-page.js +12 -13
- package/dist/commands/add.js +2 -1
- package/dist/commands/compose.js +23 -4
- package/dist/compose/gold-path.d.ts +46 -0
- package/dist/compose/gold-path.js +923 -0
- package/dist/config.d.ts +2 -2
- package/dist/config.js +1 -1
- package/dist/utils.d.ts +22 -0
- package/dist/utils.js +69 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -49,7 +49,7 @@ Notes that match the commander surface:
|
|
|
49
49
|
`packages/cli/scripts/build-registry.ts` — each item carries its source, its npm
|
|
50
50
|
`dependencies`, and its `registryDependencies` (other components it imports),
|
|
51
51
|
derived by parsing imports.
|
|
52
|
-
- The default registry is pinned to the CLI package version (`v0.6.
|
|
52
|
+
- The default registry is pinned to the CLI package version (`v0.6.2` here), not
|
|
53
53
|
mutable `main`, so a published CLI reads the registry snapshot it was released
|
|
54
54
|
with. Use `-r, --registry ./registry` when testing local registry changes before
|
|
55
55
|
a release tag exists.
|
|
@@ -80,7 +80,7 @@ Notes that match the commander surface:
|
|
|
80
80
|
"lib": "lib",
|
|
81
81
|
"blocks": "components/blocks"
|
|
82
82
|
},
|
|
83
|
-
"registry": "https://raw.githubusercontent.com/pedrogbraz/cronus-ui/v0.6.
|
|
83
|
+
"registry": "https://raw.githubusercontent.com/pedrogbraz/cronus-ui/v0.6.2/registry"
|
|
84
84
|
}
|
|
85
85
|
```
|
|
86
86
|
|
|
@@ -26,7 +26,7 @@ import { baseSnapshotDir, reloadManifest } from "../compose/reload.js";
|
|
|
26
26
|
import { chromeWrapperPath, layoutPath, pagePath, renderChromeWrapper, renderLayout, renderPage, renderShellWrapper, rewriteChromeBlock, } from "../compose/render.js";
|
|
27
27
|
import { CLI_VERSION, hasConfig, readConfig, writeConfig, } from "../config.js";
|
|
28
28
|
import { Registry, registrySourceVersion } from "../registry.js";
|
|
29
|
-
import { closestName, collectDependencies, detectPackageManager, log, resolveSafeDest, runInstall, writeFileEnsured, writeItemFiles, } from "../utils.js";
|
|
29
|
+
import { closestName, collectDependencies, detectPackageManager, log, recordDependencies, resolveSafeDest, rewriteImports, runInstall, writeFileEnsured, writeItemFiles, } from "../utils.js";
|
|
30
30
|
import { readChromeSources, readComposeMeta } from "./compose.js";
|
|
31
31
|
/** Title-case a route into a default page title: "/faq" → "Faq", "/help-center" → "Help Center". */
|
|
32
32
|
function defaultTitle(route) {
|
|
@@ -275,7 +275,7 @@ export async function addPage(options) {
|
|
|
275
275
|
const source = plan.chromeSources[slug];
|
|
276
276
|
if (source === undefined || !existsSync(dest))
|
|
277
277
|
continue;
|
|
278
|
-
const content = rewriteChromeBlock(slug, source, plan);
|
|
278
|
+
const content = rewriteImports(rewriteChromeBlock(slug, source, plan), config);
|
|
279
279
|
await writeFileEnsured(dest, content);
|
|
280
280
|
if (!generatedFiles.includes(rel))
|
|
281
281
|
generatedFiles.push(rel);
|
|
@@ -298,17 +298,16 @@ export async function addPage(options) {
|
|
|
298
298
|
};
|
|
299
299
|
const composed = { ...config.composed, [appName]: nextRecord };
|
|
300
300
|
await writeConfig(targetDir, { ...config, installed, composed });
|
|
301
|
-
// ---
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
301
|
+
// --- Record + install npm deps (best-effort) ------------------------------
|
|
302
|
+
const deps = resolvedItems.length > 0 ? collectDependencies(resolvedItems) : [];
|
|
303
|
+
await recordDependencies(targetDir, deps);
|
|
304
|
+
if (deps.length > 0 && !(options.skipInstall ?? false)) {
|
|
305
|
+
const pm = detectPackageManager(targetDir);
|
|
306
|
+
try {
|
|
307
|
+
await runInstall(pm, deps, targetDir);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// Non-fatal.
|
|
312
311
|
}
|
|
313
312
|
}
|
|
314
313
|
return { appName, route: options.route, generatedFiles, skippedFiles, installedBlocks };
|
package/dist/commands/add.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CLI_VERSION, hasConfig, readConfig, writeConfig, } from "../config.js";
|
|
2
2
|
import { Registry, registrySourceVersion } from "../registry.js";
|
|
3
|
-
import { closestName, collectDependencies, detectPackageManager, log, runInstall, writeItemFiles, } from "../utils.js";
|
|
3
|
+
import { closestName, collectDependencies, detectPackageManager, log, recordDependencies, runInstall, writeItemFiles, } from "../utils.js";
|
|
4
4
|
export async function add(names, options) {
|
|
5
5
|
const { cwd } = options;
|
|
6
6
|
if (!hasConfig(cwd)) {
|
|
@@ -94,6 +94,7 @@ export async function add(names, options) {
|
|
|
94
94
|
await writeConfig(cwd, { ...config, installed });
|
|
95
95
|
}
|
|
96
96
|
const deps = collectDependencies(items);
|
|
97
|
+
await recordDependencies(cwd, deps);
|
|
97
98
|
if (deps.length > 0 && !options.skipInstall) {
|
|
98
99
|
const pm = detectPackageManager(cwd);
|
|
99
100
|
log.step(`Installing ${deps.length} dependencies with ${pm}…`);
|
package/dist/commands/compose.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { writeDesignDocuments } from "@cronus-ui/ai-kit";
|
|
5
|
+
import { applyGoldPath, GOLD_PATH_DEPENDENCIES, isGoldPathTemplate } from "../compose/gold-path.js";
|
|
5
6
|
import { manifestFingerprint } from "../compose/manifest.js";
|
|
6
7
|
import { buildComposePlan, ComposePlanError, } from "../compose/plan.js";
|
|
7
8
|
import { renderPreview } from "../compose/preview.js";
|
|
@@ -10,7 +11,7 @@ import { renderPlan } from "../compose/render.js";
|
|
|
10
11
|
import { defaultComposeTemplate, listTemplates, loadManifestFile, loadTemplate, } from "../compose/templates.js";
|
|
11
12
|
import { CLI_VERSION, hasConfig, readConfig, writeConfig, } from "../config.js";
|
|
12
13
|
import { Registry, registrySourceVersion } from "../registry.js";
|
|
13
|
-
import { collectDependencies, detectPackageManager, log, resolveSafeDest, runInstall, writeFileEnsured, writeItemFiles, } from "../utils.js";
|
|
14
|
+
import { collectDependencies, detectPackageManager, log, recordDependencies, resolveSafeDest, rewriteImports, runInstall, writeFileEnsured, writeItemFiles, } from "../utils.js";
|
|
14
15
|
export { listTemplates, loadManifestFile, loadTemplate } from "../compose/templates.js";
|
|
15
16
|
/** Read the chrome block sources named by the manifest's chrome map from the registry. */
|
|
16
17
|
export async function readChromeSources(manifest, registry) {
|
|
@@ -129,7 +130,7 @@ export async function composeApp(options) {
|
|
|
129
130
|
continue;
|
|
130
131
|
}
|
|
131
132
|
const dest = resolveSafeDest(targetDir, ".", rewrite.file);
|
|
132
|
-
await writeFileEnsured(dest, rewrite.content);
|
|
133
|
+
await writeFileEnsured(dest, rewriteImports(rewrite.content, config));
|
|
133
134
|
generatedFiles.push(rewrite.file);
|
|
134
135
|
}
|
|
135
136
|
// --- Write the generated pages/layouts/wrappers (safe writes) -------------
|
|
@@ -147,6 +148,18 @@ export async function composeApp(options) {
|
|
|
147
148
|
const snapDest = resolveSafeDest(targetDir, baseSnapshotDir(plan.templateName), file.path);
|
|
148
149
|
await writeFileEnsured(snapDest, file.content);
|
|
149
150
|
}
|
|
151
|
+
// Authenticated gold path (saas/admin only): sqlite + drizzle + better-auth.
|
|
152
|
+
if (isGoldPathTemplate(plan.templateName)) {
|
|
153
|
+
const gold = await applyGoldPath({
|
|
154
|
+
targetDir,
|
|
155
|
+
config,
|
|
156
|
+
generatedFiles,
|
|
157
|
+
overwrite,
|
|
158
|
+
templateName: plan.templateName,
|
|
159
|
+
});
|
|
160
|
+
generatedFiles.push(...gold.written);
|
|
161
|
+
skippedFiles.push(...gold.skipped);
|
|
162
|
+
}
|
|
150
163
|
// --- Record composed{} + installed{} --------------------------------------
|
|
151
164
|
// Keyed by TEMPLATE name (not project name): a project can compose several
|
|
152
165
|
// templates, and re-composing the same one updates its record in place.
|
|
@@ -172,8 +185,14 @@ export async function composeApp(options) {
|
|
|
172
185
|
const nextConfig = { ...config, installed, composed };
|
|
173
186
|
await writeConfig(targetDir, nextConfig);
|
|
174
187
|
writeDesignDocuments(targetDir, { theme: nextConfig.theme?.name });
|
|
175
|
-
// ---
|
|
176
|
-
|
|
188
|
+
// --- Record + install npm deps --------------------------------------------
|
|
189
|
+
// Always pin registry specs into package.json (lucide-react, recharts, …)
|
|
190
|
+
// even when --no-install: a later `bun install` must match `pm add`.
|
|
191
|
+
const registryDeps = collectDependencies(items);
|
|
192
|
+
await recordDependencies(targetDir, registryDeps);
|
|
193
|
+
const deps = isGoldPathTemplate(plan.templateName)
|
|
194
|
+
? [...new Set([...registryDeps, ...GOLD_PATH_DEPENDENCIES])].sort()
|
|
195
|
+
: registryDeps;
|
|
177
196
|
if (deps.length > 0 && !(options.skipInstall ?? false)) {
|
|
178
197
|
const pm = detectPackageManager(targetDir);
|
|
179
198
|
try {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated gold path for saas/admin compose: SQLite + Drizzle + Better-Auth.
|
|
3
|
+
* Always sqlite — postgres/mysql live in create-cronus-stack.
|
|
4
|
+
*/
|
|
5
|
+
import type { CronusUIConfig } from "../config.js";
|
|
6
|
+
export declare const GOLD_PATH_TEMPLATES: Set<string>;
|
|
7
|
+
export declare function isGoldPathTemplate(name: string): boolean;
|
|
8
|
+
/** Production npm specs installed with the gold path (devDeps are merged into package.json). */
|
|
9
|
+
export declare const GOLD_PATH_DEPENDENCIES: readonly ["drizzle-orm@^0.45.2", "better-sqlite3@^12.0.0", "better-auth@^1.7.2"];
|
|
10
|
+
export interface GoldPathLayout {
|
|
11
|
+
libDir: string;
|
|
12
|
+
dbDir: string;
|
|
13
|
+
appDir: string;
|
|
14
|
+
componentsDir: string;
|
|
15
|
+
middlewareRel: string;
|
|
16
|
+
}
|
|
17
|
+
export interface ApplyGoldPathOptions {
|
|
18
|
+
targetDir: string;
|
|
19
|
+
config: CronusUIConfig;
|
|
20
|
+
/** Paths compose actually wrote this run (used to gate the home-page patch). */
|
|
21
|
+
generatedFiles: string[];
|
|
22
|
+
overwrite: boolean;
|
|
23
|
+
/** Composed{} key; when set, the patched home snapshot is updated too. */
|
|
24
|
+
templateName?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface ApplyGoldPathResult {
|
|
27
|
+
written: string[];
|
|
28
|
+
skipped: string[];
|
|
29
|
+
}
|
|
30
|
+
export declare function goldPathLayout(config: CronusUIConfig): GoldPathLayout;
|
|
31
|
+
/**
|
|
32
|
+
* Wire the installed app-shell-chrome copy to live Better-Auth orgs + session.
|
|
33
|
+
* Idempotent: a chrome that already has WorkspaceMenu and SessionUser is left
|
|
34
|
+
* untouched. Returns undefined when the WorkspaceSwitcher / InviteDialog
|
|
35
|
+
* anchors are missing and nothing else can be patched.
|
|
36
|
+
*/
|
|
37
|
+
export declare function patchChromeSource(source: string, workspaceImport: string, inviteImport: string, sessionImport?: string): string | undefined;
|
|
38
|
+
/** Insert ItemsPanel into a generated home page. Returns undefined when there is no main. */
|
|
39
|
+
export declare function patchHomePageSource(source: string, itemsImport: string): string | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Write sqlite + Drizzle + Better-Auth files into a composed saas/admin app.
|
|
42
|
+
* Overwrites lib/auth-adapter.ts always (replaces the demo adapter). Patches
|
|
43
|
+
* the shell home page only when compose wrote it this run.
|
|
44
|
+
*/
|
|
45
|
+
export declare function applyGoldPath(options: ApplyGoldPathOptions): Promise<ApplyGoldPathResult>;
|
|
46
|
+
//# sourceMappingURL=gold-path.d.ts.map
|
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated gold path for saas/admin compose: SQLite + Drizzle + Better-Auth.
|
|
3
|
+
* Always sqlite — postgres/mysql live in create-cronus-stack.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { readFile } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { resolveSafeDest, writeFileEnsured } from "../utils.js";
|
|
9
|
+
import { baseSnapshotDir } from "./reload.js";
|
|
10
|
+
export const GOLD_PATH_TEMPLATES = new Set(["saas", "admin"]);
|
|
11
|
+
export function isGoldPathTemplate(name) {
|
|
12
|
+
return GOLD_PATH_TEMPLATES.has(name);
|
|
13
|
+
}
|
|
14
|
+
/** Production npm specs installed with the gold path (devDeps are merged into package.json). */
|
|
15
|
+
export const GOLD_PATH_DEPENDENCIES = [
|
|
16
|
+
"drizzle-orm@^0.45.2",
|
|
17
|
+
"better-sqlite3@^12.0.0",
|
|
18
|
+
"better-auth@^1.7.2",
|
|
19
|
+
];
|
|
20
|
+
const GOLD_PATH_PROD_DEPS = {
|
|
21
|
+
"drizzle-orm": "^0.45.2",
|
|
22
|
+
"better-sqlite3": "^12.0.0",
|
|
23
|
+
"better-auth": "^1.7.2",
|
|
24
|
+
};
|
|
25
|
+
const GOLD_PATH_DEV_DEPS = {
|
|
26
|
+
"drizzle-kit": "^0.31.10",
|
|
27
|
+
"@types/better-sqlite3": "^9.6.0",
|
|
28
|
+
};
|
|
29
|
+
const GOLD_PATH_SCRIPTS = {
|
|
30
|
+
"db:push": "drizzle-kit push",
|
|
31
|
+
"db:generate": "drizzle-kit generate",
|
|
32
|
+
"db:studio": "drizzle-kit studio",
|
|
33
|
+
};
|
|
34
|
+
const ENV_VARS = {
|
|
35
|
+
DATABASE_URL: "file:./data/app.db",
|
|
36
|
+
BETTER_AUTH_SECRET: "change-me-to-a-32-character-secret",
|
|
37
|
+
BETTER_AUTH_URL: "http://localhost:3000",
|
|
38
|
+
};
|
|
39
|
+
const GITIGNORE_ENTRIES = ["*.db", "data/", "drizzle/"];
|
|
40
|
+
const DATABASE_URL_FALLBACK = "file:./data/app.db";
|
|
41
|
+
export function goldPathLayout(config) {
|
|
42
|
+
const libDir = posix(config.paths.lib);
|
|
43
|
+
const uiDir = posix(config.paths.ui);
|
|
44
|
+
const src = libDir === "src" || libDir.startsWith("src/");
|
|
45
|
+
const prefix = src ? "src/" : "";
|
|
46
|
+
const componentsDir = uiDir.endsWith("/ui")
|
|
47
|
+
? uiDir.slice(0, -"/ui".length)
|
|
48
|
+
: `${prefix}components`;
|
|
49
|
+
return {
|
|
50
|
+
libDir,
|
|
51
|
+
dbDir: `${prefix}db`,
|
|
52
|
+
appDir: `${prefix}app`,
|
|
53
|
+
componentsDir,
|
|
54
|
+
middlewareRel: `${prefix}middleware.ts`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function posix(p) {
|
|
58
|
+
return p.replaceAll("\\", "/");
|
|
59
|
+
}
|
|
60
|
+
function resolveAppDir(targetDir, layout, generatedFiles) {
|
|
61
|
+
const fromGenerated = generatedFiles.find((f) => /(^|\/)\(shell\)\/page\.tsx$/.test(posix(f)));
|
|
62
|
+
if (fromGenerated !== undefined) {
|
|
63
|
+
return posix(fromGenerated).replace(/\/\(shell\)\/page\.tsx$/, "");
|
|
64
|
+
}
|
|
65
|
+
if (existsSync(join(targetDir, "app", "(shell)", "page.tsx")))
|
|
66
|
+
return "app";
|
|
67
|
+
if (existsSync(join(targetDir, "src", "app", "(shell)", "page.tsx")))
|
|
68
|
+
return "src/app";
|
|
69
|
+
if (existsSync(join(targetDir, layout.appDir)))
|
|
70
|
+
return layout.appDir;
|
|
71
|
+
if (existsSync(join(targetDir, "app")))
|
|
72
|
+
return "app";
|
|
73
|
+
if (existsSync(join(targetDir, "src", "app")))
|
|
74
|
+
return "src/app";
|
|
75
|
+
return layout.appDir;
|
|
76
|
+
}
|
|
77
|
+
function homePageRel(appDir, generatedFiles) {
|
|
78
|
+
const match = generatedFiles.find((f) => posix(f) === `${appDir}/(shell)/page.tsx`);
|
|
79
|
+
if (match !== undefined)
|
|
80
|
+
return posix(match);
|
|
81
|
+
const any = generatedFiles.find((f) => /(^|\/)\(shell\)\/page\.tsx$/.test(posix(f)));
|
|
82
|
+
return any !== undefined ? posix(any) : undefined;
|
|
83
|
+
}
|
|
84
|
+
function drizzleConfigSource(dbDir) {
|
|
85
|
+
return `import { mkdirSync } from "node:fs";
|
|
86
|
+
import { dirname } from "node:path";
|
|
87
|
+
import { defineConfig } from "drizzle-kit";
|
|
88
|
+
|
|
89
|
+
const url = process.env.DATABASE_URL ?? "${DATABASE_URL_FALLBACK}";
|
|
90
|
+
const fileFromUrl = url.startsWith("file:") ? url.slice("file:".length) : url;
|
|
91
|
+
mkdirSync(dirname(fileFromUrl) || ".", { recursive: true });
|
|
92
|
+
|
|
93
|
+
export default defineConfig({
|
|
94
|
+
dialect: "sqlite",
|
|
95
|
+
schema: "./${dbDir}/schema.ts",
|
|
96
|
+
out: "./drizzle",
|
|
97
|
+
dbCredentials: { url },
|
|
98
|
+
});
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
function dbSchemaSource() {
|
|
102
|
+
return `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
103
|
+
|
|
104
|
+
export const user = sqliteTable("user", {
|
|
105
|
+
id: text("id").primaryKey(),
|
|
106
|
+
name: text("name").notNull(),
|
|
107
|
+
email: text("email").notNull().unique(),
|
|
108
|
+
emailVerified: integer("email_verified", { mode: "boolean" }).notNull(),
|
|
109
|
+
image: text("image"),
|
|
110
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
111
|
+
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
export const session = sqliteTable("session", {
|
|
115
|
+
id: text("id").primaryKey(),
|
|
116
|
+
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
|
117
|
+
token: text("token").notNull().unique(),
|
|
118
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
119
|
+
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
|
120
|
+
ipAddress: text("ip_address"),
|
|
121
|
+
userAgent: text("user_agent"),
|
|
122
|
+
activeOrganizationId: text("active_organization_id"),
|
|
123
|
+
userId: text("user_id")
|
|
124
|
+
.notNull()
|
|
125
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
export const account = sqliteTable("account", {
|
|
129
|
+
id: text("id").primaryKey(),
|
|
130
|
+
issuer: text("issuer").notNull(),
|
|
131
|
+
accountId: text("account_id").notNull(),
|
|
132
|
+
providerId: text("provider_id").notNull(),
|
|
133
|
+
userId: text("user_id")
|
|
134
|
+
.notNull()
|
|
135
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
136
|
+
accessToken: text("access_token"),
|
|
137
|
+
refreshToken: text("refresh_token"),
|
|
138
|
+
idToken: text("id_token"),
|
|
139
|
+
accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp" }),
|
|
140
|
+
refreshTokenExpiresAt: integer("refresh_token_expires_at", { mode: "timestamp" }),
|
|
141
|
+
scope: text("scope"),
|
|
142
|
+
password: text("password"),
|
|
143
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
144
|
+
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
export const verification = sqliteTable("verification", {
|
|
148
|
+
id: text("id").primaryKey(),
|
|
149
|
+
identifier: text("identifier").notNull(),
|
|
150
|
+
value: text("value").notNull(),
|
|
151
|
+
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
|
152
|
+
createdAt: integer("created_at", { mode: "timestamp" }),
|
|
153
|
+
updatedAt: integer("updated_at", { mode: "timestamp" }),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
export const organization = sqliteTable("organization", {
|
|
157
|
+
id: text("id").primaryKey(),
|
|
158
|
+
name: text("name").notNull(),
|
|
159
|
+
slug: text("slug").notNull().unique(),
|
|
160
|
+
logo: text("logo"),
|
|
161
|
+
metadata: text("metadata"),
|
|
162
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
export const member = sqliteTable("member", {
|
|
166
|
+
id: text("id").primaryKey(),
|
|
167
|
+
organizationId: text("organization_id")
|
|
168
|
+
.notNull()
|
|
169
|
+
.references(() => organization.id, { onDelete: "cascade" }),
|
|
170
|
+
userId: text("user_id")
|
|
171
|
+
.notNull()
|
|
172
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
173
|
+
role: text("role").notNull(),
|
|
174
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
export const invitation = sqliteTable("invitation", {
|
|
178
|
+
id: text("id").primaryKey(),
|
|
179
|
+
organizationId: text("organization_id")
|
|
180
|
+
.notNull()
|
|
181
|
+
.references(() => organization.id, { onDelete: "cascade" }),
|
|
182
|
+
email: text("email").notNull(),
|
|
183
|
+
role: text("role"),
|
|
184
|
+
status: text("status").notNull(),
|
|
185
|
+
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
|
186
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
187
|
+
inviterId: text("inviter_id")
|
|
188
|
+
.notNull()
|
|
189
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
export const items = sqliteTable("items", {
|
|
193
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
194
|
+
title: text("title").notNull(),
|
|
195
|
+
workspaceId: text("workspace_id").references(() => organization.id, { onDelete: "cascade" }),
|
|
196
|
+
});
|
|
197
|
+
`;
|
|
198
|
+
}
|
|
199
|
+
function dbClientSource() {
|
|
200
|
+
return `import { mkdirSync } from "node:fs";
|
|
201
|
+
import { dirname } from "node:path";
|
|
202
|
+
import Database from "better-sqlite3";
|
|
203
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
204
|
+
import * as schema from "./schema";
|
|
205
|
+
|
|
206
|
+
const url = process.env.DATABASE_URL ?? "${DATABASE_URL_FALLBACK}";
|
|
207
|
+
const fileFromUrl = url.startsWith("file:") ? url.slice("file:".length) : url;
|
|
208
|
+
mkdirSync(dirname(fileFromUrl) || ".", { recursive: true });
|
|
209
|
+
const sqlite = new Database(fileFromUrl);
|
|
210
|
+
|
|
211
|
+
export const db = drizzle(sqlite, { schema });
|
|
212
|
+
`;
|
|
213
|
+
}
|
|
214
|
+
function authServerSource() {
|
|
215
|
+
return `import { betterAuth } from "better-auth";
|
|
216
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
217
|
+
import { nextCookies } from "better-auth/next-js";
|
|
218
|
+
import { organization } from "better-auth/plugins";
|
|
219
|
+
import { and, eq } from "drizzle-orm";
|
|
220
|
+
import { db } from "@/db";
|
|
221
|
+
import * as schema from "@/db/schema";
|
|
222
|
+
import {
|
|
223
|
+
invitation as invitationTable,
|
|
224
|
+
member,
|
|
225
|
+
organization as organizationTable,
|
|
226
|
+
user as userTable,
|
|
227
|
+
} from "@/db/schema";
|
|
228
|
+
|
|
229
|
+
function newId(): string {
|
|
230
|
+
return crypto.randomUUID().replaceAll("-", "");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export const auth = betterAuth({
|
|
234
|
+
database: drizzleAdapter(db, { provider: "sqlite", schema }),
|
|
235
|
+
emailAndPassword: {
|
|
236
|
+
enabled: true,
|
|
237
|
+
sendResetPassword: async ({ url }) => {
|
|
238
|
+
console.info(url);
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
databaseHooks: {
|
|
242
|
+
session: {
|
|
243
|
+
create: {
|
|
244
|
+
before: async (session) => {
|
|
245
|
+
const [existing] = await db
|
|
246
|
+
.select({ organizationId: member.organizationId })
|
|
247
|
+
.from(member)
|
|
248
|
+
.where(eq(member.userId, session.userId))
|
|
249
|
+
.limit(1);
|
|
250
|
+
if (existing?.organizationId) {
|
|
251
|
+
return { data: { ...session, activeOrganizationId: existing.organizationId } };
|
|
252
|
+
}
|
|
253
|
+
const [owner] = await db
|
|
254
|
+
.select({ name: userTable.name, email: userTable.email })
|
|
255
|
+
.from(userTable)
|
|
256
|
+
.where(eq(userTable.id, session.userId))
|
|
257
|
+
.limit(1);
|
|
258
|
+
if (owner?.email) {
|
|
259
|
+
const [pending] = await db
|
|
260
|
+
.select({ id: invitationTable.id })
|
|
261
|
+
.from(invitationTable)
|
|
262
|
+
.where(
|
|
263
|
+
and(eq(invitationTable.email, owner.email), eq(invitationTable.status, "pending")),
|
|
264
|
+
)
|
|
265
|
+
.limit(1);
|
|
266
|
+
if (pending) return;
|
|
267
|
+
}
|
|
268
|
+
const orgId = newId();
|
|
269
|
+
const now = new Date();
|
|
270
|
+
await db.insert(organizationTable).values({
|
|
271
|
+
id: orgId,
|
|
272
|
+
name: owner?.name.trim() || "Workspace",
|
|
273
|
+
slug: \`ws-\${session.userId.slice(0, 16)}\`,
|
|
274
|
+
createdAt: now,
|
|
275
|
+
});
|
|
276
|
+
await db.insert(member).values({
|
|
277
|
+
id: newId(),
|
|
278
|
+
organizationId: orgId,
|
|
279
|
+
userId: session.userId,
|
|
280
|
+
role: "owner",
|
|
281
|
+
createdAt: now,
|
|
282
|
+
});
|
|
283
|
+
return { data: { ...session, activeOrganizationId: orgId } };
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
secret: process.env.BETTER_AUTH_SECRET,
|
|
289
|
+
baseURL: process.env.BETTER_AUTH_URL,
|
|
290
|
+
plugins: [
|
|
291
|
+
organization({
|
|
292
|
+
sendInvitationEmail: async (data) => {
|
|
293
|
+
const base = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
|
|
294
|
+
console.info(\`Invite \${data.email}: \${base}/accept-invitation?id=\${data.id}\`);
|
|
295
|
+
},
|
|
296
|
+
}),
|
|
297
|
+
nextCookies(),
|
|
298
|
+
],
|
|
299
|
+
});
|
|
300
|
+
`;
|
|
301
|
+
}
|
|
302
|
+
function authClientSource() {
|
|
303
|
+
return `import { organizationClient } from "better-auth/client/plugins";
|
|
304
|
+
import { createAuthClient } from "better-auth/react";
|
|
305
|
+
|
|
306
|
+
export const authClient = createAuthClient({
|
|
307
|
+
plugins: [organizationClient()],
|
|
308
|
+
});
|
|
309
|
+
`;
|
|
310
|
+
}
|
|
311
|
+
function authAdapterSource() {
|
|
312
|
+
return `import { authClient } from "./auth-client";
|
|
313
|
+
|
|
314
|
+
const INVITE_KEY = "cronus-invitation";
|
|
315
|
+
|
|
316
|
+
function readInvitation(): string | null {
|
|
317
|
+
if (typeof window === "undefined") return null;
|
|
318
|
+
const fromUrl = new URLSearchParams(window.location.search).get("invitation");
|
|
319
|
+
if (fromUrl) {
|
|
320
|
+
sessionStorage.setItem(INVITE_KEY, fromUrl);
|
|
321
|
+
return fromUrl;
|
|
322
|
+
}
|
|
323
|
+
return sessionStorage.getItem(INVITE_KEY);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function afterAuthPath(): string {
|
|
327
|
+
const invitation = readInvitation();
|
|
328
|
+
if (invitation) {
|
|
329
|
+
return \`/accept-invitation?id=\${encodeURIComponent(invitation)}\`;
|
|
330
|
+
}
|
|
331
|
+
return "/";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (typeof window !== "undefined") {
|
|
335
|
+
readInvitation();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function signInEmail({ email, password }: { email: string; password: string }) {
|
|
339
|
+
const callbackURL = afterAuthPath();
|
|
340
|
+
const { error } = await authClient.signIn.email({ email, password, callbackURL });
|
|
341
|
+
if (error) throw new Error(error.message || "Sign in failed");
|
|
342
|
+
window.location.assign(callbackURL);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function signUpEmail({
|
|
346
|
+
email,
|
|
347
|
+
password,
|
|
348
|
+
name,
|
|
349
|
+
}: {
|
|
350
|
+
email: string;
|
|
351
|
+
password: string;
|
|
352
|
+
name?: string;
|
|
353
|
+
}) {
|
|
354
|
+
const callbackURL = afterAuthPath();
|
|
355
|
+
const { error } = await authClient.signUp.email({
|
|
356
|
+
email,
|
|
357
|
+
password,
|
|
358
|
+
name: name ?? email,
|
|
359
|
+
callbackURL,
|
|
360
|
+
});
|
|
361
|
+
if (error) throw new Error(error.message || "Sign up failed");
|
|
362
|
+
window.location.assign(callbackURL);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export async function requestPasswordReset({ email }: { email: string }) {
|
|
366
|
+
const { error } = await authClient.requestPasswordReset({ email, redirectTo: "/login" });
|
|
367
|
+
if (error) throw new Error(error.message || "Reset failed");
|
|
368
|
+
}
|
|
369
|
+
`;
|
|
370
|
+
}
|
|
371
|
+
function authRouteSource(authImport) {
|
|
372
|
+
return `import { toNextJsHandler } from "better-auth/next-js";
|
|
373
|
+
import { auth } from ${JSON.stringify(authImport)};
|
|
374
|
+
|
|
375
|
+
export const { GET, POST } = toNextJsHandler(auth);
|
|
376
|
+
`;
|
|
377
|
+
}
|
|
378
|
+
function middlewareSource() {
|
|
379
|
+
return `import type { NextRequest } from "next/server";
|
|
380
|
+
import { NextResponse } from "next/server";
|
|
381
|
+
import { getSessionCookie } from "better-auth/cookies";
|
|
382
|
+
|
|
383
|
+
const AUTH_PAGES = ["/login", "/signup", "/forgot-password"];
|
|
384
|
+
|
|
385
|
+
function invitationOf(request: NextRequest): string | null {
|
|
386
|
+
const { pathname, searchParams } = request.nextUrl;
|
|
387
|
+
return (
|
|
388
|
+
searchParams.get("invitation") ??
|
|
389
|
+
(pathname === "/accept-invitation" ? searchParams.get("id") : null)
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function middleware(request: NextRequest) {
|
|
394
|
+
const { pathname } = request.nextUrl;
|
|
395
|
+
if (
|
|
396
|
+
pathname.startsWith("/api/auth") ||
|
|
397
|
+
pathname.startsWith("/_next") ||
|
|
398
|
+
pathname === "/favicon.ico"
|
|
399
|
+
) {
|
|
400
|
+
return NextResponse.next();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const sessionCookie = getSessionCookie(request);
|
|
404
|
+
const isAuthPage = AUTH_PAGES.includes(pathname);
|
|
405
|
+
const invitation = invitationOf(request);
|
|
406
|
+
|
|
407
|
+
if (!sessionCookie && pathname === "/accept-invitation") {
|
|
408
|
+
const url = new URL("/signup", request.url);
|
|
409
|
+
if (invitation) url.searchParams.set("invitation", invitation);
|
|
410
|
+
return NextResponse.redirect(url);
|
|
411
|
+
}
|
|
412
|
+
if (!sessionCookie && !isAuthPage) {
|
|
413
|
+
const url = new URL("/login", request.url);
|
|
414
|
+
if (invitation) url.searchParams.set("invitation", invitation);
|
|
415
|
+
return NextResponse.redirect(url);
|
|
416
|
+
}
|
|
417
|
+
if (sessionCookie && isAuthPage) {
|
|
418
|
+
if (invitation) {
|
|
419
|
+
return NextResponse.redirect(
|
|
420
|
+
new URL(\`/accept-invitation?id=\${encodeURIComponent(invitation)}\`, request.url),
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
return NextResponse.redirect(new URL("/", request.url));
|
|
424
|
+
}
|
|
425
|
+
return NextResponse.next();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export const config = {
|
|
429
|
+
matcher: [
|
|
430
|
+
"/((?!_next/static|_next/image|favicon.ico|.*\\\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)",
|
|
431
|
+
],
|
|
432
|
+
};
|
|
433
|
+
`;
|
|
434
|
+
}
|
|
435
|
+
function itemsPanelSource(authImport) {
|
|
436
|
+
return `import { eq } from "drizzle-orm";
|
|
437
|
+
import { headers } from "next/headers";
|
|
438
|
+
import { db } from "@/db";
|
|
439
|
+
import { items, member, organization } from "@/db/schema";
|
|
440
|
+
import { auth } from ${JSON.stringify(authImport)};
|
|
441
|
+
|
|
442
|
+
export async function ItemsPanel() {
|
|
443
|
+
const session = await auth.api.getSession({ headers: await headers() });
|
|
444
|
+
let orgId = session?.session?.activeOrganizationId ?? null;
|
|
445
|
+
if (!orgId && session?.user?.id) {
|
|
446
|
+
const [row] = await db
|
|
447
|
+
.select({ organizationId: member.organizationId })
|
|
448
|
+
.from(member)
|
|
449
|
+
.where(eq(member.userId, session.user.id))
|
|
450
|
+
.limit(1);
|
|
451
|
+
orgId = row?.organizationId ?? null;
|
|
452
|
+
}
|
|
453
|
+
const org = orgId
|
|
454
|
+
? (await db.select().from(organization).where(eq(organization.id, orgId)).limit(1))[0]
|
|
455
|
+
: undefined;
|
|
456
|
+
const rows = orgId
|
|
457
|
+
? await db.select().from(items).where(eq(items.workspaceId, orgId))
|
|
458
|
+
: [];
|
|
459
|
+
const email = session?.user?.email ?? "signed out";
|
|
460
|
+
const workspace = org?.name ?? "no workspace";
|
|
461
|
+
const count = String(rows.length);
|
|
462
|
+
return (
|
|
463
|
+
<p className="px-6 pt-6 text-sm text-fg-tertiary">
|
|
464
|
+
{email} · {workspace} · {count} items
|
|
465
|
+
</p>
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
`;
|
|
469
|
+
}
|
|
470
|
+
function workspaceMenuSource(authClientImport) {
|
|
471
|
+
return `"use client";
|
|
472
|
+
|
|
473
|
+
import { WorkspaceSwitcher } from "@cronus-ui/ui";
|
|
474
|
+
import { useRouter } from "next/navigation";
|
|
475
|
+
import { useEffect } from "react";
|
|
476
|
+
import { authClient } from ${JSON.stringify(authClientImport)};
|
|
477
|
+
|
|
478
|
+
export function WorkspaceMenu() {
|
|
479
|
+
const router = useRouter();
|
|
480
|
+
const { data: orgs } = authClient.useListOrganizations();
|
|
481
|
+
const { data: active } = authClient.useActiveOrganization();
|
|
482
|
+
const workspaces = (orgs ?? []).map((org) => ({ id: org.id, name: org.name }));
|
|
483
|
+
const firstId = workspaces[0]?.id;
|
|
484
|
+
useEffect(() => {
|
|
485
|
+
if (active || !firstId) return;
|
|
486
|
+
void authClient.organization.setActive({ organizationId: firstId }).then(() => {
|
|
487
|
+
router.refresh();
|
|
488
|
+
});
|
|
489
|
+
}, [active, firstId, router]);
|
|
490
|
+
return (
|
|
491
|
+
<WorkspaceSwitcher
|
|
492
|
+
workspaces={workspaces}
|
|
493
|
+
value={active?.id}
|
|
494
|
+
onValueChange={(id) => {
|
|
495
|
+
void authClient.organization.setActive({ organizationId: id }).then(() => {
|
|
496
|
+
router.refresh();
|
|
497
|
+
});
|
|
498
|
+
}}
|
|
499
|
+
/>
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
`;
|
|
503
|
+
}
|
|
504
|
+
function inviteMemberSource(authClientImport) {
|
|
505
|
+
return `"use client";
|
|
506
|
+
|
|
507
|
+
import { InviteDialog } from "@cronus-ui/ui";
|
|
508
|
+
import type { ReactNode } from "react";
|
|
509
|
+
import { authClient } from ${JSON.stringify(authClientImport)};
|
|
510
|
+
|
|
511
|
+
export function InviteMember({ trigger }: { trigger: ReactNode }) {
|
|
512
|
+
return (
|
|
513
|
+
<InviteDialog
|
|
514
|
+
trigger={trigger}
|
|
515
|
+
onInvite={async ({ email, role }) => {
|
|
516
|
+
const assigned = role === "admin" || role === "owner" ? role : "member";
|
|
517
|
+
const { error } = await authClient.organization.inviteMember({
|
|
518
|
+
email,
|
|
519
|
+
role: assigned,
|
|
520
|
+
});
|
|
521
|
+
if (error) throw new Error(error.message || "Invite failed");
|
|
522
|
+
}}
|
|
523
|
+
/>
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
`;
|
|
527
|
+
}
|
|
528
|
+
function sessionUserSource(authClientImport) {
|
|
529
|
+
return `"use client";
|
|
530
|
+
|
|
531
|
+
import { Avatar, AvatarFallback, AvatarImage } from "@cronus-ui/ui";
|
|
532
|
+
import { authClient } from ${JSON.stringify(authClientImport)};
|
|
533
|
+
|
|
534
|
+
function initialsOf(name: string, email: string): string {
|
|
535
|
+
const parts = name.trim().split(/\\s+/).filter(Boolean);
|
|
536
|
+
if (parts.length >= 2) {
|
|
537
|
+
return \`\${parts[0]?.[0] ?? ""}\${parts[1]?.[0] ?? ""}\`.toUpperCase();
|
|
538
|
+
}
|
|
539
|
+
if (parts[0]?.[0]) return parts[0][0].toUpperCase();
|
|
540
|
+
return email.slice(0, 2).toUpperCase();
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export function SessionUser({ compact = false }: { compact?: boolean }) {
|
|
544
|
+
const { data } = authClient.useSession();
|
|
545
|
+
const user = data?.user;
|
|
546
|
+
if (!user) return null;
|
|
547
|
+
const name = user.name || user.email || "Account";
|
|
548
|
+
const email = user.email || "";
|
|
549
|
+
const initials = initialsOf(name, email);
|
|
550
|
+
const avatar = (
|
|
551
|
+
<Avatar className="size-8">
|
|
552
|
+
{user.image ? <AvatarImage src={user.image} alt={name} /> : null}
|
|
553
|
+
<AvatarFallback>{initials}</AvatarFallback>
|
|
554
|
+
</Avatar>
|
|
555
|
+
);
|
|
556
|
+
if (compact) return avatar;
|
|
557
|
+
return (
|
|
558
|
+
<div className="flex items-center gap-2 rounded-lg px-2 py-1.5">
|
|
559
|
+
{avatar}
|
|
560
|
+
<div className="flex min-w-0 flex-col">
|
|
561
|
+
<span className="truncate text-sm font-medium text-fg">{name}</span>
|
|
562
|
+
<span className="truncate text-xs text-fg-tertiary">{email}</span>
|
|
563
|
+
</div>
|
|
564
|
+
</div>
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
`;
|
|
568
|
+
}
|
|
569
|
+
function acceptInvitationPageSource(authClientImport) {
|
|
570
|
+
return `"use client";
|
|
571
|
+
|
|
572
|
+
import { Suspense, useEffect, useState } from "react";
|
|
573
|
+
import { useRouter, useSearchParams } from "next/navigation";
|
|
574
|
+
import { authClient } from ${JSON.stringify(authClientImport)};
|
|
575
|
+
|
|
576
|
+
function AcceptInvitation() {
|
|
577
|
+
const router = useRouter();
|
|
578
|
+
const params = useSearchParams();
|
|
579
|
+
const id = params.get("id") ?? params.get("invitation");
|
|
580
|
+
const { data: session, isPending } = authClient.useSession();
|
|
581
|
+
const [error, setError] = useState<string | null>(null);
|
|
582
|
+
|
|
583
|
+
useEffect(() => {
|
|
584
|
+
if (isPending) return;
|
|
585
|
+
if (!id) {
|
|
586
|
+
setError("Invitation is missing.");
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (!session) {
|
|
590
|
+
router.replace(\`/signup?invitation=\${encodeURIComponent(id)}\`);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
let cancelled = false;
|
|
594
|
+
void (async () => {
|
|
595
|
+
const { data, error: acceptError } = await authClient.organization.acceptInvitation({
|
|
596
|
+
invitationId: id,
|
|
597
|
+
});
|
|
598
|
+
if (cancelled) return;
|
|
599
|
+
if (acceptError) {
|
|
600
|
+
setError(acceptError.message || "Could not accept invitation.");
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const orgId = data?.invitation?.organizationId ?? data?.member?.organizationId;
|
|
604
|
+
if (orgId) {
|
|
605
|
+
await authClient.organization.setActive({ organizationId: orgId });
|
|
606
|
+
}
|
|
607
|
+
try {
|
|
608
|
+
sessionStorage.removeItem("cronus-invitation");
|
|
609
|
+
} catch {
|
|
610
|
+
// ignore
|
|
611
|
+
}
|
|
612
|
+
window.location.assign("/");
|
|
613
|
+
})();
|
|
614
|
+
return () => {
|
|
615
|
+
cancelled = true;
|
|
616
|
+
};
|
|
617
|
+
}, [id, isPending, router, session]);
|
|
618
|
+
|
|
619
|
+
return (
|
|
620
|
+
<main className="flex min-h-svh flex-col items-center justify-center px-6">
|
|
621
|
+
{error ? (
|
|
622
|
+
<p role="alert" className="text-sm text-error-strong">
|
|
623
|
+
{error}
|
|
624
|
+
</p>
|
|
625
|
+
) : (
|
|
626
|
+
<p className="text-sm text-fg-tertiary">Accepting invitation…</p>
|
|
627
|
+
)}
|
|
628
|
+
</main>
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export default function AcceptInvitationPage() {
|
|
633
|
+
return (
|
|
634
|
+
<Suspense
|
|
635
|
+
fallback={
|
|
636
|
+
<main className="flex min-h-svh flex-col items-center justify-center px-6">
|
|
637
|
+
<p className="text-sm text-fg-tertiary">Accepting invitation…</p>
|
|
638
|
+
</main>
|
|
639
|
+
}
|
|
640
|
+
>
|
|
641
|
+
<AcceptInvitation />
|
|
642
|
+
</Suspense>
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
`;
|
|
646
|
+
}
|
|
647
|
+
function insertImport(source, line) {
|
|
648
|
+
if (source.includes(line))
|
|
649
|
+
return source;
|
|
650
|
+
const firstImport = source.match(/^import .+$/m);
|
|
651
|
+
if (firstImport?.index !== undefined) {
|
|
652
|
+
return `${source.slice(0, firstImport.index)}${line}\n${source.slice(firstImport.index)}`;
|
|
653
|
+
}
|
|
654
|
+
return `${line}\n${source}`;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Wire the installed app-shell-chrome copy to live Better-Auth orgs + session.
|
|
658
|
+
* Idempotent: a chrome that already has WorkspaceMenu and SessionUser is left
|
|
659
|
+
* untouched. Returns undefined when the WorkspaceSwitcher / InviteDialog
|
|
660
|
+
* anchors are missing and nothing else can be patched.
|
|
661
|
+
*/
|
|
662
|
+
export function patchChromeSource(source, workspaceImport, inviteImport, sessionImport) {
|
|
663
|
+
const hasMenu = source.includes("WorkspaceMenu");
|
|
664
|
+
const hasSession = source.includes("SessionUser");
|
|
665
|
+
if (hasMenu && hasSession)
|
|
666
|
+
return source;
|
|
667
|
+
const canPatchMenu = !hasMenu &&
|
|
668
|
+
/<WorkspaceSwitcher[\s\S]*?\/>/.test(source) &&
|
|
669
|
+
/<InviteDialog[\s\S]*?\/>/.test(source);
|
|
670
|
+
const canPatchSession = sessionImport !== undefined &&
|
|
671
|
+
!hasSession &&
|
|
672
|
+
source.includes("{USER.email}") &&
|
|
673
|
+
source.includes("<SidebarFooter>");
|
|
674
|
+
if (!canPatchMenu && !canPatchSession) {
|
|
675
|
+
return hasMenu ? source : undefined;
|
|
676
|
+
}
|
|
677
|
+
let out = source;
|
|
678
|
+
if (canPatchMenu) {
|
|
679
|
+
out = insertImport(out, `import { WorkspaceMenu } from ${JSON.stringify(workspaceImport)};`);
|
|
680
|
+
out = insertImport(out, `import { InviteMember } from ${JSON.stringify(inviteImport)};`);
|
|
681
|
+
out = out.replace(/<WorkspaceSwitcher[\s\S]*?\/>/, "<WorkspaceMenu />");
|
|
682
|
+
out = out.replace(/<InviteDialog([\s\S]*?)\/>/, "<InviteMember$1/>");
|
|
683
|
+
out = out.replace(/\nconst WORKSPACES = \[[\s\S]*?\];\n/, "\n");
|
|
684
|
+
out = out.replace(/\s*const \[workspaceId, setWorkspaceId\] = useState\("[^"]*"\);\n/, "\n");
|
|
685
|
+
out = out.replace(/,\s*useState/, "");
|
|
686
|
+
out = out.replace(/\s*InviteDialog,\n/, "\n");
|
|
687
|
+
out = out.replace(/\s*WorkspaceSwitcher,\n/, "\n");
|
|
688
|
+
}
|
|
689
|
+
if (canPatchSession && sessionImport !== undefined) {
|
|
690
|
+
out = insertImport(out, `import { SessionUser } from ${JSON.stringify(sessionImport)};`);
|
|
691
|
+
out = out.replace(/<SidebarFooter>\s*<div className="flex items-center gap-2 rounded-lg px-2 py-1\.5">[\s\S]*?<\/SidebarFooter>/, "<SidebarFooter>\n <SessionUser />\n </SidebarFooter>");
|
|
692
|
+
out = out.replace(/<Avatar className="size-8">\s*\{OWNER\?\.avatar \? <AvatarImage src=\{OWNER\.avatar\} alt=\{USER\.name\} \/> : null\}\s*<AvatarFallback>\{USER\.initials\}<\/AvatarFallback>\s*<\/Avatar>/, "<SessionUser compact />");
|
|
693
|
+
out = out.replace(/\nimport \{ TEAM, USER \} from "[^"]+";\n/, "\n");
|
|
694
|
+
out = out.replace(/\nconst OWNER = TEAM\.find\(\(m\) => m\.email === USER\.email\);\n/, "\n");
|
|
695
|
+
out = out.replace(/\s*Avatar,\n/, "\n");
|
|
696
|
+
out = out.replace(/\s*AvatarFallback,\n/, "\n");
|
|
697
|
+
out = out.replace(/\s*AvatarImage,\n/, "\n");
|
|
698
|
+
}
|
|
699
|
+
return out;
|
|
700
|
+
}
|
|
701
|
+
function nextConfigSource() {
|
|
702
|
+
return `/** @type {import('next').NextConfig} */
|
|
703
|
+
const nextConfig = {
|
|
704
|
+
serverExternalPackages: ["better-sqlite3"],
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
export default nextConfig;
|
|
708
|
+
`;
|
|
709
|
+
}
|
|
710
|
+
/** Insert ItemsPanel into a generated home page. Returns undefined when there is no main. */
|
|
711
|
+
export function patchHomePageSource(source, itemsImport) {
|
|
712
|
+
if (!/<main\b/.test(source))
|
|
713
|
+
return undefined;
|
|
714
|
+
let out = source;
|
|
715
|
+
const importLine = `import { ItemsPanel } from ${JSON.stringify(itemsImport)};`;
|
|
716
|
+
if (!out.includes(importLine)) {
|
|
717
|
+
const match = out.match(/^import .+$/m);
|
|
718
|
+
if (match?.index !== undefined) {
|
|
719
|
+
out = `${out.slice(0, match.index)}${importLine}\n${out.slice(match.index)}`;
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
out = `${importLine}\n${out}`;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
out = out.replace(/export default(?! async) function/, "export default async function");
|
|
726
|
+
if (!/<ItemsPanel\s*\/>/.test(out)) {
|
|
727
|
+
out = out.replace(/(<main\b[^>]*>)/, "$1\n <ItemsPanel />");
|
|
728
|
+
}
|
|
729
|
+
return out;
|
|
730
|
+
}
|
|
731
|
+
function mergePackageJson(raw) {
|
|
732
|
+
const pkg = JSON.parse(raw);
|
|
733
|
+
const dependencies = { ...(pkg.dependencies ?? {}) };
|
|
734
|
+
const devDependencies = { ...(pkg.devDependencies ?? {}) };
|
|
735
|
+
const scripts = { ...(pkg.scripts ?? {}) };
|
|
736
|
+
for (const [name, range] of Object.entries(GOLD_PATH_PROD_DEPS)) {
|
|
737
|
+
dependencies[name] ??= range;
|
|
738
|
+
}
|
|
739
|
+
for (const [name, range] of Object.entries(GOLD_PATH_DEV_DEPS)) {
|
|
740
|
+
devDependencies[name] ??= range;
|
|
741
|
+
}
|
|
742
|
+
for (const [name, cmd] of Object.entries(GOLD_PATH_SCRIPTS)) {
|
|
743
|
+
scripts[name] ??= cmd;
|
|
744
|
+
}
|
|
745
|
+
pkg.dependencies = dependencies;
|
|
746
|
+
pkg.devDependencies = devDependencies;
|
|
747
|
+
pkg.scripts = scripts;
|
|
748
|
+
return `${JSON.stringify(pkg, null, 2)}\n`;
|
|
749
|
+
}
|
|
750
|
+
function mergeNextConfig(raw) {
|
|
751
|
+
if (raw.includes("better-sqlite3"))
|
|
752
|
+
return raw;
|
|
753
|
+
if (/serverExternalPackages:\s*\[/.test(raw)) {
|
|
754
|
+
return raw.replace(/serverExternalPackages:\s*\[/, 'serverExternalPackages: ["better-sqlite3", ');
|
|
755
|
+
}
|
|
756
|
+
const empty = raw.replace(/const nextConfig = \{\s*\}/, 'const nextConfig = {\n serverExternalPackages: ["better-sqlite3"],\n}');
|
|
757
|
+
if (empty !== raw)
|
|
758
|
+
return empty;
|
|
759
|
+
if (raw.includes("const nextConfig = {")) {
|
|
760
|
+
return raw.replace(/const nextConfig = \{/, 'const nextConfig = {\n serverExternalPackages: ["better-sqlite3"],');
|
|
761
|
+
}
|
|
762
|
+
return `${raw.trimEnd()}\n`;
|
|
763
|
+
}
|
|
764
|
+
function mergeEnvExample(raw) {
|
|
765
|
+
const lines = raw.split(/\r?\n/);
|
|
766
|
+
const keys = new Set(lines.map((line) => {
|
|
767
|
+
const eq = line.indexOf("=");
|
|
768
|
+
return eq === -1 ? line.trim() : line.slice(0, eq).trim();
|
|
769
|
+
}));
|
|
770
|
+
const extra = [];
|
|
771
|
+
for (const [key, value] of Object.entries(ENV_VARS)) {
|
|
772
|
+
if (!keys.has(key))
|
|
773
|
+
extra.push(`${key}=${value}`);
|
|
774
|
+
}
|
|
775
|
+
if (extra.length === 0)
|
|
776
|
+
return raw.endsWith("\n") ? raw : `${raw}\n`;
|
|
777
|
+
const base = raw.endsWith("\n") || raw.length === 0 ? raw : `${raw}\n`;
|
|
778
|
+
return `${base}${extra.join("\n")}\n`;
|
|
779
|
+
}
|
|
780
|
+
function mergeGitignore(raw) {
|
|
781
|
+
const lines = raw.split(/\r?\n/);
|
|
782
|
+
const have = new Set(lines.map((l) => l.trim()));
|
|
783
|
+
const extra = GITIGNORE_ENTRIES.filter((entry) => !have.has(entry));
|
|
784
|
+
if (extra.length === 0)
|
|
785
|
+
return raw.endsWith("\n") ? raw : `${raw}\n`;
|
|
786
|
+
const base = raw.endsWith("\n") || raw.length === 0 ? raw : `${raw}\n`;
|
|
787
|
+
return `${base}${extra.join("\n")}\n`;
|
|
788
|
+
}
|
|
789
|
+
async function writeRel(targetDir, rel, content, overwrite, always, written, skipped) {
|
|
790
|
+
const dest = resolveSafeDest(targetDir, ".", rel);
|
|
791
|
+
if (!always && existsSync(dest) && !overwrite) {
|
|
792
|
+
skipped.push(rel);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
await writeFileEnsured(dest, content);
|
|
796
|
+
written.push(rel);
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Write sqlite + Drizzle + Better-Auth files into a composed saas/admin app.
|
|
800
|
+
* Overwrites lib/auth-adapter.ts always (replaces the demo adapter). Patches
|
|
801
|
+
* the shell home page only when compose wrote it this run.
|
|
802
|
+
*/
|
|
803
|
+
export async function applyGoldPath(options) {
|
|
804
|
+
const { targetDir, config, generatedFiles, overwrite } = options;
|
|
805
|
+
const layout = goldPathLayout(config);
|
|
806
|
+
const appDir = resolveAppDir(targetDir, layout, generatedFiles);
|
|
807
|
+
const middlewareRel = appDir === "src/app"
|
|
808
|
+
? "src/middleware.ts"
|
|
809
|
+
: appDir === "app"
|
|
810
|
+
? "middleware.ts"
|
|
811
|
+
: layout.middlewareRel;
|
|
812
|
+
const authImport = `${config.aliases.lib}/auth`;
|
|
813
|
+
const authClientImport = `${config.aliases.lib}/auth-client`;
|
|
814
|
+
const itemsImport = "@/components/items-panel";
|
|
815
|
+
const workspaceImport = "@/components/workspace-menu";
|
|
816
|
+
const inviteImport = "@/components/invite-member";
|
|
817
|
+
const sessionImport = "@/components/session-user";
|
|
818
|
+
const chromeRel = `${posix(config.paths.blocks)}/app-shell-chrome.tsx`;
|
|
819
|
+
const written = [];
|
|
820
|
+
const skipped = [];
|
|
821
|
+
const files = [
|
|
822
|
+
{ rel: "drizzle.config.ts", content: drizzleConfigSource(layout.dbDir) },
|
|
823
|
+
{ rel: `${layout.dbDir}/schema.ts`, content: dbSchemaSource() },
|
|
824
|
+
{ rel: `${layout.dbDir}/index.ts`, content: dbClientSource() },
|
|
825
|
+
{ rel: `${layout.libDir}/auth.ts`, content: authServerSource() },
|
|
826
|
+
{ rel: `${layout.libDir}/auth-client.ts`, content: authClientSource() },
|
|
827
|
+
{ rel: `${layout.libDir}/auth-adapter.ts`, content: authAdapterSource(), always: true },
|
|
828
|
+
{
|
|
829
|
+
rel: `${appDir}/api/auth/[...all]/route.ts`,
|
|
830
|
+
content: authRouteSource(authImport),
|
|
831
|
+
},
|
|
832
|
+
{ rel: middlewareRel, content: middlewareSource() },
|
|
833
|
+
{ rel: `${layout.componentsDir}/items-panel.tsx`, content: itemsPanelSource(authImport) },
|
|
834
|
+
{
|
|
835
|
+
rel: `${layout.componentsDir}/workspace-menu.tsx`,
|
|
836
|
+
content: workspaceMenuSource(authClientImport),
|
|
837
|
+
always: true,
|
|
838
|
+
},
|
|
839
|
+
{
|
|
840
|
+
rel: `${layout.componentsDir}/invite-member.tsx`,
|
|
841
|
+
content: inviteMemberSource(authClientImport),
|
|
842
|
+
always: true,
|
|
843
|
+
},
|
|
844
|
+
{
|
|
845
|
+
rel: `${layout.componentsDir}/session-user.tsx`,
|
|
846
|
+
content: sessionUserSource(authClientImport),
|
|
847
|
+
always: true,
|
|
848
|
+
},
|
|
849
|
+
{
|
|
850
|
+
rel: `${appDir}/(bare)/accept-invitation/page.tsx`,
|
|
851
|
+
content: acceptInvitationPageSource(authClientImport),
|
|
852
|
+
always: true,
|
|
853
|
+
},
|
|
854
|
+
];
|
|
855
|
+
for (const file of files) {
|
|
856
|
+
await writeRel(targetDir, file.rel, file.content, overwrite, file.always === true, written, skipped);
|
|
857
|
+
}
|
|
858
|
+
const chromeDest = resolveSafeDest(targetDir, ".", chromeRel);
|
|
859
|
+
if (existsSync(chromeDest)) {
|
|
860
|
+
const current = await readFile(chromeDest, "utf8");
|
|
861
|
+
const patched = patchChromeSource(current, workspaceImport, inviteImport, sessionImport);
|
|
862
|
+
if (patched !== undefined && patched !== current) {
|
|
863
|
+
await writeFileEnsured(chromeDest, patched);
|
|
864
|
+
if (!written.includes(chromeRel))
|
|
865
|
+
written.push(chromeRel);
|
|
866
|
+
const templateName = options.templateName;
|
|
867
|
+
if (templateName !== undefined) {
|
|
868
|
+
const snapDest = resolveSafeDest(targetDir, baseSnapshotDir(templateName), chromeRel);
|
|
869
|
+
await writeFileEnsured(snapDest, patched);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
const homeRel = homePageRel(appDir, generatedFiles);
|
|
874
|
+
if (homeRel !== undefined) {
|
|
875
|
+
const dest = resolveSafeDest(targetDir, ".", homeRel);
|
|
876
|
+
if (existsSync(dest)) {
|
|
877
|
+
const current = await readFile(dest, "utf8");
|
|
878
|
+
const patched = patchHomePageSource(current, itemsImport);
|
|
879
|
+
if (patched !== undefined && patched !== current) {
|
|
880
|
+
await writeFileEnsured(dest, patched);
|
|
881
|
+
const templateName = options.templateName;
|
|
882
|
+
if (templateName !== undefined) {
|
|
883
|
+
const snapDest = resolveSafeDest(targetDir, baseSnapshotDir(templateName), homeRel);
|
|
884
|
+
await writeFileEnsured(snapDest, patched);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
await mergeTextFile(targetDir, "package.json", mergePackageJson);
|
|
890
|
+
await mergeOrCreate(targetDir, "next.config.mjs", nextConfigSource(), mergeNextConfig);
|
|
891
|
+
await mergeOrCreate(targetDir, ".env.example", `${Object.entries(ENV_VARS)
|
|
892
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
893
|
+
.join("\n")}\n`, mergeEnvExample);
|
|
894
|
+
await mergeOrCreate(targetDir, ".gitignore", `${GITIGNORE_ENTRIES.join("\n")}\n`, mergeGitignore);
|
|
895
|
+
return { written, skipped };
|
|
896
|
+
}
|
|
897
|
+
async function mergeOrCreate(targetDir, rel, created, merge) {
|
|
898
|
+
const dest = resolveSafeDest(targetDir, ".", rel);
|
|
899
|
+
if (!existsSync(dest)) {
|
|
900
|
+
await writeFileEnsured(dest, created);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
const raw = await readFile(dest, "utf8");
|
|
904
|
+
const next = merge(raw);
|
|
905
|
+
if (next !== raw)
|
|
906
|
+
await writeFileEnsured(dest, next);
|
|
907
|
+
}
|
|
908
|
+
async function mergeTextFile(targetDir, rel, merge) {
|
|
909
|
+
const dest = resolveSafeDest(targetDir, ".", rel);
|
|
910
|
+
if (!existsSync(dest))
|
|
911
|
+
return;
|
|
912
|
+
const raw = await readFile(dest, "utf8");
|
|
913
|
+
let next;
|
|
914
|
+
try {
|
|
915
|
+
next = merge(raw);
|
|
916
|
+
}
|
|
917
|
+
catch {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
if (next !== raw)
|
|
921
|
+
await writeFileEnsured(dest, next);
|
|
922
|
+
}
|
|
923
|
+
//# sourceMappingURL=gold-path.js.map
|
package/dist/config.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export declare const CONFIG_FILE = "cronus-ui.json";
|
|
2
|
-
export declare const CLI_VERSION = "0.6.
|
|
3
|
-
export declare const DEFAULT_REGISTRY = "https://raw.githubusercontent.com/pedrogbraz/cronus-ui/v0.6.
|
|
2
|
+
export declare const CLI_VERSION = "0.6.2";
|
|
3
|
+
export declare const DEFAULT_REGISTRY = "https://raw.githubusercontent.com/pedrogbraz/cronus-ui/v0.6.2/registry";
|
|
4
4
|
/** Manifest entry `add`/`upgrade` record per installed registry item. */
|
|
5
5
|
export interface InstalledRecord {
|
|
6
6
|
/** Registry release the files came from (git tag without the leading "v"). */
|
package/dist/config.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
export const CONFIG_FILE = "cronus-ui.json";
|
|
5
|
-
export const CLI_VERSION = "0.6.
|
|
5
|
+
export const CLI_VERSION = "0.6.2";
|
|
6
6
|
export const DEFAULT_REGISTRY = `https://raw.githubusercontent.com/pedrogbraz/cronus-ui/v${CLI_VERSION}/registry`;
|
|
7
7
|
export const DEFAULT_CONFIG = {
|
|
8
8
|
aliases: { ui: "@/components/ui", lib: "@/lib", blocks: "@/components/blocks" },
|
package/dist/utils.d.ts
CHANGED
|
@@ -60,6 +60,28 @@ export declare function assertValidDependency(dep: string): void;
|
|
|
60
60
|
* package-manager spawn — so an injected/malformed spec throws before install.
|
|
61
61
|
*/
|
|
62
62
|
export declare function collectDependencies(items: RegistryItem[]): string[];
|
|
63
|
+
/**
|
|
64
|
+
* Split a registry npm spec (`lucide-react@^0.577.0`, `@cronus-ui/ui@0.6.1`)
|
|
65
|
+
* into name + range. A bare name (no `@range`) leaves `range` undefined so we
|
|
66
|
+
* never write an empty pin into package.json.
|
|
67
|
+
*/
|
|
68
|
+
export declare function splitDependencySpec(spec: string): {
|
|
69
|
+
name: string;
|
|
70
|
+
range: string | undefined;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Merge versioned registry specs into a package.json document. Existing pins
|
|
74
|
+
* win (`??=`) so a scaffold range is not clobbered by a later compose. Specs
|
|
75
|
+
* without a range are skipped. Returns the original string when nothing changes
|
|
76
|
+
* so we do not reformat an untouched file.
|
|
77
|
+
*/
|
|
78
|
+
export declare function mergeDependencySpecsIntoPackageJson(raw: string, specs: string[]): string;
|
|
79
|
+
/**
|
|
80
|
+
* Persist registry npm pins into `<cwd>/package.json` even when install is
|
|
81
|
+
* skipped, so a later `bun install` / `npm install` picks up lucide-react,
|
|
82
|
+
* recharts, etc. No-ops when there is no package.json or nothing new to add.
|
|
83
|
+
*/
|
|
84
|
+
export declare function recordDependencies(cwd: string, specs: string[]): Promise<void>;
|
|
63
85
|
/** Levenshtein edit distance between two strings (small inputs: registry names). */
|
|
64
86
|
export declare function levenshtein(a: string, b: string): number;
|
|
65
87
|
/**
|
package/dist/utils.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
5
5
|
import pc from "picocolors";
|
|
6
6
|
export const log = {
|
|
@@ -141,6 +141,74 @@ export function collectDependencies(items) {
|
|
|
141
141
|
}
|
|
142
142
|
return [...set].sort();
|
|
143
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Split a registry npm spec (`lucide-react@^0.577.0`, `@cronus-ui/ui@0.6.1`)
|
|
146
|
+
* into name + range. A bare name (no `@range`) leaves `range` undefined so we
|
|
147
|
+
* never write an empty pin into package.json.
|
|
148
|
+
*/
|
|
149
|
+
export function splitDependencySpec(spec) {
|
|
150
|
+
if (spec.startsWith("@")) {
|
|
151
|
+
const at = spec.indexOf("@", 1);
|
|
152
|
+
if (at === -1)
|
|
153
|
+
return { name: spec, range: undefined };
|
|
154
|
+
const range = spec.slice(at + 1);
|
|
155
|
+
return { name: spec.slice(0, at), range: range.length > 0 ? range : undefined };
|
|
156
|
+
}
|
|
157
|
+
const at = spec.indexOf("@");
|
|
158
|
+
if (at <= 0)
|
|
159
|
+
return { name: spec, range: undefined };
|
|
160
|
+
const range = spec.slice(at + 1);
|
|
161
|
+
return { name: spec.slice(0, at), range: range.length > 0 ? range : undefined };
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Merge versioned registry specs into a package.json document. Existing pins
|
|
165
|
+
* win (`??=`) so a scaffold range is not clobbered by a later compose. Specs
|
|
166
|
+
* without a range are skipped. Returns the original string when nothing changes
|
|
167
|
+
* so we do not reformat an untouched file.
|
|
168
|
+
*/
|
|
169
|
+
export function mergeDependencySpecsIntoPackageJson(raw, specs) {
|
|
170
|
+
if (specs.length === 0)
|
|
171
|
+
return raw;
|
|
172
|
+
const pkg = JSON.parse(raw);
|
|
173
|
+
const dependencies = { ...(pkg.dependencies ?? {}) };
|
|
174
|
+
let changed = false;
|
|
175
|
+
for (const spec of specs) {
|
|
176
|
+
const { name, range } = splitDependencySpec(spec);
|
|
177
|
+
if (range === undefined || name.length === 0)
|
|
178
|
+
continue;
|
|
179
|
+
if (dependencies[name] === undefined) {
|
|
180
|
+
dependencies[name] = range;
|
|
181
|
+
changed = true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (!changed)
|
|
185
|
+
return raw;
|
|
186
|
+
pkg.dependencies = dependencies;
|
|
187
|
+
return `${JSON.stringify(pkg, null, 2)}\n`;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Persist registry npm pins into `<cwd>/package.json` even when install is
|
|
191
|
+
* skipped, so a later `bun install` / `npm install` picks up lucide-react,
|
|
192
|
+
* recharts, etc. No-ops when there is no package.json or nothing new to add.
|
|
193
|
+
*/
|
|
194
|
+
export async function recordDependencies(cwd, specs) {
|
|
195
|
+
if (specs.length === 0)
|
|
196
|
+
return;
|
|
197
|
+
const pkgPath = join(cwd, "package.json");
|
|
198
|
+
if (!existsSync(pkgPath))
|
|
199
|
+
return;
|
|
200
|
+
const raw = await readFile(pkgPath, "utf8");
|
|
201
|
+
let next;
|
|
202
|
+
try {
|
|
203
|
+
next = mergeDependencySpecsIntoPackageJson(raw, specs);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (next === raw)
|
|
209
|
+
return;
|
|
210
|
+
await writeFile(pkgPath, next, "utf8");
|
|
211
|
+
}
|
|
144
212
|
/** Levenshtein edit distance between two strings (small inputs: registry names). */
|
|
145
213
|
export function levenshtein(a, b) {
|
|
146
214
|
// Single rolling row of the DP matrix (row 0 = distances against an empty `a`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cronus-ui",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "cronus-ui — add Cronus UI components to your project, shadcn-style (copy-paste registry).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"registry:check": "bun run scripts/check-registry.ts"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@cronus-ui/ai-kit": "0.6.
|
|
57
|
+
"@cronus-ui/ai-kit": "0.6.2",
|
|
58
58
|
"commander": "^15.0.0",
|
|
59
59
|
"picocolors": "^1.1.1"
|
|
60
60
|
},
|