millas 0.2.13 → 0.2.15

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.
Files changed (88) hide show
  1. package/package.json +6 -3
  2. package/src/admin/Admin.js +107 -1027
  3. package/src/admin/AdminAuth.js +1 -1
  4. package/src/admin/ViewContext.js +1 -1
  5. package/src/admin/handlers/ActionHandler.js +103 -0
  6. package/src/admin/handlers/ApiHandler.js +113 -0
  7. package/src/admin/handlers/AuthHandler.js +76 -0
  8. package/src/admin/handlers/ExportHandler.js +70 -0
  9. package/src/admin/handlers/InlineHandler.js +71 -0
  10. package/src/admin/handlers/PageHandler.js +351 -0
  11. package/src/admin/resources/AdminResource.js +22 -1
  12. package/src/admin/static/SelectFilter2.js +34 -0
  13. package/src/admin/static/actions.js +201 -0
  14. package/src/admin/static/admin.css +7 -0
  15. package/src/admin/static/change_form.js +585 -0
  16. package/src/admin/static/core.js +128 -0
  17. package/src/admin/static/login.js +76 -0
  18. package/src/admin/static/vendor/bi/bootstrap-icons.min.css +5 -0
  19. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff +0 -0
  20. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff2 +0 -0
  21. package/src/admin/static/vendor/jquery.min.js +2 -0
  22. package/src/admin/views/layouts/base.njk +30 -113
  23. package/src/admin/views/pages/detail.njk +10 -9
  24. package/src/admin/views/pages/form.njk +4 -4
  25. package/src/admin/views/pages/list.njk +11 -193
  26. package/src/admin/views/pages/login.njk +19 -64
  27. package/src/admin/views/partials/form-field.njk +1 -1
  28. package/src/admin/views/partials/form-scripts.njk +4 -478
  29. package/src/admin/views/partials/form-widget.njk +10 -10
  30. package/src/ai/AITokenBudget.js +1 -1
  31. package/src/auth/Auth.js +112 -3
  32. package/src/auth/AuthMiddleware.js +18 -15
  33. package/src/auth/Hasher.js +15 -43
  34. package/src/cli.js +3 -0
  35. package/src/commands/call.js +190 -0
  36. package/src/commands/createsuperuser.js +3 -4
  37. package/src/commands/key.js +97 -0
  38. package/src/commands/make.js +16 -2
  39. package/src/commands/new.js +16 -1
  40. package/src/commands/serve.js +5 -5
  41. package/src/console/Command.js +337 -0
  42. package/src/console/CommandLoader.js +165 -0
  43. package/src/console/index.js +6 -0
  44. package/src/container/AppInitializer.js +48 -1
  45. package/src/container/Application.js +3 -1
  46. package/src/container/HttpServer.js +0 -1
  47. package/src/container/MillasConfig.js +48 -0
  48. package/src/controller/Controller.js +13 -11
  49. package/src/core/docs.js +6 -0
  50. package/src/core/foundation.js +8 -0
  51. package/src/core/http.js +20 -10
  52. package/src/core/validation.js +58 -27
  53. package/src/docs/Docs.js +268 -0
  54. package/src/docs/DocsServiceProvider.js +80 -0
  55. package/src/docs/SchemaInferrer.js +131 -0
  56. package/src/docs/handlers/ApiHandler.js +305 -0
  57. package/src/docs/handlers/PageHandler.js +47 -0
  58. package/src/docs/index.js +13 -0
  59. package/src/docs/resources/ApiResource.js +402 -0
  60. package/src/docs/static/docs.css +723 -0
  61. package/src/docs/static/docs.js +1181 -0
  62. package/src/encryption/Encrypter.js +381 -0
  63. package/src/facades/Auth.js +5 -2
  64. package/src/facades/Crypt.js +166 -0
  65. package/src/facades/Docs.js +43 -0
  66. package/src/facades/Mail.js +1 -1
  67. package/src/http/MillasRequest.js +7 -31
  68. package/src/http/RequestContext.js +11 -7
  69. package/src/http/SecurityBootstrap.js +24 -2
  70. package/src/http/Shape.js +168 -0
  71. package/src/http/adapters/ExpressAdapter.js +9 -5
  72. package/src/middleware/CorsMiddleware.js +3 -0
  73. package/src/middleware/ThrottleMiddleware.js +10 -7
  74. package/src/orm/model/Model.js +20 -2
  75. package/src/providers/EncryptionServiceProvider.js +66 -0
  76. package/src/router/MiddlewareRegistry.js +79 -54
  77. package/src/router/Route.js +9 -4
  78. package/src/router/RouteEntry.js +91 -0
  79. package/src/router/Router.js +71 -1
  80. package/src/scaffold/maker.js +138 -1
  81. package/src/scaffold/templates.js +12 -0
  82. package/src/serializer/Serializer.js +239 -0
  83. package/src/support/Str.js +1080 -0
  84. package/src/validation/BaseValidator.js +45 -5
  85. package/src/validation/Validator.js +67 -61
  86. package/src/validation/types.js +490 -0
  87. package/src/middleware/AuthMiddleware.js +0 -46
  88. package/src/middleware/MiddlewareRegistry.js +0 -106
package/src/auth/Auth.js CHANGED
@@ -19,23 +19,35 @@ const HttpError = require('../errors/HttpError');
19
19
  * password: 'secret123',
20
20
  * });
21
21
  *
22
- * // Login
22
+ * // Login — throws 401 on failure
23
23
  * const { user, token } = await Auth.login('alice@example.com', 'secret123');
24
24
  *
25
+ * // Attempt — returns user | null (Laravel/Rails style, never throws)
26
+ * const user = await Auth.attempt(email, password);
27
+ * if (!user) return this.unauthorized('Invalid credentials');
28
+ *
25
29
  * // Verify a token
26
30
  * const payload = Auth.verify(token);
27
31
  *
28
32
  * // Get logged-in user from a request
29
33
  * const user = await Auth.user(req);
30
34
  *
35
+ * // Revoke a token (adds to in-memory denylist — resets on restart)
36
+ * await Auth.revokeToken(user, token);
37
+ *
31
38
  * // Check password
32
39
  * const ok = await Auth.checkPassword('plain', user.password);
33
40
  */
34
41
  class Auth {
35
42
  constructor() {
36
- this._jwt = null;
37
- this._config = null;
43
+ this._jwt = null;
44
+ this._config = null;
38
45
  this._UserModel = null;
46
+ // In-memory token denylist for revokeToken().
47
+ // Resets on server restart — sufficient for short-lived JWTs.
48
+ // For persistent revocation across restarts/processes, set a cache
49
+ // store via Auth.configure({ revocationStore: redisClient }).
50
+ this._revokedTokens = new Set();
39
51
  }
40
52
 
41
53
  // ─── Configuration ───────────────────────────────────────────────────────
@@ -124,6 +136,41 @@ class Auth {
124
136
  return { user, token, refreshToken };
125
137
  }
126
138
 
139
+ /**
140
+ * Attempt to authenticate with email + password.
141
+ *
142
+ * Laravel / Rails style — returns the user on success, null on failure.
143
+ * Never throws for bad credentials; throws only for unexpected errors.
144
+ *
145
+ * The caller is responsible for status checks (banned, inactive, etc.)
146
+ * because attempt() intentionally skips them — it only validates identity.
147
+ *
148
+ * const user = await Auth.attempt(email, password);
149
+ * if (!user) return this.unauthorized('Invalid email or password.');
150
+ * if (user.status === 'banned') return this.forbidden('Account suspended.');
151
+ * const token = Auth.issueToken(user);
152
+ *
153
+ * @param {string} email
154
+ * @param {string} password
155
+ * @returns {Promise<object|null>} user instance or null
156
+ */
157
+ async attempt(email, password) {
158
+ this._requireUserModel();
159
+
160
+ const user = await this._UserModel.findBy('email', email);
161
+ if (!user) return null;
162
+
163
+ const ok = await Hasher.check(password, user.password);
164
+ if (!ok) return null;
165
+
166
+ // Record last login (fire-and-forget)
167
+ try {
168
+ await this._UserModel.where('id', user.id).update({ last_login: new Date().toISOString() });
169
+ } catch { /* non-fatal */ }
170
+
171
+ return user;
172
+ }
173
+
127
174
  /**
128
175
  * Verify and decode a token string.
129
176
  * Throws 401 if expired or invalid.
@@ -176,6 +223,68 @@ class Auth {
176
223
  return u;
177
224
  }
178
225
 
226
+ /**
227
+ * Revoke a token so it cannot be used again.
228
+ *
229
+ * Adds the token's unique identifier (jti / sub+iat) to an in-memory denylist.
230
+ * The denylist resets on server restart — acceptable for short-lived JWTs (≤7d).
231
+ *
232
+ * For persistence across restarts or multi-process deployments, configure a
233
+ * cache store (Redis) in config/auth.js:
234
+ *
235
+ * guards: { jwt: { revocationStore: redisClient } }
236
+ *
237
+ * The token string can be passed directly, or extracted from the request
238
+ * by AuthMiddleware and attached to req.token / ctx.token.
239
+ *
240
+ * // In a logout controller:
241
+ * await Auth.revokeToken(user, ctx.token);
242
+ *
243
+ * @param {object} user — the authenticated user (used to scope the key)
244
+ * @param {string} token — the raw JWT string to revoke
245
+ */
246
+ async revokeToken(user, token) {
247
+ if (!token) return;
248
+
249
+ // Decode without verifying — we want to revoke even if it's expiring soon
250
+ const payload = this._jwt.decode(token);
251
+ if (!payload) return;
252
+
253
+ // Use jti if present, otherwise fall back to sub+iat which is unique per-issue
254
+ const key = payload.jti || `${payload.sub || user?.id}:${payload.iat}`;
255
+ this._revokedTokens.add(key);
256
+
257
+ // Prune stale entries occasionally to prevent unbounded growth
258
+ if (this._revokedTokens.size > 10000) {
259
+ this._pruneExpiredRevocations();
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Check whether a token has been revoked.
265
+ * Called automatically by AuthMiddleware on every authenticated request.
266
+ *
267
+ * @param {string} token — raw JWT string
268
+ * @returns {boolean}
269
+ */
270
+ isRevoked(token) {
271
+ if (!token || this._revokedTokens.size === 0) return false;
272
+ const payload = this._jwt.decode(token);
273
+ if (!payload) return false;
274
+ const key = payload.jti || `${payload.sub}:${payload.iat}`;
275
+ return this._revokedTokens.has(key);
276
+ }
277
+
278
+ _pruneExpiredRevocations() {
279
+ // No expiry metadata stored — just trim the oldest half as a safety valve.
280
+ // In production use a Redis TTL-based store instead.
281
+ const entries = [...this._revokedTokens];
282
+ const half = Math.floor(entries.length / 2);
283
+ for (let i = 0; i < half; i++) {
284
+ this._revokedTokens.delete(entries[i]);
285
+ }
286
+ }
287
+
179
288
  /**
180
289
  * Hash a plain-text password.
181
290
  */
@@ -11,40 +11,43 @@ const Auth = require('./Auth');
11
11
  * Reads the Bearer token from the Authorization header,
12
12
  * verifies it, loads the user, and attaches them to req.user.
13
13
  *
14
- * Uses the Millas middleware signature: handle(req, next)
14
+ * Uses the Millas middleware signature: handle(ctx, next)
15
15
  * No Express res — returns a MillasResponse or calls next().
16
16
  */
17
17
  class AuthMiddleware extends Middleware {
18
- async handle({ headers, req }, next) {
19
- const header = headers.authorization || headers.Authorization;
20
-
18
+ async handle({ req, headers }, next) {
19
+ const header = headers?.authorization || headers?.Authorization;
20
+ //
21
21
  if (!header) {
22
22
  throw new HttpError(401, 'Authorization header missing');
23
23
  }
24
-
24
+ //
25
25
  if (!header.startsWith('Bearer ')) {
26
26
  throw new HttpError(401, 'Invalid authorization format. Use: Bearer <token>');
27
27
  }
28
-
28
+ //
29
29
  const token = header.slice(7);
30
30
  if (!token) {
31
31
  throw new HttpError(401, 'Token is empty');
32
32
  }
33
-
33
+ //
34
+ if (Auth.isRevoked(token)) {
35
+ throw new HttpError(401, 'Token has been revoked');
36
+ }
37
+ //
34
38
  const payload = Auth.verify(token);
35
-
39
+ //
36
40
  let user;
37
41
  try {
38
- user = await Auth.user(req.raw);
39
- } catch {
40
- throw new HttpError(401, 'Authentication service not configured');
42
+ user = await Auth.user(req);
43
+ } catch (err) {
44
+ throw new HttpError(401, 'Authentication failed: ' + err.message);
41
45
  }
42
-
46
+ //
43
47
  if (!user) {
44
48
  throw new HttpError(401, 'User not found or has been deleted');
45
49
  }
46
-
47
- // Attach to the underlying request so downstream handlers see req.user
50
+ //
48
51
  req.raw.user = user;
49
52
  req.raw.token = token;
50
53
  req.raw.tokenPayload = payload;
@@ -53,4 +56,4 @@ class AuthMiddleware extends Middleware {
53
56
  }
54
57
  }
55
58
 
56
- module.exports = AuthMiddleware;
59
+ module.exports = AuthMiddleware;
@@ -1,51 +1,23 @@
1
1
  'use strict';
2
2
 
3
- const bcrypt = require('bcryptjs');
4
-
5
3
  /**
6
4
  * Hasher
7
5
  *
8
- * Wraps bcrypt for password hashing and verification.
6
+ * Thin re-export of the canonical Hash singleton from hashing/Hash.js.
7
+ *
8
+ * All password hashing in the framework — Auth.register(), Auth.login(),
9
+ * Auth.attempt(), AdminAuth.login(), createsuperuser CLI — flows through
10
+ * this single file, which delegates to HashManager (hashing/Hash.js).
11
+ *
12
+ * This means one algorithm, one rounds config, one place to change either.
13
+ *
14
+ * Developers who need hashing directly should prefer the Hash facade:
15
+ * const { Hash } = require('millas/facades/Hash');
9
16
  *
10
- * Usage:
11
- * const hash = await Hasher.make('my-password');
12
- * const ok = await Hasher.check('my-password', hash);
17
+ * Internal framework code uses this file so it works before the container
18
+ * is booted (CLI commands, early bootstrap).
13
19
  */
14
- class Hasher {
15
- constructor(rounds = 12) {
16
- this.rounds = rounds;
17
- }
18
-
19
- /**
20
- * Hash a plain-text password.
21
- * @param {string} plain
22
- * @returns {Promise<string>}
23
- */
24
- async make(plain) {
25
- return bcrypt.hash(String(plain), this.rounds);
26
- }
27
-
28
- /**
29
- * Verify a plain-text password against a hash.
30
- * @param {string} plain
31
- * @param {string} hash
32
- * @returns {Promise<boolean>}
33
- */
34
- async check(plain, hash) {
35
- if (!plain || !hash) return false;
36
- return bcrypt.compare(String(plain), String(hash));
37
- }
38
-
39
- /**
40
- * Determine if a value needs to be re-hashed
41
- * (e.g. rounds have changed).
42
- */
43
- needsRehash(hash) {
44
- const rounds = bcrypt.getRounds(hash);
45
- return rounds !== this.rounds;
46
- }
47
- }
20
+ const Hash = require('../hashing/Hash');
48
21
 
49
- // Singleton with default rounds
50
- module.exports = new Hasher(12);
51
- module.exports.Hasher = Hasher;
22
+ module.exports = Hash;
23
+ module.exports.Hasher = Hash.constructor;
package/src/cli.js CHANGED
@@ -5,6 +5,7 @@ const chalk = require('chalk');
5
5
  const program = new Command();
6
6
 
7
7
  program
8
+ .enablePositionalOptions()
8
9
  .name('millas')
9
10
  .description(chalk.cyan('⚡ Millas — A modern batteries-included Node.js framework'))
10
11
  .version('0.2.12-beta-1');
@@ -18,6 +19,8 @@ require('./commands/route')(program);
18
19
  require('./commands/queue')(program);
19
20
  require('./commands/createsuperuser')(program);
20
21
  require('./commands/lang')(program);
22
+ require('./commands/key')(program);
23
+ require('./commands/call')(program);
21
24
 
22
25
  // Unknown command handler
23
26
  program.on('command:*', ([cmd]) => {
@@ -0,0 +1,190 @@
1
+ 'use strict';
2
+
3
+ const chalk = require('chalk');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+
7
+ /**
8
+ * `millas call <signature> [args] [options]`
9
+ *
10
+ * Discovers all commands in app/commands/, then either:
11
+ * - Runs the matched command directly, OR
12
+ * - Lists all available commands when no signature is given
13
+ *
14
+ * Each command in app/commands/ must extend Command and have a static `signature`.
15
+ *
16
+ * ── Examples ─────────────────────────────────────────────────────────────────
17
+ *
18
+ * millas call — list all custom commands
19
+ * millas call email:digest — run with defaults
20
+ * millas call email:digest weekly — positional arg
21
+ * millas call email:digest --dry-run — flag
22
+ */
23
+ module.exports = function (program) {
24
+ const commandsDir = path.resolve(process.cwd(), 'app/commands');
25
+ const CommandLoader = require('../console/CommandLoader');
26
+
27
+ // ── Bootstrap helpers ──────────────────────────────────────────────────────
28
+ async function bootstrapApp() {
29
+ const bootstrapPath = path.join(process.cwd(), 'bootstrap/app.js');
30
+ if (!fs.existsSync(bootstrapPath)) return;
31
+
32
+ require('dotenv').config({ path: path.resolve(process.cwd(), '.env') });
33
+
34
+ const basePath = process.cwd();
35
+ const AppServiceProvider = require(path.join(basePath, 'providers/AppServiceProvider'));
36
+ const AppInitializer = require('../container/AppInitializer');
37
+
38
+ const config = {
39
+ basePath,
40
+ providers: [AppServiceProvider],
41
+ routes: null,
42
+ middleware: [],
43
+ logging: true,
44
+ database: true,
45
+ auth: true,
46
+ cache: true,
47
+ storage: true,
48
+ mail: true,
49
+ queue: true,
50
+ events: true,
51
+ admin: null,
52
+ docs: null,
53
+ adapterMiddleware: [],
54
+ };
55
+
56
+ const initializer = new AppInitializer(config);
57
+ await initializer.bootKernel();
58
+ }
59
+
60
+ async function closeDb() {
61
+ try {
62
+ const DatabaseManager = require('../orm/drivers/DatabaseManager');
63
+ await DatabaseManager.closeAll();
64
+ } catch {}
65
+ }
66
+
67
+ // ── millas list ────────────────────────────────────────────────────────────
68
+ program
69
+ .command('list')
70
+ .description('List all available custom commands')
71
+ .action(() => {
72
+ if (!fs.existsSync(commandsDir)) {
73
+ console.log(chalk.yellow('\n No app/commands/ directory found.\n'));
74
+ console.log(chalk.dim(' Run: millas make:command <Name> to create your first command.\n'));
75
+ return;
76
+ }
77
+
78
+ const loader = new CommandLoader(commandsDir);
79
+ loader.load();
80
+ const sigs = loader.signatures();
81
+
82
+ if (sigs.length === 0) {
83
+ console.log(chalk.yellow('\n No custom commands found in app/commands/.\n'));
84
+ console.log(chalk.dim(' Run: millas make:command <Name> to create one.\n'));
85
+ return;
86
+ }
87
+
88
+ const maxLen = Math.max(...sigs.map(s => s.length));
89
+
90
+ console.log(chalk.bold('\n Available commands:\n'));
91
+ for (const [sig, CommandClass] of loader._commands) {
92
+ const desc = CommandClass.description || '';
93
+ console.log(
94
+ ' ' + chalk.cyan(sig.padEnd(maxLen + 2)) +
95
+ chalk.dim(desc)
96
+ );
97
+ }
98
+ console.log();
99
+ });
100
+
101
+ // ── millas call <signature> ────────────────────────────────────────────────
102
+ // Uses Commander's allowUnknownOption + passThroughOptions so we can
103
+ // forward all args/options to the matched command.
104
+ const callCmd = program
105
+ .command('call <signature> [args...]')
106
+ .description('Run a custom command from app/commands/')
107
+ .allowUnknownOption(true)
108
+ .passThroughOptions(true)
109
+ .action((signature, extraArgs, options, cmd) => {
110
+ if (!fs.existsSync(commandsDir)) {
111
+ console.error(chalk.red('\n ✖ No app/commands/ directory found.\n'));
112
+ console.error(chalk.dim(' Run: millas make:command <Name> to create your first command.\n'));
113
+ process.exit(1);
114
+ }
115
+
116
+ const loader = new CommandLoader(commandsDir);
117
+ loader.load();
118
+
119
+ const CommandClass = loader._commands.get(signature);
120
+
121
+ if (!CommandClass) {
122
+ const sigs = loader.signatures();
123
+ console.error(chalk.red(`\n ✖ Unknown command: ${chalk.bold(signature)}\n`));
124
+ if (sigs.length) {
125
+ console.log(chalk.dim(' Available commands:'));
126
+ for (const s of sigs) console.log(chalk.dim(` ${s}`));
127
+ } else {
128
+ console.log(chalk.dim(' No custom commands found. Run: millas make:command <Name>'));
129
+ }
130
+ console.log();
131
+ process.exit(1);
132
+ }
133
+
134
+ // Re-parse args + options against the command's own definition
135
+ const { Command: CommanderCmd } = require('commander');
136
+ const sub = new CommanderCmd(signature);
137
+
138
+ const argsDef = CommandClass.args || [];
139
+ const optsDef = CommandClass.options || [];
140
+
141
+ for (const a of argsDef) {
142
+ sub.argument(
143
+ a.default !== undefined ? `[${a.name}]` : `<${a.name}>`,
144
+ a.description || '',
145
+ a.default
146
+ );
147
+ }
148
+ for (const o of optsDef) {
149
+ if (o.default !== undefined) {
150
+ sub.option(o.flag, o.description || '', o.default);
151
+ } else {
152
+ sub.option(o.flag, o.description || '');
153
+ }
154
+ }
155
+
156
+ // Combine extraArgs back with the raw unknown options Commander captured
157
+ const rawArgs = [...(extraArgs || []), ...(cmd.args || [])];
158
+
159
+ // Re-deduplicate (Commander may put some into cmd.args already)
160
+ const allRaw = [...new Set([...extraArgs, ...rawArgs])];
161
+
162
+ sub.parse([process.execPath, signature, ...allRaw]);
163
+
164
+ const parsedOpts = sub.opts();
165
+ const parsedPosArgs = sub.args;
166
+
167
+ const argMap = {};
168
+ for (let i = 0; i < argsDef.length; i++) {
169
+ argMap[argsDef[i].name] = parsedPosArgs[i] !== undefined
170
+ ? parsedPosArgs[i]
171
+ : argsDef[i].default;
172
+ }
173
+
174
+ const instance = new CommandClass();
175
+ instance._hydrate(argMap, parsedOpts);
176
+
177
+ // Prevent HTTP server from starting during CLI commands
178
+ process.env.MILLAS_CLI_MODE = '1';
179
+
180
+ Promise.resolve()
181
+ .then(() => bootstrapApp())
182
+ .then(() => instance.handle())
183
+ .catch(err => {
184
+ console.error(chalk.red(`\n ✖ ${signature} failed: ${err.message}\n`));
185
+ if (process.env.DEBUG) console.error(err.stack);
186
+ process.exit(1);
187
+ })
188
+ .finally(() => closeDb());
189
+ });
190
+ };
@@ -4,6 +4,7 @@ const chalk = require('chalk');
4
4
  const path = require('path');
5
5
  const fs = require('fs-extra');
6
6
  const readline = require('readline');
7
+ const Hasher = require('../auth/Hasher');
7
8
 
8
9
  const Log = require("../logger/internal")
9
10
 
@@ -65,8 +66,7 @@ module.exports = function (program) {
65
66
 
66
67
  // ── Create via Auth.register path but with staff flags ───
67
68
  // Hash manually so we can pass the flags in the same create() call.
68
- const Hasher = require('../auth/Hasher');
69
- const hash = await Hasher.make(plainPassword);
69
+ const hash = await Hasher.make(plainPassword);
70
70
 
71
71
  await User.create({
72
72
  email,
@@ -109,8 +109,7 @@ module.exports = function (program) {
109
109
  if (plain !== confirm) throw new Error('Passwords do not match.');
110
110
  validatePassword(plain);
111
111
 
112
- const Hasher = require('../auth/Hasher');
113
- const hash = await Hasher.make(plain);
112
+ const hash = await Hasher.make(plain);
114
113
 
115
114
  await User.where('id', user.id).update({
116
115
  password: hash,
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ const chalk = require('chalk');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ /**
8
+ * `millas key:generate`
9
+ *
10
+ * Generates a cryptographically random APP_KEY and writes it into the
11
+ * project's .env file — exactly like Laravel's `php artisan key:generate`.
12
+ *
13
+ * ── Behaviour ────────────────────────────────────────────────────────────────
14
+ *
15
+ * - Reads the .env file in the current working directory
16
+ * - Replaces (or appends) the APP_KEY= line with the new key
17
+ * - Prints the key to stdout
18
+ * - Use --show to print the key without writing to .env
19
+ * - Use --force to overwrite an existing non-empty APP_KEY without prompting
20
+ *
21
+ * ── Examples ─────────────────────────────────────────────────────────────────
22
+ *
23
+ * millas key:generate — generate and write to .env
24
+ * millas key:generate --show — print only, don't write
25
+ * millas key:generate --force — overwrite existing key without prompt
26
+ * millas key:generate --cipher AES-128-CBC
27
+ */
28
+ module.exports = function (program) {
29
+ program
30
+ .command('key:generate')
31
+ .description('Generate a new application key and write it to .env')
32
+ .option('--show', 'Print the key without writing to .env')
33
+ .option('--force', 'Overwrite existing APP_KEY without confirmation')
34
+ .option('--cipher <cipher>', 'Cipher to use (default: AES-256-CBC)', 'AES-256-CBC')
35
+ .action(async (options) => {
36
+ const { Encrypter } = require('../encryption/Encrypter');
37
+
38
+ // Generate the key
39
+ let key;
40
+ try {
41
+ key = Encrypter.generateKey(options.cipher);
42
+ } catch (err) {
43
+ process.stderr.write(chalk.red(`\n ✖ ${err.message}\n\n`));
44
+ process.exit(1);
45
+ }
46
+
47
+ // --show: just print, don't touch .env
48
+ if (options.show) {
49
+ console.log('\n ' + chalk.cyan(key) + '\n');
50
+ return;
51
+ }
52
+
53
+ const envPath = path.resolve(process.cwd(), '.env');
54
+
55
+ if (!fs.existsSync(envPath)) {
56
+ process.stderr.write(chalk.red('\n ✖ .env file not found.\n'));
57
+ process.stderr.write(chalk.dim(' Run: millas new <project> or create a .env file first.\n\n'));
58
+ process.exit(1);
59
+ }
60
+
61
+ let envContent = fs.readFileSync(envPath, 'utf8');
62
+
63
+ // Check if APP_KEY already has a value
64
+ const existing = envContent.match(/^APP_KEY=(.+)$/m);
65
+ const hasValue = existing && existing[1] && existing[1].trim() !== '';
66
+
67
+ if (hasValue && !options.force) {
68
+ // Prompt for confirmation
69
+ const readline = require('readline');
70
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
71
+ const answer = await new Promise(resolve => {
72
+ rl.question(
73
+ chalk.yellow('\n ⚠ APP_KEY already set. Overwrite? (y/N) '),
74
+ ans => { rl.close(); resolve(ans); }
75
+ );
76
+ });
77
+ if ((answer || '').trim().toLowerCase() !== 'y') {
78
+ console.log(chalk.dim('\n Key not changed.\n'));
79
+ return;
80
+ }
81
+ }
82
+
83
+ // Write the key into .env
84
+ if (/^APP_KEY=/m.test(envContent)) {
85
+ // Replace existing line
86
+ envContent = envContent.replace(/^APP_KEY=.*$/m, `APP_KEY=${key}`);
87
+ } else {
88
+ // Append if APP_KEY line is missing entirely
89
+ envContent += `\nAPP_KEY=${key}\n`;
90
+ }
91
+
92
+ fs.writeFileSync(envPath, envContent, 'utf8');
93
+
94
+ console.log(chalk.green('\n ✔ Application key set.\n'));
95
+ console.log(' ' + chalk.dim('APP_KEY=') + chalk.cyan(key) + '\n');
96
+ });
97
+ };
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const chalk = require('chalk');
4
- const { makeController, makeModel, makeMiddleware, makeService, makeJob, makeMigration } = require('../scaffold/maker');
4
+ const { makeController, makeModel, makeMiddleware, makeService, makeJob, makeMigration, makeShape, makeCommand } = require('../scaffold/maker');
5
5
 
6
6
  module.exports = function (program) {
7
7
 
@@ -48,6 +48,20 @@ module.exports = function (program) {
48
48
  .action(async (name) => {
49
49
  await run('Migration', () => makeMigration(name));
50
50
  });
51
+
52
+ program
53
+ .command('make:shape <n>')
54
+ .description('Generate a shape file with Create/Update contracts (app/shapes/)')
55
+ .action(async (name) => {
56
+ await run('Shape', () => makeShape(name));
57
+ });
58
+ program
59
+ .command('make:command <n>')
60
+ .description('Generate a new custom console command in app/commands/')
61
+ .action(async (name) => {
62
+ await run('Command', () => makeCommand(name));
63
+ });
64
+
51
65
  };
52
66
 
53
67
  async function run(type, fn) {
@@ -58,4 +72,4 @@ async function run(type, fn) {
58
72
  console.error(chalk.red(`\n ✖ Failed to create ${type}: ${err.message}\n`));
59
73
  process.exit(1);
60
74
  }
61
- }
75
+ }
@@ -36,6 +36,21 @@ module.exports = function (program) {
36
36
  installSpinner.succeed(chalk.green(' Dependencies installed!'));
37
37
  }
38
38
 
39
+ // Auto-generate APP_KEY into the new project's .env
40
+ try {
41
+ const { Encrypter } = require('../encryption/Encrypter');
42
+ const envPath = path.join(targetDir, '.env');
43
+ const key = Encrypter.generateKey('AES-256-CBC');
44
+ if (fs.existsSync(envPath)) {
45
+ let envContent = fs.readFileSync(envPath, 'utf8');
46
+ envContent = /^APP_KEY=/m.test(envContent)
47
+ ? envContent.replace(/^APP_KEY=.*$/m, `APP_KEY=${key}`)
48
+ : envContent + `\nAPP_KEY=${key}\n`;
49
+ fs.writeFileSync(envPath, envContent, 'utf8');
50
+ console.log(chalk.green(' ✔ Application key generated.'));
51
+ }
52
+ } catch { /* non-fatal — developer can run millas key:generate */ }
53
+
39
54
  console.log();
40
55
  console.log(chalk.bold(' Next steps:'));
41
56
  console.log(chalk.cyan(` cd ${projectName}`));
@@ -47,4 +62,4 @@ module.exports = function (program) {
47
62
  process.exit(1);
48
63
  }
49
64
  });
50
- };
65
+ };