@friggframework/devtools 2.0.0--canary.657.e372ef6.0 → 2.0.0--canary.639.1315452.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,29 +10,59 @@ The Frigg CLI provides tools for building, deploying, and managing serverless in
10
10
 
11
11
  ### Core Commands
12
12
 
13
- #### `frigg init [options]`
13
+ #### `frigg init [projectName] [options]`
14
14
 
15
- **Status:** To be documented (command may not be merged yet)
16
-
17
- Initialize a new Frigg application with scaffolding and configuration.
15
+ Scaffold a new Frigg backend application that installs and runs out of the box.
18
16
 
19
17
  **Usage:**
18
+
20
19
  ```bash
21
- frigg init
22
- frigg init my-app
23
- frigg init --template typescript
20
+ frigg init my-app # interactive (when stdin is a TTY)
21
+ frigg init my-app --yes # accept every default, no prompts
22
+ frigg init my-app --yes --mode standalone --no-install --no-git
23
+ frigg init my-app --mode embedded # add to an existing project (./frigg-integration)
24
+ npx @friggframework/devtools init my-app --yes # without a global install
24
25
  ```
25
26
 
26
27
  **What it does:**
27
- - TBD - Full documentation pending implementation merge
28
+
29
+ 1. Validates the project name and `--mode`, and checks that the target directory is
30
+ empty (only `.git`, `.gitignore`, `README.md` and `.DS_Store` may exist) before asking anything else
31
+ 2. Copies the backend template: `index.js` (app definition, named after the project),
32
+ `infrastructure.js`, `README.md`, `.gitignore`, `.env.example` (also seeded into `.env`)
33
+ 3. Writes `package.json` with:
34
+ - scripts: `start` (`frigg start`), `build` (`frigg build`), `deploy` (`frigg deploy`),
35
+ `db:setup` (`frigg db:setup`), `test` (`jest`)
36
+ - dependencies: `@friggframework/core`, `@prisma/client`, `prisma`
37
+ - devDependencies: `@friggframework/devtools`, `@friggframework/serverless-plugin`,
38
+ `osls`, and the serverless plugins the composed definition loads
39
+ - Frigg package versions match the devtools that ran `init` (a prerelease is pinned exactly)
40
+ 4. Validates the generated app definition against the app-definition schema
41
+ 5. Optionally initializes git and runs `npm install`
42
+ 6. Prints next steps. API modules picked in the interactive prompt are not wired in
43
+ automatically; the next steps list a `frigg install <module>` command for each
44
+
45
+ In `embedded` mode the backend is written to `./frigg-integration` inside the target
46
+ directory, which may already contain your application. Git is not initialized.
28
47
 
29
48
  **Options:**
30
- - TBD
31
49
 
32
- **Example Output:**
33
- - TBD
50
+ - `-n, --name <name>` - Project name (alternative to the positional argument)
51
+ - `-m, --mode <mode>` - `standalone` (default) or `embedded`
52
+ - `-y, --yes` - Accept the default for every prompt. Implied when stdin is not a TTY
53
+ - `--no-install` - Skip `npm install`
54
+ - `--no-git` - Skip `git init` and the initial commit
55
+ - `-f, --force` - Scaffold into a non-empty directory and overwrite an existing `README.md`/`.gitignore`
56
+ - `-v, --verbose` - Verbose output
34
57
 
35
- > **Note**: This command may be part of an upcoming release. Documentation will be updated once the implementation is merged to the main branch.
58
+ **Next steps after `init`:**
59
+
60
+ ```bash
61
+ cd my-app
62
+ docker run --name frigg-postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:16
63
+ npm run db:setup
64
+ npm start
65
+ ```
36
66
 
37
67
  ---
38
68
 
@@ -0,0 +1,489 @@
1
+ /**
2
+ * Tests for `frigg init`
3
+ *
4
+ * - commander registration (flags exposed on `frigg init --help`)
5
+ * - initCommand argument handling (name, --mode, legacy --template)
6
+ * - BackendFirstHandler scaffolding with --yes into a temp directory
7
+ * - scaffold dependency/version derivation
8
+ */
9
+
10
+ const path = require('path');
11
+ const os = require('os');
12
+ const fs = require('fs-extra');
13
+ const { spawnSync } = require('child_process');
14
+
15
+ const CLI_DIR = path.join(__dirname, '..', '..', '..');
16
+ const CLI_ENTRY = path.join(CLI_DIR, 'index.js');
17
+ const DEVTOOLS_PACKAGE_JSON = require('../../../../package.json');
18
+
19
+ const makeTmpDir = () =>
20
+ fs.mkdtempSync(path.join(os.tmpdir(), 'frigg-init-test-'));
21
+
22
+ /** Prompt implementations that fail the test if any prompt is shown. */
23
+ const failingPrompts = () => ({
24
+ select: jest.fn(() => {
25
+ throw new Error('select prompt should not be shown');
26
+ }),
27
+ confirm: jest.fn(() => {
28
+ throw new Error('confirm prompt should not be shown');
29
+ }),
30
+ checkbox: jest.fn(() => {
31
+ throw new Error('checkbox prompt should not be shown');
32
+ }),
33
+ Separator: class {},
34
+ });
35
+
36
+ describe('frigg init', () => {
37
+ let consoleLogSpy;
38
+ let consoleErrorSpy;
39
+ let processExitSpy;
40
+
41
+ beforeEach(() => {
42
+ jest.resetModules();
43
+ consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
44
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
45
+ processExitSpy = jest.spyOn(process, 'exit').mockImplementation();
46
+ });
47
+
48
+ afterEach(() => {
49
+ jest.restoreAllMocks();
50
+ jest.dontMock('../../../init-command/backend-first-handler');
51
+ });
52
+
53
+ const output = () =>
54
+ [...consoleLogSpy.mock.calls, ...consoleErrorSpy.mock.calls]
55
+ .map((args) => args.join(' '))
56
+ .join('\n');
57
+
58
+ describe('commander registration', () => {
59
+ it('exposes the init options on `frigg init --help`', () => {
60
+ const result = spawnSync(
61
+ process.execPath,
62
+ [CLI_ENTRY, 'init', '--help'],
63
+ {
64
+ cwd: os.tmpdir(),
65
+ encoding: 'utf8',
66
+ env: {
67
+ ...process.env,
68
+ FRIGG_CLI_SKIP_VERSION_CHECK: 'true',
69
+ },
70
+ }
71
+ );
72
+
73
+ expect(result.status).toBe(0);
74
+ const help = result.stdout;
75
+ expect(help).toMatch(/init \[options\] \[projectName\]/);
76
+ expect(help).toMatch(/-y, --yes/);
77
+ expect(help).toMatch(/-m, --mode <mode>/);
78
+ expect(help).toMatch(/--no-install/);
79
+ expect(help).toMatch(/--no-git/);
80
+ expect(help).toMatch(/-f, --force/);
81
+ expect(help).toMatch(/-n, --name <name>/);
82
+ // The demo frontend is gone.
83
+ expect(help).not.toMatch(/frontend/);
84
+ });
85
+ });
86
+
87
+ describe('initCommand', () => {
88
+ const mockHandler = () => {
89
+ const initialize = jest.fn().mockResolvedValue();
90
+ const Handler = jest.fn().mockImplementation(() => ({
91
+ initialize,
92
+ }));
93
+ jest.doMock(
94
+ '../../../init-command/backend-first-handler',
95
+ () => Handler
96
+ );
97
+ return { Handler, initialize };
98
+ };
99
+
100
+ it('runs the backend-first handler by default, not the legacy branch', async () => {
101
+ const { Handler, initialize } = mockHandler();
102
+ const { initCommand } = require('../../../init-command');
103
+ const target = path.join(makeTmpDir(), 'my-app');
104
+
105
+ await initCommand(target, { yes: true });
106
+
107
+ expect(Handler).toHaveBeenCalledWith(
108
+ target,
109
+ expect.objectContaining({ yes: true })
110
+ );
111
+ expect(initialize).toHaveBeenCalled();
112
+ expect(processExitSpy).not.toHaveBeenCalled();
113
+ expect(output()).not.toMatch(/Legacy template/);
114
+ });
115
+
116
+ it('accepts the project name from --name', async () => {
117
+ const { Handler } = mockHandler();
118
+ const { initCommand } = require('../../../init-command');
119
+ const target = path.join(makeTmpDir(), 'named-app');
120
+
121
+ await initCommand(undefined, { name: target });
122
+
123
+ expect(Handler).toHaveBeenCalledWith(target, expect.any(Object));
124
+ });
125
+
126
+ it('exits with a message when no project name is given', async () => {
127
+ const { Handler } = mockHandler();
128
+ const { initCommand } = require('../../../init-command');
129
+
130
+ await initCommand(undefined, {});
131
+
132
+ expect(processExitSpy).toHaveBeenCalledWith(1);
133
+ expect(output()).toMatch(/Please specify a project name/);
134
+ expect(Handler).not.toHaveBeenCalled();
135
+ });
136
+
137
+ it('rejects an invalid --mode before scaffolding', async () => {
138
+ const { Handler } = mockHandler();
139
+ const { initCommand } = require('../../../init-command');
140
+ const target = path.join(makeTmpDir(), 'my-app');
141
+
142
+ await initCommand(target, { mode: 'serverless' });
143
+
144
+ expect(processExitSpy).toHaveBeenCalledWith(1);
145
+ expect(output()).toMatch(
146
+ /Invalid --mode "serverless". Expected one of: standalone, embedded/
147
+ );
148
+ expect(Handler).not.toHaveBeenCalled();
149
+ });
150
+
151
+ it('rejects the legacy --template option', async () => {
152
+ const { Handler } = mockHandler();
153
+ const { initCommand } = require('../../../init-command');
154
+
155
+ await initCommand('my-app', { template: 'react' });
156
+
157
+ expect(processExitSpy).toHaveBeenCalledWith(1);
158
+ expect(output()).toMatch(/Legacy template system/);
159
+ expect(Handler).not.toHaveBeenCalled();
160
+ });
161
+ });
162
+
163
+ describe('BackendFirstHandler', () => {
164
+ let tmpDir;
165
+
166
+ beforeEach(() => {
167
+ tmpDir = makeTmpDir();
168
+ });
169
+
170
+ afterEach(() => {
171
+ fs.removeSync(tmpDir);
172
+ });
173
+
174
+ const createHandler = (target, options = {}) => {
175
+ const BackendFirstHandler = require('../../../init-command/backend-first-handler');
176
+ return new BackendFirstHandler(target, {
177
+ yes: true,
178
+ install: false,
179
+ git: false,
180
+ prompts: failingPrompts(),
181
+ ...options,
182
+ });
183
+ };
184
+
185
+ it('--yes scaffolds a standalone app without prompting', async () => {
186
+ const target = path.join(tmpDir, 'demo-app');
187
+ const handler = createHandler(target, { mode: 'standalone' });
188
+
189
+ await handler.initialize();
190
+
191
+ for (const file of [
192
+ 'index.js',
193
+ 'infrastructure.js',
194
+ 'package.json',
195
+ 'README.md',
196
+ '.gitignore',
197
+ '.env.example',
198
+ '.env',
199
+ ]) {
200
+ expect(fs.existsSync(path.join(target, file))).toBe(true);
201
+ }
202
+
203
+ const indexJs = fs.readFileSync(
204
+ path.join(target, 'index.js'),
205
+ 'utf8'
206
+ );
207
+ expect(indexJs).toContain("name: 'demo-app',");
208
+ expect(indexJs).not.toContain("name: 'frigg-app'");
209
+
210
+ const readme = fs.readFileSync(
211
+ path.join(target, 'README.md'),
212
+ 'utf8'
213
+ );
214
+ expect(readme.startsWith('# demo-app\n')).toBe(true);
215
+
216
+ const pkg = fs.readJsonSync(path.join(target, 'package.json'));
217
+ expect(pkg.name).toBe('demo-app');
218
+ expect(pkg.scripts).toEqual(
219
+ expect.objectContaining({
220
+ start: 'frigg start',
221
+ build: 'frigg build',
222
+ deploy: 'frigg deploy',
223
+ test: expect.stringMatching(/^jest/),
224
+ })
225
+ );
226
+ expect(JSON.stringify(pkg.scripts)).not.toMatch(
227
+ /infrastructure\.js/
228
+ );
229
+ expect(Object.keys(pkg.dependencies)).toEqual(
230
+ expect.arrayContaining([
231
+ '@friggframework/core',
232
+ '@prisma/client',
233
+ 'prisma',
234
+ ])
235
+ );
236
+ expect(Object.keys(pkg.devDependencies)).toEqual(
237
+ expect.arrayContaining([
238
+ '@friggframework/devtools',
239
+ '@friggframework/serverless-plugin',
240
+ 'osls',
241
+ 'serverless-esbuild',
242
+ 'serverless-offline',
243
+ 'serverless-offline-sqs',
244
+ 'serverless-dotenv-plugin',
245
+ ])
246
+ );
247
+ expect(pkg.workspaces).toBeUndefined();
248
+ expect(fs.existsSync(path.join(target, 'frontend'))).toBe(false);
249
+ expect(handler.prompts.select).not.toHaveBeenCalled();
250
+ expect(handler.prompts.confirm).not.toHaveBeenCalled();
251
+ });
252
+
253
+ it('produces a Definition that loads and passes schema validation', async () => {
254
+ const target = path.join(tmpDir, 'valid-app');
255
+ await createHandler(target).initialize();
256
+
257
+ const { Definition } = require(path.join(target, 'index.js'));
258
+ const {
259
+ validateAppDefinition,
260
+ } = require('@friggframework/schemas');
261
+
262
+ expect(Definition.name).toBe('valid-app');
263
+ expect(validateAppDefinition(Definition).valid).toBe(true);
264
+ });
265
+
266
+ it('uses defaults when stdin is not a TTY even without --yes', () => {
267
+ const BackendFirstHandler = require('../../../init-command/backend-first-handler');
268
+ const original = process.stdin.isTTY;
269
+ try {
270
+ process.stdin.isTTY = false;
271
+ expect(new BackendFirstHandler(tmpDir, {}).interactive).toBe(
272
+ false
273
+ );
274
+ process.stdin.isTTY = true;
275
+ expect(new BackendFirstHandler(tmpDir, {}).interactive).toBe(
276
+ true
277
+ );
278
+ expect(
279
+ new BackendFirstHandler(tmpDir, { yes: true }).interactive
280
+ ).toBe(false);
281
+ } finally {
282
+ process.stdin.isTTY = original;
283
+ }
284
+ });
285
+
286
+ it('rejects an invalid mode', async () => {
287
+ const handler = createHandler(path.join(tmpDir, 'x'), {
288
+ mode: 'bogus',
289
+ });
290
+ await expect(handler.selectDeploymentMode()).rejects.toThrow(
291
+ /Invalid --mode "bogus"/
292
+ );
293
+ });
294
+
295
+ it('checks for a non-empty directory before prompting', async () => {
296
+ const target = path.join(tmpDir, 'existing');
297
+ fs.outputFileSync(path.join(target, 'server.js'), '// app');
298
+ const handler = createHandler(target, {
299
+ yes: false,
300
+ interactive: true,
301
+ mode: 'standalone',
302
+ });
303
+
304
+ await expect(handler.initialize()).rejects.toThrow(
305
+ 'Directory not empty'
306
+ );
307
+ expect(handler.prompts.select).not.toHaveBeenCalled();
308
+ expect(handler.prompts.confirm).not.toHaveBeenCalled();
309
+ });
310
+
311
+ it('keeps an existing README.md unless --force', async () => {
312
+ const target = path.join(tmpDir, 'with-readme');
313
+ fs.outputFileSync(path.join(target, 'README.md'), 'mine');
314
+
315
+ await createHandler(target).initialize();
316
+ expect(
317
+ fs.readFileSync(path.join(target, 'README.md'), 'utf8')
318
+ ).toBe('mine');
319
+
320
+ const forced = path.join(tmpDir, 'with-readme-forced');
321
+ fs.outputFileSync(path.join(forced, 'README.md'), 'mine');
322
+ await createHandler(forced, { force: true }).initialize();
323
+ expect(
324
+ fs.readFileSync(path.join(forced, 'README.md'), 'utf8')
325
+ ).toMatch(/^# with-readme-forced/);
326
+ });
327
+
328
+ it('scaffolds embedded mode into frigg-integration/ of an existing project', async () => {
329
+ const target = path.join(tmpDir, 'existing-app');
330
+ fs.outputFileSync(path.join(target, 'server.js'), '// app');
331
+
332
+ await createHandler(target, { mode: 'embedded' }).initialize();
333
+
334
+ const dir = path.join(target, 'frigg-integration');
335
+ const pkg = fs.readJsonSync(path.join(dir, 'package.json'));
336
+ expect(pkg.name).toBe('existing-app-frigg-integration');
337
+ expect(pkg.scripts.start).toBe('frigg start');
338
+ expect(pkg.dependencies['@friggframework/core']).toBeDefined();
339
+ expect(fs.existsSync(path.join(dir, 'infrastructure.js'))).toBe(
340
+ true
341
+ );
342
+ expect(
343
+ fs.readFileSync(path.join(target, 'server.js'), 'utf8')
344
+ ).toBe('// app');
345
+ });
346
+
347
+ it('turns selected API modules into `frigg install` next steps without editing the app', async () => {
348
+ jest.doMock('../../../utils/npm-registry', () => ({
349
+ getModulesByType: jest.fn().mockResolvedValue({
350
+ CRM: [
351
+ {
352
+ name: '@friggframework/api-module-hubspot',
353
+ integrationName: 'HubSpot',
354
+ description: 'CRM',
355
+ },
356
+ ],
357
+ }),
358
+ }));
359
+ const answers = {
360
+ 'What are you building with Frigg?': 'exploring',
361
+ 'Which cloud provider will you use?': 'aws',
362
+ };
363
+ const prompts = {
364
+ select: jest.fn(async ({ message }) => answers[message]),
365
+ confirm: jest.fn(async ({ message }) =>
366
+ message.startsWith('Would you like to pick API modules')
367
+ ),
368
+ checkbox: jest.fn(async () => ['hubspot']),
369
+ Separator: class {},
370
+ };
371
+ const target = path.join(tmpDir, 'with-modules');
372
+ const handler = createHandler(target, {
373
+ yes: false,
374
+ interactive: true,
375
+ mode: 'standalone',
376
+ prompts,
377
+ });
378
+
379
+ await handler.initialize();
380
+
381
+ const pkg = fs.readJsonSync(path.join(target, 'package.json'));
382
+ expect(
383
+ Object.keys(pkg.dependencies).filter((d) =>
384
+ d.includes('api-module')
385
+ )
386
+ ).toEqual([]);
387
+ const indexJs = fs.readFileSync(
388
+ path.join(target, 'index.js'),
389
+ 'utf8'
390
+ );
391
+ expect(indexJs).not.toMatch(
392
+ /HubspotIntegration|src\/integrations\/Hub/
393
+ );
394
+ expect(output()).toMatch(/npx frigg install hubspot/);
395
+ });
396
+ });
397
+
398
+ describe('scaffold dependencies', () => {
399
+ const {
400
+ getFriggVersionRange,
401
+ getScaffoldDependencies,
402
+ } = require('../../../init-command/scaffold-dependencies');
403
+
404
+ it.each([
405
+ ['2.0.0-next.115', '2.0.0-next.115'],
406
+ ['2.0.0--canary.639.abc1234.0', '2.0.0--canary.639.abc1234.0'],
407
+ ['2.0.1', '^2.0.1'],
408
+ ['2.3.4', '^2.3.4'],
409
+ // The deprecated core@2.0.0 must never be admitted.
410
+ ['2.0.0', '^2.0.1'],
411
+ ])(
412
+ 'derives the Frigg range for devtools %s as %s',
413
+ (version, range) => {
414
+ expect(getFriggVersionRange(version)).toBe(range);
415
+ }
416
+ );
417
+
418
+ it('rejects an invalid version', () => {
419
+ expect(() => getFriggVersionRange('not-a-version')).toThrow(
420
+ /Invalid/
421
+ );
422
+ });
423
+
424
+ it('reads third-party versions from devtools and Frigg ranges from its version', () => {
425
+ const { dependencies, devDependencies } = getScaffoldDependencies();
426
+ const friggRange = getFriggVersionRange(
427
+ DEVTOOLS_PACKAGE_JSON.version
428
+ );
429
+ const devtoolsDeps = {
430
+ ...DEVTOOLS_PACKAGE_JSON.dependencies,
431
+ ...DEVTOOLS_PACKAGE_JSON.devDependencies,
432
+ };
433
+
434
+ expect(dependencies['@friggframework/core']).toBe(friggRange);
435
+ expect(devDependencies['@friggframework/devtools']).toBe(
436
+ friggRange
437
+ );
438
+ expect(devDependencies['@friggframework/serverless-plugin']).toBe(
439
+ friggRange
440
+ );
441
+ for (const name of [
442
+ 'osls',
443
+ 'serverless-esbuild',
444
+ 'serverless-offline',
445
+ 'serverless-offline-sqs',
446
+ 'serverless-dotenv-plugin',
447
+ ]) {
448
+ expect(devDependencies[name]).toBe(devtoolsDeps[name]);
449
+ }
450
+ });
451
+
452
+ it('covers every serverless plugin the composed definition loads', () => {
453
+ const { devDependencies } = getScaffoldDependencies();
454
+ const {
455
+ createBaseDefinition,
456
+ } = require('../../../../infrastructure/domains/shared/utilities/base-definition-factory');
457
+ const originalArgv = process.argv;
458
+ try {
459
+ // Include the offline-only dotenv plugin.
460
+ process.argv = [...originalArgv, 'offline'];
461
+ const definition = createBaseDefinition(
462
+ { name: 'x' },
463
+ {},
464
+ {},
465
+ {}
466
+ );
467
+ for (const plugin of definition.plugins) {
468
+ expect(devDependencies[plugin]).toBeDefined();
469
+ }
470
+ } finally {
471
+ process.argv = originalArgv;
472
+ }
473
+ });
474
+
475
+ it('takes the Prisma versions from core', () => {
476
+ const { dependencies } = getScaffoldDependencies({
477
+ devtoolsPackageJson: DEVTOOLS_PACKAGE_JSON,
478
+ corePackageJson: {
479
+ peerDependencies: {
480
+ '@prisma/client': '^9.9.9',
481
+ prisma: '^9.9.9',
482
+ },
483
+ },
484
+ });
485
+ expect(dependencies['@prisma/client']).toBe('^9.9.9');
486
+ expect(dependencies.prisma).toBe('^9.9.9');
487
+ });
488
+ });
489
+ });
@@ -91,11 +91,16 @@ const { ssmPushCommand } = require('./ssm-command');
91
91
  const program = new Command();
92
92
 
93
93
  program
94
- .command('init [templateName]')
94
+ .command('init [projectName]')
95
95
  .description('Initialize a new Frigg application')
96
- .option('-t, --template <template>', 'template to use', 'backend-only')
97
- .option('-n, --name <name>', 'project name')
98
- .option('-d, --directory <directory>', 'target directory')
96
+ .option('-n, --name <name>', 'project name (alternative to the positional argument)')
97
+ .option('-m, --mode <mode>', 'deployment mode: standalone or embedded')
98
+ .option('-y, --yes', 'accept the default for every prompt (implied when stdin is not a TTY)')
99
+ .option('--no-install', 'skip installing dependencies')
100
+ .option('--no-git', 'skip initializing a git repository')
101
+ .option('-f, --force', 'scaffold into a non-empty directory and overwrite README.md')
102
+ .option('-v, --verbose', 'enable verbose output')
103
+ .option('-t, --template <template>', 'legacy template (no longer supported)')
99
104
  .action(initCommand);
100
105
 
101
106
  program