@skalfa/skalfa-cli 1.0.11 → 1.0.13

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.
@@ -63,22 +63,25 @@ program
63
63
  .command("init")
64
64
  .description("Initialize a new Skalfa monorepo project containing both API and App.")
65
65
  .argument("[name]", "project folder name")
66
- .action(async (name) => {
67
- await runCommand(() => (0, init_1.initProject)(name));
66
+ .option("-a, --auth <type>", "Authentication type: username or email")
67
+ .action(async (name, options) => {
68
+ await runCommand(() => (0, init_1.initProject)(name, options?.auth ? { authType: options.auth } : undefined));
68
69
  });
69
70
  program
70
71
  .command("create:api")
71
72
  .description("Create a new Skalfa API project.")
72
73
  .argument("<name>", "project folder and package name")
73
- .action(async (name) => {
74
- await runCommand(() => (0, create_api_1.createApi)(name));
74
+ .option("-a, --auth <type>", "Authentication type: username or email")
75
+ .action(async (name, options) => {
76
+ await runCommand(() => (0, create_api_1.createApi)(name, options.auth ? { authType: options.auth } : undefined));
75
77
  });
76
78
  program
77
79
  .command("create:app")
78
80
  .description("Create a new Skalfa App Next.js project.")
79
81
  .argument("<name>", "project folder and package name")
80
- .action(async (name) => {
81
- await runCommand(() => (0, create_app_1.createApp)(name));
82
+ .option("-a, --auth <type>", "Authentication type: username or email")
83
+ .action(async (name, options) => {
84
+ await runCommand(() => (0, create_app_1.createApp)(name, options.auth ? { authType: options.auth } : undefined));
82
85
  });
83
86
  program
84
87
  .command("add")
@@ -89,10 +92,21 @@ program
89
92
  });
90
93
  program
91
94
  .command("pick")
92
- .description("Eject/copy a core utility from @skalfa/skalfa-api-core into your local utils folder for customization.")
93
- .argument("<utility>", `utility name: ${pick_1.UTILITIES.join(", ")}`)
94
- .action(async (utility) => {
95
- await runCommand(() => (0, pick_1.pickUtility)(utility));
95
+ .description("Eject/copy a core utility or component into your local project for customization.")
96
+ .argument("<name>", "utility or component name")
97
+ .argument("[newName]", "new name for the component (optional, component only)")
98
+ .action(async (name, newName) => {
99
+ await runCommand(() => {
100
+ if (pick_1.UTILITIES.includes(name)) {
101
+ if (newName) {
102
+ throw new Error("Renaming is only supported for components, not utilities.");
103
+ }
104
+ (0, pick_1.pickUtility)(name);
105
+ }
106
+ else {
107
+ (0, pick_1.pickComponent)(name, newName);
108
+ }
109
+ });
96
110
  });
97
111
  program
98
112
  .command("update")
@@ -43,6 +43,75 @@ async function addExtension(extensionName) {
43
43
  (0, installer_1.installPackage)(projectRoot, isDev ? "file:../skalfa-idb" : "@skalfa/skalfa-idb");
44
44
  addTsconfigPath(node_path_1.default.join(projectRoot, "tsconfig.json"), "@skalfa/skalfa-idb");
45
45
  addUtilExport(node_path_1.default.join(projectRoot, "utils", "index.ts"), "@skalfa/skalfa-idb");
46
+ // Scaffold IDBProvider
47
+ console.log("Scaffolding IDBProvider...");
48
+ const providerDir = node_path_1.default.join(projectRoot, "components", "base.components", "wrap");
49
+ if (!node_fs_1.default.existsSync(providerDir)) {
50
+ node_fs_1.default.mkdirSync(providerDir, { recursive: true });
51
+ }
52
+ const providerPath = node_path_1.default.join(providerDir, "IDBProvider.tsx");
53
+ const providerContent = `"use client"
54
+
55
+ import { useEffect } from "react"
56
+ import { idb } from "@skalfa/skalfa-idb"
57
+ import { AppSchema } from "@schema"
58
+ import { registry } from "@utils"
59
+
60
+ export function IDBProvider({ children }: { children: React.ReactNode }) {
61
+ useEffect(() => {
62
+ idb.setDefaultSchema(AppSchema);
63
+ registry.register("idb", idb);
64
+ }, []);
65
+
66
+ return <>{children}</>
67
+ }
68
+ `;
69
+ node_fs_1.default.writeFileSync(providerPath, providerContent, "utf8");
70
+ console.log(`Created: ${providerPath}`);
71
+ // Scaffold app.schema.ts
72
+ console.log("Scaffolding AppSchema...");
73
+ const schemaDir = node_path_1.default.join(projectRoot, "schema", "idb");
74
+ if (!node_fs_1.default.existsSync(schemaDir)) {
75
+ node_fs_1.default.mkdirSync(schemaDir, { recursive: true });
76
+ }
77
+ const schemaPath = node_path_1.default.join(schemaDir, "app.schema.ts");
78
+ const schemaContent = `import { DBSchema } from "@skalfa/skalfa-idb"
79
+
80
+ const name = String(process.env.NEXT_PUBLIC_APP_NAME || "").toLowerCase().trim().replace(/[^\\w\\s-]/g, "").replace(/[\\s_-]+/g, "-").replace(/^-+|-+$/g, "") + ".idb-app";
81
+
82
+ export const AppSchema: DBSchema = {
83
+ name: name,
84
+ version: 1,
85
+ stores: {}
86
+ }
87
+ `;
88
+ node_fs_1.default.writeFileSync(schemaPath, schemaContent, "utf8");
89
+ console.log(`Created: ${schemaPath}`);
90
+ // Ensure schema/index.ts exists so the @schema alias works
91
+ const schemaIndexPath = node_path_1.default.join(projectRoot, "schema", "index.ts");
92
+ if (!node_fs_1.default.existsSync(schemaIndexPath)) {
93
+ node_fs_1.default.writeFileSync(schemaIndexPath, `export * from "./idb/app.schema";\n`, "utf8");
94
+ console.log(`Created: ${schemaIndexPath}`);
95
+ }
96
+ // Update app/layout.tsx
97
+ console.log("Updating app/layout.tsx...");
98
+ const layoutPath = node_path_1.default.join(projectRoot, "app", "layout.tsx");
99
+ if (node_fs_1.default.existsSync(layoutPath)) {
100
+ let layoutContent = node_fs_1.default.readFileSync(layoutPath, "utf8");
101
+ // 1. Add IDBProvider to import
102
+ if (layoutContent.includes('import { ShortcutProvider } from "@components";')) {
103
+ layoutContent = layoutContent.replace('import { ShortcutProvider } from "@components";', 'import { IDBProvider, ShortcutProvider } from "@components";');
104
+ }
105
+ else if (layoutContent.includes('import { ShortcutProvider } from "@components"')) {
106
+ layoutContent = layoutContent.replace('import { ShortcutProvider } from "@components"', 'import { IDBProvider, ShortcutProvider } from "@components"');
107
+ }
108
+ // 2. Wrap {children} with <IDBProvider>
109
+ if (layoutContent.includes("{children}") && !layoutContent.includes("<IDBProvider>")) {
110
+ layoutContent = layoutContent.replace(/\{\s*children\s*\}/, `<IDBProvider>\n {children}\n </IDBProvider>`);
111
+ }
112
+ node_fs_1.default.writeFileSync(layoutPath, layoutContent, "utf8");
113
+ console.log(`Updated: ${layoutPath}`);
114
+ }
46
115
  }
47
116
  else if (extensionName === "socket") {
48
117
  console.log("Installing Skalfa Socket.io client extension...");
@@ -67,6 +67,33 @@ async function installAgent(overrideType, targetProjectDir = process.cwd()) {
67
67
  console.log(`Created initial record: ${file}`);
68
68
  }
69
69
  }
70
+ if (type === "app") {
71
+ // Detect auth type from page.tsx or similar
72
+ const loginPagePath = node_path_1.default.join(targetProjectDir, "app", "auth", "login", "page.tsx");
73
+ let isEmail = true;
74
+ if (node_fs_1.default.existsSync(loginPagePath)) {
75
+ const loginContent = node_fs_1.default.readFileSync(loginPagePath, "utf8");
76
+ isEmail = loginContent.includes("email");
77
+ }
78
+ const updateFeaturesFile = (filePath) => {
79
+ if (node_fs_1.default.existsSync(filePath)) {
80
+ let content = node_fs_1.default.readFileSync(filePath, "utf8");
81
+ if (isEmail) {
82
+ content = content
83
+ .replace(/\|\s*`admin`\s*\|/g, "| `admin@mail.com` |")
84
+ .replace(/\|\s*`user`\s*\|/g, "| `user@mail.com` |");
85
+ }
86
+ else {
87
+ content = content
88
+ .replace(/\|\s*`admin`\s*\|/g, "| `admin` |")
89
+ .replace(/\|\s*`user`\s*\|/g, "| `user` |");
90
+ }
91
+ node_fs_1.default.writeFileSync(filePath, content, "utf8");
92
+ }
93
+ };
94
+ updateFeaturesFile(node_path_1.default.join(templatesDir, "features.md"));
95
+ updateFeaturesFile(node_path_1.default.join(recordsDir, "features.md"));
96
+ }
70
97
  }
71
98
  // 3. Add /.agents/ to project's .gitignore
72
99
  const gitignorePath = node_path_1.default.join(targetProjectDir, ".gitignore");
@@ -48,10 +48,13 @@ async function createApi(projectName, options) {
48
48
  let hasCron = options?.cron ?? false;
49
49
  let hasDa = options?.da ?? false;
50
50
  let hasSocket = options?.socket ?? false;
51
+ let authType = options?.authType ?? "username";
51
52
  if (!options) {
52
53
  // Ask interactive questions sequentially using a single readline interface
53
54
  const q = new Questioner();
54
55
  try {
56
+ const authChoice = (await q.ask("Choose authentication type (username/email) [default: username]: ", "username")).toLowerCase();
57
+ authType = authChoice === "email" ? "email" : "username";
55
58
  hasRedis = (await q.ask("Do you need Redis? (y/N): ", "No")).toLowerCase().startsWith("y");
56
59
  hasQueue = (await q.ask("Do you need Queue? (y/N): ", "No")).toLowerCase().startsWith("y");
57
60
  hasCache = (await q.ask("Do you need Cache? (y/N): ", "No")).toLowerCase().startsWith("y");
@@ -188,7 +191,8 @@ If you are an AI coding agent assisting with this project, please make sure to r
188
191
  cache: hasCache,
189
192
  cron: hasCron,
190
193
  da: hasDa,
191
- socket: hasSocket
194
+ socket: hasSocket,
195
+ authType: authType
192
196
  });
193
197
  spinner.update("Installing dependencies (this may take a moment)...");
194
198
  await (0, installer_1.installDependenciesAsync)(target);
@@ -221,35 +225,37 @@ function customizeProject(target, opts) {
221
225
  const pkg = JSON.parse(node_fs_1.default.readFileSync(packageJsonPath, "utf8"));
222
226
  pkg.dependencies = pkg.dependencies || {};
223
227
  pkg.scripts = pkg.scripts || {};
228
+ const isMonorepo = node_path_1.default.basename(target) === "api" || node_path_1.default.basename(target) === "app";
229
+ const devPathPrefix = isMonorepo ? "file:../../" : "file:../";
224
230
  // Base ORM integration (always included)
225
- pkg.dependencies["@skalfa/skalfa-orm"] = isDev ? "file:../skalfa-orm" : "^1.0.0";
231
+ pkg.dependencies["@skalfa/skalfa-orm"] = isDev ? `${devPathPrefix}skalfa-orm` : "^1.0.0";
226
232
  if (isDev) {
227
- pkg.dependencies["@skalfa/skalfa-api-core"] = "file:../skalfa-api-core";
233
+ pkg.dependencies["@skalfa/skalfa-api-core"] = `${devPathPrefix}skalfa-api-core`;
228
234
  }
229
235
  const devCommands = ["bun run --watch app/app.ts", "bun skalfa watch:barrels"];
230
236
  if (opts.redis) {
231
- pkg.dependencies["@skalfa/skalfa-redis"] = isDev ? "file:../skalfa-redis" : "^1.0.0";
237
+ pkg.dependencies["@skalfa/skalfa-redis"] = isDev ? `${devPathPrefix}skalfa-redis` : "^1.0.0";
232
238
  pkg.dependencies["ioredis"] = "^5.4.1";
233
239
  }
234
240
  if (opts.queue) {
235
- pkg.dependencies["@skalfa/skalfa-queue"] = isDev ? "file:../skalfa-queue" : "^1.0.0";
241
+ pkg.dependencies["@skalfa/skalfa-queue"] = isDev ? `${devPathPrefix}skalfa-queue` : "^1.0.0";
236
242
  pkg.scripts["start:queue"] = "bun run app/jobs/queues/worker.queue.ts";
237
243
  devCommands.push("bun start:queue");
238
244
  }
239
245
  if (opts.cache) {
240
- pkg.dependencies["@skalfa/skalfa-cache"] = isDev ? "file:../skalfa-cache" : "^1.0.0";
246
+ pkg.dependencies["@skalfa/skalfa-cache"] = isDev ? `${devPathPrefix}skalfa-cache` : "^1.0.0";
241
247
  }
242
248
  if (opts.cron) {
243
- pkg.dependencies["@skalfa/skalfa-cron"] = isDev ? "file:../skalfa-cron" : "^1.0.0";
249
+ pkg.dependencies["@skalfa/skalfa-cron"] = isDev ? `${devPathPrefix}skalfa-cron` : "^1.0.0";
244
250
  pkg.scripts["start:cron"] = "bun run app/jobs/crons/worker.cron.ts";
245
251
  devCommands.push("bun start:cron");
246
252
  }
247
253
  if (opts.da) {
248
- pkg.dependencies["@skalfa/skalfa-da"] = isDev ? "file:../skalfa-da" : "^1.0.0";
254
+ pkg.dependencies["@skalfa/skalfa-da"] = isDev ? `${devPathPrefix}skalfa-da` : "^1.0.0";
249
255
  pkg.dependencies["@clickhouse/client"] = "^1.6.0";
250
256
  }
251
257
  if (opts.socket) {
252
- pkg.dependencies["@skalfa/skalfa-socket"] = isDev ? "file:../skalfa-socket" : "^1.0.0";
258
+ pkg.dependencies["@skalfa/skalfa-socket"] = isDev ? `${devPathPrefix}skalfa-socket` : "^1.0.0";
253
259
  pkg.dependencies["socket.io"] = "^4.7.5";
254
260
  pkg.scripts["start:socket"] = "bun run app/jobs/sockets/worker.socket.ts";
255
261
  devCommands.push("bun start:socket");
@@ -349,6 +355,98 @@ function customizeProject(target, opts) {
349
355
  }
350
356
  node_fs_1.default.writeFileSync(appTsPath, content, "utf8");
351
357
  }
358
+ // 6. Handle authentication type customization
359
+ if (opts.authType === "username") {
360
+ // A. Modify migration: database/migrations/0000_00/users.ts
361
+ const migrationDir = node_path_1.default.join(target, "database", "migrations", "0000_00");
362
+ const migrationUsersPath = node_path_1.default.join(migrationDir, "users.ts");
363
+ if (node_fs_1.default.existsSync(migrationUsersPath)) {
364
+ let content = node_fs_1.default.readFileSync(migrationUsersPath, "utf8");
365
+ // Replace table.string("email").unique().notNullable() with table.string("username").unique().notNullable()
366
+ content = content.replace(/table\.string\("email"\)\.unique\(\)\.notNullable\(\)/g, 'table.string("username").unique().notNullable()');
367
+ // Remove table.timestamp("email_verification_at")
368
+ content = content.replace(/table\.timestamp\("email_verification_at"\)\r?\n?/g, '');
369
+ // Delete the user_mail_tokens table schema creation
370
+ content = content.replace(/await\s+knex\.schema\.createTable\("user_mail_tokens",\s*\((table|t)\)\s*=>\s*\{[\s\S]*?\}\)\r?\n?/g, '');
371
+ node_fs_1.default.writeFileSync(migrationUsersPath, content, "utf8");
372
+ }
373
+ // B. Modify model: app/models/iam/user.model.ts
374
+ const userModelPath = node_path_1.default.join(target, "app", "models", "iam", "user.model.ts");
375
+ if (node_fs_1.default.existsSync(userModelPath)) {
376
+ let content = node_fs_1.default.readFileSync(userModelPath, "utf8");
377
+ // Replace email field with username field
378
+ content = content.replace(/@Field\(\s*\[\s*"fillable"\s*,\s*"selectable"\s*,\s*"searchable"\s*\]\s*\)\s*\r?\n?\s*email!:\s*string/g, '@Field(["fillable", "selectable", "searchable"])\n username!: string');
379
+ // Remove email_verification_at field
380
+ content = content.replace(/@Field\(\s*\[\s*"fillable"\s*,\s*"selectable"\s*,\s*"searchable"\s*\]\s*\)\s*\r?\n?\s*email_verification_at!:\s*Date/g, '');
381
+ node_fs_1.default.writeFileSync(userModelPath, content, "utf8");
382
+ }
383
+ // C. Modify controller: app/controllers/iam/auth.controller.ts
384
+ const authControllerPath = node_path_1.default.join(target, "app", "controllers", "iam", "auth.controller.ts");
385
+ if (node_fs_1.default.existsSync(authControllerPath)) {
386
+ let content = node_fs_1.default.readFileSync(authControllerPath, "utf8");
387
+ // Remove import { UserMailToken } from "app/outputs/mails";
388
+ content = content.replace(/import\s*\{\s*UserMailToken\s*\}\s*from\s*["']app\/outputs\/mails["'];?\r?\n?/g, '');
389
+ // Update validation and logic in login
390
+ content = content.replace(/email\s*:\s*["']required["'],/g, 'username : "required",');
391
+ content = content.replace(/const\s*\{\s*email\s*,\s*password\s*\}\s*=\s*c\.body/g, 'const { username, password } = c.body');
392
+ content = content.replace(/const user\s*=\s*await\s*User\.query\(\)\.where\("email",\s*email\)\.whereNotNull\("email_verification_at"\)\.first\(\);/g, 'const user = await User.query().where("username", username).first();');
393
+ content = content.replace(/if\s*\(!user\)\s*return\s*c\.responseErrorValidation\(\{\s*email\s*:\s*\[\s*["']E-mail not found!["']\s*\]\s*\}\)/g, 'if (!user) return c.responseErrorValidation({username: ["Username not found!"]})');
394
+ // Remove register and verify methods
395
+ content = content.replace(/\/\/\s*=+\s*>\s*\/\/\s*## Register new account\.[\s\S]*?(?=\/\/\s*=+\s*>\s*\/\/\s*## Get logged account)/g, '');
396
+ // Replace update method validation: email: "required" -> username: "required"
397
+ content = content.replace(/email\s*:\s*["']required["'],/g, 'username : "required",');
398
+ node_fs_1.default.writeFileSync(authControllerPath, content, "utf8");
399
+ }
400
+ // D. Modify router: app/routes/base.routes.ts
401
+ const baseRoutesPath = node_path_1.default.join(target, "app", "routes", "base.routes.ts");
402
+ if (node_fs_1.default.existsSync(baseRoutesPath)) {
403
+ let content = node_fs_1.default.readFileSync(baseRoutesPath, "utf8");
404
+ // Comment out register and verify routes
405
+ content = content.replace(/route\.post\('\/register',\s*AuthController\.register\)/g, '// route.post(\'/register\', AuthController.register) - disabled in username auth');
406
+ content = content.replace(/route\.post\('\/verify',\s*AuthController\.verify\)/g, '// route.post(\'/verify\', AuthController.verify) - disabled in username auth');
407
+ node_fs_1.default.writeFileSync(baseRoutesPath, content, "utf8");
408
+ }
409
+ // E. Modify controller: app/controllers/iam/user.controller.ts
410
+ const userControllerPath = node_path_1.default.join(target, "app", "controllers", "iam", "user.controller.ts");
411
+ if (node_fs_1.default.existsSync(userControllerPath)) {
412
+ let content = node_fs_1.default.readFileSync(userControllerPath, "utf8");
413
+ // Replace validation in store: email : ["required", "email"], -> username : ["required"],
414
+ content = content.replace(/email\s*:\s*\[\s*["']required["']\s*,\s*["']email["']\s*\]/g, 'username : ["required"]');
415
+ // Replace validation in update: email : "required", -> username : "required",
416
+ content = content.replace(/email\s*:\s*["']required["']/g, 'username : "required"');
417
+ node_fs_1.default.writeFileSync(userControllerPath, content, "utf8");
418
+ }
419
+ // F. Modify seeder: database/seeders/user.seeder.ts
420
+ const userSeederPath = node_path_1.default.join(target, "database", "seeders", "user.seeder.ts");
421
+ if (node_fs_1.default.existsSync(userSeederPath)) {
422
+ let content = node_fs_1.default.readFileSync(userSeederPath, "utf8");
423
+ // Seed roles: Admin and User
424
+ content = content.replace(/\{"name": "Petugas"\}/g, '{"name": "User"}');
425
+ // Seed users with username instead of email
426
+ content = content.replace(/\{"name": "Admin", "email": "admin@skalfa.id",/g, '{"name": "Admin", "username": "admin",');
427
+ content = content.replace(/\{"name": "Petugas", "email": "petugas@skalfa.id",/g, '{"name": "User", "username": "user",');
428
+ node_fs_1.default.writeFileSync(userSeederPath, content, "utf8");
429
+ }
430
+ // G. Delete app/outputs/mails folder
431
+ const mailsDir = node_path_1.default.join(target, "app", "outputs", "mails");
432
+ if (node_fs_1.default.existsSync(mailsDir)) {
433
+ node_fs_1.default.rmSync(mailsDir, { recursive: true, force: true });
434
+ }
435
+ }
436
+ else {
437
+ // If authType === "email":
438
+ // Change seeder from petugas@skalfa.id to user@mail.com and admin@skalfa.id to admin@mail.com, role Petugas to User
439
+ const userSeederPath = node_path_1.default.join(target, "database", "seeders", "user.seeder.ts");
440
+ if (node_fs_1.default.existsSync(userSeederPath)) {
441
+ let content = node_fs_1.default.readFileSync(userSeederPath, "utf8");
442
+ content = content.replace(/\{"name": "Petugas"\}/g, '{"name": "User"}');
443
+ content = content.replace(/admin@skalfa.id/g, 'admin@mail.com');
444
+ content = content.replace(/\{"name": "Petugas", "email": "petugas@mail.com",/g, '{"name": "User", "email": "user@mail.com",');
445
+ content = content.replace(/\{"name": "Petugas", "email": "petugas@skalfa.id",/g, '{"name": "User", "email": "user@mail.com",');
446
+ content = content.replace(/petugas@skalfa.id/g, 'user@mail.com');
447
+ node_fs_1.default.writeFileSync(userSeederPath, content, "utf8");
448
+ }
449
+ }
352
450
  }
353
451
  function addTsconfigPath(tsconfigPath, packageName) {
354
452
  if (!node_fs_1.default.existsSync(tsconfigPath))
@@ -47,10 +47,13 @@ async function createApp(projectName, options) {
47
47
  let hasPwa = options?.pwa ?? false;
48
48
  let hasTauriDesktop = options?.tauriDesktop ?? false;
49
49
  let hasTauriMobile = options?.tauriMobile ?? false;
50
+ let authType = options?.authType ?? "username";
50
51
  if (!options) {
51
52
  // Ask interactive questions sequentially
52
53
  const q = new Questioner();
53
54
  try {
55
+ const authChoice = (await q.ask("Choose authentication type (username/email) [default: username]: ", "username")).toLowerCase();
56
+ authType = authChoice === "email" ? "email" : "username";
54
57
  hasIdb = (await q.ask("Do you need IndexedDB (IDB)? (y/N): ", "No")).toLowerCase().startsWith("y");
55
58
  hasSocket = (await q.ask("Do you need Socket Client? (y/N): ", "No")).toLowerCase().startsWith("y");
56
59
  hasDocument = (await q.ask("Do you need Document Export/Viewer (PDF/Excel)? (y/N): ", "No")).toLowerCase().startsWith("y");
@@ -174,7 +177,8 @@ If you are an AI coding agent assisting with this project, please make sure to r
174
177
  document: hasDocument,
175
178
  pwa: hasPwa,
176
179
  tauriDesktop: hasTauriDesktop,
177
- tauriMobile: hasTauriMobile
180
+ tauriMobile: hasTauriMobile,
181
+ authType: authType
178
182
  });
179
183
  spinner.update("Installing dependencies (this may take a moment)...");
180
184
  await (0, installer_1.installDependenciesAsync)(target);
@@ -200,6 +204,8 @@ function customizeProject(target, opts) {
200
204
  const packageJsonPath = node_path_1.default.join(target, "package.json");
201
205
  const baseComponentsIndexPath = node_path_1.default.join(target, "components", "base.components", "index.ts");
202
206
  const isDev = !!process.env[TEMPLATE_ENV_KEY];
207
+ const isMonorepo = node_path_1.default.basename(target) === "api" || node_path_1.default.basename(target) === "app";
208
+ const devPathPrefix = isMonorepo ? "file:../../" : "file:../";
203
209
  // 1. Update dependencies and scripts in package.json
204
210
  if (node_fs_1.default.existsSync(packageJsonPath)) {
205
211
  const pkg = JSON.parse(node_fs_1.default.readFileSync(packageJsonPath, "utf8"));
@@ -207,10 +213,13 @@ function customizeProject(target, opts) {
207
213
  pkg.devDependencies = pkg.devDependencies || {};
208
214
  pkg.scripts = pkg.scripts || {};
209
215
  // Core dependency
210
- pkg.dependencies["@skalfa/skalfa-app-core"] = isDev ? "file:../skalfa-app-core" : "^1.0.0";
216
+ pkg.dependencies["@skalfa/skalfa-app-core"] = isDev ? `${devPathPrefix}skalfa-app-core` : "^1.0.0";
217
+ if (isDev && pkg.dependencies["@skalfa/skalfa-component"]) {
218
+ pkg.dependencies["@skalfa/skalfa-component"] = `${devPathPrefix}skalfa-component`;
219
+ }
211
220
  // A. IndexedDB Option
212
221
  if (opts.idb) {
213
- pkg.dependencies["@skalfa/skalfa-idb"] = isDev ? "file:../skalfa-idb" : "^1.0.0";
222
+ pkg.dependencies["@skalfa/skalfa-idb"] = isDev ? `${devPathPrefix}skalfa-idb` : "^1.0.0";
214
223
  }
215
224
  else {
216
225
  // Delete schema directory
@@ -242,12 +251,12 @@ function customizeProject(target, opts) {
242
251
  }
243
252
  // B. Socket Option
244
253
  if (opts.socket) {
245
- pkg.dependencies["@skalfa/skalfa-socket-client"] = isDev ? "file:../skalfa-socket-client" : "^1.0.0";
254
+ pkg.dependencies["@skalfa/skalfa-socket-client"] = isDev ? `${devPathPrefix}skalfa-socket-client` : "^1.0.0";
246
255
  pkg.dependencies["socket.io-client"] = "^4.8.1";
247
256
  }
248
257
  // C. Document Option
249
258
  if (opts.document) {
250
- pkg.dependencies["@skalfa/skalfa-document"] = isDev ? "file:../skalfa-document" : "^1.0.0";
259
+ pkg.dependencies["@skalfa/skalfa-document"] = isDev ? `${devPathPrefix}skalfa-document` : "^1.0.0";
251
260
  pkg.dependencies["exceljs"] = "^4.4.0";
252
261
  pkg.dependencies["pdf-lib"] = "^1.17.1";
253
262
  pkg.dependencies["pdfjs-dist"] = "^4.4.168";
@@ -316,4 +325,54 @@ function customizeProject(target, opts) {
316
325
  }
317
326
  node_fs_1.default.writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2), "utf8");
318
327
  }
328
+ // 6. Handle authentication type customization
329
+ if (opts.authType === "username") {
330
+ // A. Modify app/auth/login/page.tsx
331
+ const loginPagePath = node_path_1.default.join(target, "app", "auth", "login", "page.tsx");
332
+ if (node_fs_1.default.existsSync(loginPagePath)) {
333
+ let content = node_fs_1.default.readFileSync(loginPagePath, "utf8");
334
+ // Replace email field with username field
335
+ content = content.replace(/\{\s*construction:\s*\{\s*name:\s*["']email["'],\s*label:\s*["']E-mail["'],\s*placeholder:\s*["']Ex:\s*example@mail\.com["'],\s*validations:\s*["']required\|min:10\|max:50\|email["']\s*\}\s*\}/g, `{\n construction: {\n name: "username",\n label: "Username",\n placeholder: "Ex: joko.gunawan",\n validations: "required|min:3|max:50"\n }\n }`);
336
+ // Remove Create Account link in login page
337
+ content = content.replace(/<p className="mt-4 text-center">Don&apos;t have an account yet\? <Link href="\/auth\/register" className="text-primary underline">Create Account<\/Link><\/p>/g, '');
338
+ node_fs_1.default.writeFileSync(loginPagePath, content, "utf8");
339
+ }
340
+ // B. Modify app/auth/edit/page.tsx
341
+ const editPagePath = node_path_1.default.join(target, "app", "auth", "edit", "page.tsx");
342
+ if (node_fs_1.default.existsSync(editPagePath)) {
343
+ let content = node_fs_1.default.readFileSync(editPagePath, "utf8");
344
+ // Replace email field with username field
345
+ content = content.replace(/\{\s*construction:\s*\{\s*name:\s*["']email["'],\s*label:\s*["']E-mail["'],\s*placeholder:\s*["']Ex:\s*example@mail\.com["'],\s*\}\s*\}/g, `{\n construction: {\n name: "username",\n label: "Username",\n placeholder: "Ex: joko.gunawan",\n }\n }`);
346
+ node_fs_1.default.writeFileSync(editPagePath, content, "utf8");
347
+ }
348
+ // C. Modify app/auth/me/page.tsx
349
+ const mePagePath = node_path_1.default.join(target, "app", "auth", "me", "page.tsx");
350
+ if (node_fs_1.default.existsSync(mePagePath)) {
351
+ let content = node_fs_1.default.readFileSync(mePagePath, "utf8");
352
+ // Replace email display with username
353
+ content = content.replace(/<div>\s*<p className="text-xs font-semibold text-light-foreground">\s*Email\s*<\/p>\s*<p>\{user\?\.email\}<\/p>\s*<\/div>/g, `<div>\n <p className="text-xs font-semibold text-light-foreground">\n Username\n </p>\n <p>{user?.username}</p>\n </div>`);
354
+ node_fs_1.default.writeFileSync(mePagePath, content, "utf8");
355
+ }
356
+ // D. Modify app/dashboard/user/page.tsx
357
+ const userPagePath = node_path_1.default.join(target, "app", "dashboard", "user", "page.tsx");
358
+ if (node_fs_1.default.existsSync(userPagePath)) {
359
+ let content = node_fs_1.default.readFileSync(userPagePath, "utf8");
360
+ // Replace table column for email
361
+ content = content.replace(/\{\s*selector:\s*["']email["'],\s*label:\s*["']Email["'],\s*sortable:\s*true,\s*width:\s*["']250px["'],\s*\}/g, `{\n selector: "username",\n label: "Username",\n sortable: true,\n width: "250px",\n }`);
362
+ // Replace detail panel for email
363
+ content = content.replace(/\{\s*label:\s*["']Email["'],\s*item:\s*["']email["'],\s*\}/g, `{\n label: "Username",\n item: "username",\n }`);
364
+ // Replace form field for email
365
+ content = content.replace(/\{\s*construction:\s*\{\s*name:\s*["']email["'],\s*label:\s*["']E-mail["'],\s*placeholder:\s*["']Ex:\s*example@mail\.com["'],\s*validations:\s*\[\s*["']required["']\s*\]\s*,\s*\}\s*,?\s*\}/g, `{\n construction: {\n name: "username",\n label: "Username",\n placeholder: "Ex: joko.gunawan",\n validations: ["required"],\n },\n }`);
366
+ node_fs_1.default.writeFileSync(userPagePath, content, "utf8");
367
+ }
368
+ // E. Delete app/auth/register and app/auth/verify folders (self-registration and verification disabled/removed)
369
+ const registerDir = node_path_1.default.join(target, "app", "auth", "register");
370
+ if (node_fs_1.default.existsSync(registerDir)) {
371
+ node_fs_1.default.rmSync(registerDir, { recursive: true, force: true });
372
+ }
373
+ const verifyDir = node_path_1.default.join(target, "app", "auth", "verify");
374
+ if (node_fs_1.default.existsSync(verifyDir)) {
375
+ node_fs_1.default.rmSync(verifyDir, { recursive: true, force: true });
376
+ }
377
+ }
319
378
  }
@@ -30,7 +30,7 @@ class Questioner {
30
30
  this.rl.close();
31
31
  }
32
32
  }
33
- async function initProject(projectName) {
33
+ async function initProject(projectName, options) {
34
34
  const cwd = process.cwd();
35
35
  const isCurrentDir = !projectName || projectName === ".";
36
36
  const target = isCurrentDir ? cwd : node_path_1.default.resolve(cwd, projectName);
@@ -62,7 +62,14 @@ async function initProject(projectName) {
62
62
  let appPwa = false;
63
63
  let appTauriDesktop = false;
64
64
  let appTauriMobile = false;
65
+ let authType = options?.authType ?? "username";
66
+ const shouldPromptAuth = !options || !options.authType;
65
67
  try {
68
+ if (shouldPromptAuth) {
69
+ console.log("\n--- Configure Authentication ---");
70
+ const authChoice = (await q.ask("Choose authentication type (username/email) [default: username]: ", "username")).toLowerCase();
71
+ authType = authChoice === "email" ? "email" : "username";
72
+ }
66
73
  console.log("\n--- Configure API (Backend) ---");
67
74
  apiRedis = (await q.ask("Do you need Redis? (y/N): ", "No")).toLowerCase().startsWith("y");
68
75
  apiQueue = (await q.ask("Do you need Queue? (y/N): ", "No")).toLowerCase().startsWith("y");
@@ -93,6 +100,7 @@ async function initProject(projectName) {
93
100
  cron: apiCron,
94
101
  da: apiDa,
95
102
  socket: apiSocket,
103
+ authType: authType,
96
104
  });
97
105
  console.log("\nCreating App...");
98
106
  await (0, create_app_1.createApp)(appDir, {
@@ -102,6 +110,7 @@ async function initProject(projectName) {
102
110
  pwa: appPwa,
103
111
  tauriDesktop: appTauriDesktop,
104
112
  tauriMobile: appTauriMobile,
113
+ authType: authType,
105
114
  });
106
115
  console.log("\nInstalling AI Agents...");
107
116
  try {
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.UTILITIES = exports.UTILITY_EXPORTS = void 0;
7
7
  exports.pickUtility = pickUtility;
8
+ exports.pickComponent = pickComponent;
8
9
  const node_fs_1 = __importDefault(require("node:fs"));
9
10
  const node_path_1 = __importDefault(require("node:path"));
10
11
  const fs_1 = require("../utils/fs");
@@ -38,7 +39,6 @@ function pickUtility(utilityName) {
38
39
  if (!(0, fs_1.exists)(utilsDir) || !(0, fs_1.exists)(indexPath)) {
39
40
  throw new Error("Folder utils or utils/index.ts not found. Make sure you are at the project root.");
40
41
  }
41
- // 1. Tentukan path source dari node_modules dan target di lokal proyek
42
42
  const corePackagePath = node_path_1.default.join(projectRoot, "node_modules", "@skalfa", "skalfa-api-core");
43
43
  const sourceDir = node_path_1.default.join(corePackagePath, "src", utilityName);
44
44
  const targetDir = node_path_1.default.join(utilsDir, utilityName);
@@ -48,7 +48,6 @@ function pickUtility(utilityName) {
48
48
  if ((0, fs_1.exists)(targetDir)) {
49
49
  throw new Error(`Utility folder "${utilityName}" is already present in your local utils folder.`);
50
50
  }
51
- // 2. Salin folder dari node_modules ke lokal proyek secara rekursif
52
51
  console.log(`Copying ${utilityName} folder from @skalfa/skalfa-api-core to utils/ ...`);
53
52
  node_fs_1.default.cpSync(sourceDir, targetDir, { recursive: true });
54
53
  const filesToDelete = ["CONTRIBUTING.md", "LICENSE"];
@@ -59,7 +58,6 @@ function pickUtility(utilityName) {
59
58
  }
60
59
  }
61
60
  console.log(`✓ Copied ${utilityName} folder`);
62
- // 3. Perbarui utils/index.ts untuk mereferensikan folder lokal
63
61
  console.log("Updating utils/index.ts with explicit local export override ...");
64
62
  let indexContent = node_fs_1.default.readFileSync(indexPath, "utf8").trim();
65
63
  const localExportLine = `export { ${utilitySymbols.join(", ")} } from "./${utilityName}";`;
@@ -73,3 +71,133 @@ function pickUtility(utilityName) {
73
71
  }
74
72
  console.log(`\nSuccess! You can now customize files under: utils/${utilityName}/`);
75
73
  }
74
+ function findComponentFolder(srcDir, componentName) {
75
+ if (!node_fs_1.default.existsSync(srcDir))
76
+ return null;
77
+ const folders = node_fs_1.default.readdirSync(srcDir);
78
+ for (const folder of folders) {
79
+ const folderPath = node_path_1.default.join(srcDir, folder);
80
+ if (!node_fs_1.default.statSync(folderPath).isDirectory())
81
+ continue;
82
+ const files = node_fs_1.default.readdirSync(folderPath);
83
+ for (const file of files) {
84
+ if (!file.endsWith(".tsx") && !file.endsWith(".ts"))
85
+ continue;
86
+ const filePath = node_path_1.default.join(folderPath, file);
87
+ const content = node_fs_1.default.readFileSync(filePath, "utf8");
88
+ // Match the component name or the component name with "Component" suffix (e.g. Button or ButtonComponent)
89
+ const regex = new RegExp(`export\\s+(async\\s+)?(function|const|class|type|interface)\\s+\\b(${componentName}|${componentName}Component)\\b`);
90
+ if (regex.test(content)) {
91
+ return { folderName: folder, fileName: file };
92
+ }
93
+ }
94
+ }
95
+ return null;
96
+ }
97
+ function pickComponent(componentName, newName) {
98
+ const projectRoot = (0, fs_1.findProjectRoot)(process.cwd());
99
+ if (!projectRoot) {
100
+ throw new Error("No package.json found. Run this command inside a Skalfa project.");
101
+ }
102
+ // 1. Locate the component source in @skalfa/skalfa-component
103
+ let componentSrcDir = node_path_1.default.join(projectRoot, "node_modules", "@skalfa", "skalfa-component", "src");
104
+ if (!node_fs_1.default.existsSync(componentSrcDir)) {
105
+ componentSrcDir = node_path_1.default.resolve(projectRoot, "..", "skalfa-component", "src");
106
+ }
107
+ // Clean the componentName if it has "Component" suffix for searching
108
+ const cleanComponentName = componentName.endsWith("Component") ? componentName.replace(/Component$/, "") : componentName;
109
+ const match = findComponentFolder(componentSrcDir, cleanComponentName);
110
+ if (!match) {
111
+ throw new Error(`Component "${componentName}" not found in @skalfa/skalfa-component.`);
112
+ }
113
+ const { folderName, fileName } = match;
114
+ const sourceFolder = node_path_1.default.join(componentSrcDir, folderName);
115
+ const sourceFilePath = node_path_1.default.join(sourceFolder, fileName);
116
+ // Extract base name of the component file (e.g. "Button" from "Button.component.tsx")
117
+ const fileBaseName = fileName.replace(/\.(component)?\.(tsx|ts)$/, "");
118
+ // Find all exported symbols in the file that start with or match the file base name
119
+ const sourceContent = node_fs_1.default.readFileSync(sourceFilePath, "utf8");
120
+ const exportRegex = new RegExp(`export\\s+(async\\s+)?(function|const|class|type|interface)\\s+\\b(${fileBaseName}\\w*)\\b`, "g");
121
+ const exportedTypes = [];
122
+ const exportedValues = [];
123
+ let exportMatch;
124
+ while ((exportMatch = exportRegex.exec(sourceContent)) !== null) {
125
+ const keyword = exportMatch[2];
126
+ const symbol = exportMatch[3];
127
+ if (keyword === "type" || keyword === "interface") {
128
+ exportedTypes.push(symbol);
129
+ }
130
+ else {
131
+ exportedValues.push(symbol);
132
+ }
133
+ }
134
+ const componentsDir = node_path_1.default.join(projectRoot, "components");
135
+ const indexPath = node_path_1.default.join(componentsDir, "index.ts");
136
+ if (!node_fs_1.default.existsSync(componentsDir)) {
137
+ node_fs_1.default.mkdirSync(componentsDir, { recursive: true });
138
+ }
139
+ // Ensure index.ts exists
140
+ if (!node_fs_1.default.existsSync(indexPath)) {
141
+ node_fs_1.default.writeFileSync(indexPath, `export * from "@skalfa/skalfa-component";\n`, "utf8");
142
+ }
143
+ // Determine target names
144
+ const targetFolderName = newName ? newName.charAt(0).toLowerCase() + newName.slice(1) : folderName;
145
+ const targetFolder = node_path_1.default.join(componentsDir, targetFolderName);
146
+ if (node_fs_1.default.existsSync(targetFolder)) {
147
+ throw new Error(`Component folder "${targetFolderName}" already exists in components/.`);
148
+ }
149
+ console.log(`Copying component "${cleanComponentName}" from ${sourceFolder} to components/${targetFolderName} ...`);
150
+ node_fs_1.default.cpSync(sourceFolder, targetFolder, { recursive: true });
151
+ let finalFileBaseName = fileBaseName;
152
+ let finalExportedTypes = [...exportedTypes];
153
+ let finalExportedValues = [...exportedValues];
154
+ // If renaming is requested, perform renaming of files and contents
155
+ if (newName) {
156
+ const cleanNewName = newName.endsWith("Component") ? newName.replace(/Component$/, "") : newName;
157
+ finalFileBaseName = cleanNewName;
158
+ finalExportedTypes = exportedTypes.map(sym => sym.replace(new RegExp(fileBaseName, "g"), cleanNewName));
159
+ finalExportedValues = exportedValues.map(sym => sym.replace(new RegExp(fileBaseName, "g"), cleanNewName));
160
+ const files = node_fs_1.default.readdirSync(targetFolder);
161
+ for (const file of files) {
162
+ const filePath = node_path_1.default.join(targetFolder, file);
163
+ if (node_fs_1.default.statSync(filePath).isDirectory())
164
+ continue;
165
+ // Read file content and replace component names
166
+ let content = node_fs_1.default.readFileSync(filePath, "utf8");
167
+ // Replace fileBaseName (e.g. Button) with cleanNewName (e.g. MyButton)
168
+ const nameRegex = new RegExp(fileBaseName, "g");
169
+ content = content.replace(nameRegex, cleanNewName);
170
+ node_fs_1.default.writeFileSync(filePath, content, "utf8");
171
+ // Rename the file if it contains the original file base name
172
+ if (file.includes(fileBaseName)) {
173
+ const newFile = file.replace(fileBaseName, cleanNewName);
174
+ node_fs_1.default.renameSync(filePath, node_path_1.default.join(targetFolder, newFile));
175
+ }
176
+ }
177
+ }
178
+ // Update components/index.ts to export the local component explicitly
179
+ let indexContent = node_fs_1.default.readFileSync(indexPath, "utf8");
180
+ // Find the exact filename of the main component file inside the target folder
181
+ const targetFiles = node_fs_1.default.readdirSync(targetFolder);
182
+ const componentFile = targetFiles.find(f => f.includes(finalFileBaseName) && (f.endsWith(".tsx") || f.endsWith(".ts")));
183
+ if (componentFile) {
184
+ const componentBaseFile = componentFile.replace(/\.(tsx|ts)$/, "");
185
+ const valueExport = finalExportedValues.length > 0
186
+ ? `export { ${finalExportedValues.join(", ")} } from "./${targetFolderName}/${componentBaseFile}";`
187
+ : "";
188
+ const typeExport = finalExportedTypes.length > 0
189
+ ? `export type { ${finalExportedTypes.join(", ")} } from "./${targetFolderName}/${componentBaseFile}";`
190
+ : "";
191
+ const localExportLine = [valueExport, typeExport].filter(Boolean).join("\n");
192
+ if (!indexContent.includes(`./${targetFolderName}/`)) {
193
+ indexContent = indexContent.trim() + `\n${localExportLine}\n`;
194
+ node_fs_1.default.writeFileSync(indexPath, indexContent, "utf8");
195
+ console.log(`✓ Updated components/index.ts with local export for: ${[...finalExportedValues, ...finalExportedTypes].join(", ")}`);
196
+ }
197
+ else {
198
+ console.log(`⚠️ Info: Local export for "${targetFolderName}" already exists in components/index.ts`);
199
+ }
200
+ }
201
+ const finalSymbols = [...finalExportedValues, ...finalExportedTypes];
202
+ console.log(`\nSuccess! Component "${finalSymbols.find((s) => !s.endsWith("Props")) || finalSymbols[0]}" is now available locally at components/${targetFolderName}/`);
203
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skalfa/skalfa-cli",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "description": "Command Line Interface tool for scaffolding Skalfa projects, managing extensions, and ejecting core utilities.",
5
5
  "main": "dist/bin/skalfa.js",
6
6
  "bin": {