@ductape/cli 0.2.0 → 0.2.1
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/.env.ductape.example +5 -0
- package/DB_MIGRATE_PLAN.md +223 -0
- package/dist/commands/apply.d.ts +6 -0
- package/dist/commands/apply.js +71 -0
- package/dist/commands/db-migrate.d.ts +19 -0
- package/dist/commands/db-migrate.js +154 -0
- package/dist/commands/db-schema.d.ts +6 -0
- package/dist/commands/db-schema.js +135 -0
- package/dist/commands/install.d.ts +1 -1
- package/dist/commands/install.js +47 -23
- package/dist/index.js +68 -14
- package/dist/lib/apply-loaders.d.ts +6 -0
- package/dist/lib/apply-loaders.js +24 -0
- package/dist/lib/config.d.ts +2 -1
- package/dist/lib/config.js +9 -4
- package/dist/lib/db-types.d.ts +71 -0
- package/dist/lib/db-types.js +1 -0
- package/dist/lib/migration-files.d.ts +4 -0
- package/dist/lib/migration-files.js +43 -0
- package/dist/lib/resources.js +3 -1
- package/dist/lib/schema-loader.d.ts +30 -0
- package/dist/lib/schema-loader.js +117 -0
- package/dist/lib/templates.js +75 -1
- package/ductape.example.ts +18 -0
- package/package.json +1 -1
- package/src/commands/apply.ts +105 -0
- package/src/commands/db-migrate.ts +211 -0
- package/src/commands/db-schema.ts +163 -0
- package/src/commands/install.ts +52 -24
- package/src/index.ts +78 -16
- package/src/lib/apply-loaders.ts +30 -0
- package/src/lib/config.ts +9 -4
- package/src/lib/db-types.ts +104 -0
- package/src/lib/migration-files.ts +58 -0
- package/src/lib/resources.ts +3 -1
- package/src/lib/schema-loader.ts +152 -0
- package/src/lib/templates.ts +80 -1
- package/templates/api-gateway.conf +178 -0
- package/templates/docker-compose.release.yml +265 -0
- package/templates/platform.env +16 -0
package/src/index.ts
CHANGED
|
@@ -15,6 +15,8 @@ import { runStart, runStop, runStatus } from './commands/platform.js';
|
|
|
15
15
|
import { runResourceCrud, runResourcesList } from './commands/resources.js';
|
|
16
16
|
import { runCloud } from './commands/cloud.js';
|
|
17
17
|
import { runDb, runDbContext } from './commands/db.js';
|
|
18
|
+
import { runDbMigrate, runDbMigrateStatus, runDbMigrateRollback } from './commands/db-migrate.js';
|
|
19
|
+
import { runDbSchemaGenerate } from './commands/db-schema.js';
|
|
18
20
|
import { runGraph } from './commands/graph.js';
|
|
19
21
|
import { runSecrets } from './commands/secrets.js';
|
|
20
22
|
import {
|
|
@@ -28,6 +30,7 @@ import { runCompletion } from './commands/completion.js';
|
|
|
28
30
|
import { runProducts, runProductApps } from './commands/products.js';
|
|
29
31
|
import { runApps } from './commands/apps.js';
|
|
30
32
|
import { runAppsImport } from './commands/apps-import.js';
|
|
33
|
+
import { runApply, type ApplyType } from './commands/apply.js';
|
|
31
34
|
|
|
32
35
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
33
36
|
const pkg = JSON.parse(
|
|
@@ -181,6 +184,22 @@ program
|
|
|
181
184
|
),
|
|
182
185
|
);
|
|
183
186
|
|
|
187
|
+
const APPLY_TYPES = ['sessions', 'notifications', 'events'] as const;
|
|
188
|
+
|
|
189
|
+
program
|
|
190
|
+
.command('apply')
|
|
191
|
+
.description('Sync declared sessions, notifications and events to your product')
|
|
192
|
+
.argument('[type]', `${APPLY_TYPES.join(' | ')}`)
|
|
193
|
+
.option('--dry-run', 'Print what would be created/updated without applying')
|
|
194
|
+
.action(
|
|
195
|
+
wrap((type: string | undefined, opts) => {
|
|
196
|
+
if (type && !(APPLY_TYPES as readonly string[]).includes(type)) {
|
|
197
|
+
throw new Error(`Unknown type "${type}". Use: ${APPLY_TYPES.join(', ')}`);
|
|
198
|
+
}
|
|
199
|
+
return runApply(type as ApplyType | undefined, { dryRun: Boolean(opts.dryRun) });
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
|
|
184
203
|
program
|
|
185
204
|
.command('start')
|
|
186
205
|
.description('Start platform via docker compose')
|
|
@@ -203,11 +222,8 @@ program.command('status').option('--json', 'JSON output').action(wrap((opts) =>
|
|
|
203
222
|
|
|
204
223
|
const products = program
|
|
205
224
|
.command('products')
|
|
206
|
-
.description('Workspace products CRUD (REST + sdk-proxy update)')
|
|
207
|
-
|
|
208
|
-
products
|
|
209
|
-
.command('<verb>')
|
|
210
|
-
.description('list | get | create | update | delete')
|
|
225
|
+
.description('Workspace products CRUD (REST + sdk-proxy update)')
|
|
226
|
+
.argument('[verb]', 'list | get | create | update | delete')
|
|
211
227
|
.option('--profile <name>')
|
|
212
228
|
.option('--status <status>', 'Filter for list: all, active, draft, …', 'all')
|
|
213
229
|
.option('--id <id>', 'Product _id (get, delete)')
|
|
@@ -217,7 +233,10 @@ products
|
|
|
217
233
|
.option('--no-interactive', 'Require -f JSON for create/update')
|
|
218
234
|
.option('--json', 'JSON output')
|
|
219
235
|
.action(
|
|
220
|
-
wrap((verb: string, opts) =>
|
|
236
|
+
wrap((verb: string | undefined, opts) => {
|
|
237
|
+
if (!verb) throw new Error('Specify a verb: list | get | create | update | delete');
|
|
238
|
+
return runProducts(verb, opts, []);
|
|
239
|
+
}),
|
|
221
240
|
);
|
|
222
241
|
|
|
223
242
|
const productApps = products.command('apps').description('Apps connected to a product');
|
|
@@ -229,11 +248,10 @@ productApps
|
|
|
229
248
|
.option('--json', 'JSON output')
|
|
230
249
|
.action(wrap((opts) => runProductApps('list', opts, [])));
|
|
231
250
|
|
|
232
|
-
const apps = program
|
|
233
|
-
|
|
234
|
-
apps
|
|
235
|
-
.
|
|
236
|
-
.description('list | get | create | update | delete')
|
|
251
|
+
const apps = program
|
|
252
|
+
.command('apps')
|
|
253
|
+
.description('Workspace integration apps CRUD (REST)')
|
|
254
|
+
.argument('[verb]', 'list | get | create | update | delete')
|
|
237
255
|
.option('--profile <name>')
|
|
238
256
|
.option('--status <status>', 'Filter for list', 'all')
|
|
239
257
|
.option('--id <id>', 'App _id')
|
|
@@ -243,7 +261,12 @@ apps
|
|
|
243
261
|
.option('-i, --interactive', 'Prompt for fields (default in TTY when -f omitted)')
|
|
244
262
|
.option('--no-interactive', 'Require -f JSON for create/update')
|
|
245
263
|
.option('--json', 'JSON output')
|
|
246
|
-
.action(
|
|
264
|
+
.action(
|
|
265
|
+
wrap((verb: string | undefined, opts) => {
|
|
266
|
+
if (!verb) throw new Error('Specify a verb: list | get | create | update | delete');
|
|
267
|
+
return runApps(verb, opts, []);
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
247
270
|
|
|
248
271
|
apps
|
|
249
272
|
.command('import <file>')
|
|
@@ -277,7 +300,8 @@ const resources = program.command('resources').description('Component CRUD (sdk-
|
|
|
277
300
|
resources.command('types').option('--json', 'JSON output').action(wrap((opts) => runResourcesList(Boolean(opts.json))));
|
|
278
301
|
|
|
279
302
|
resources
|
|
280
|
-
.
|
|
303
|
+
.argument('<type>', 'Resource type (e.g. storage, database, cache …)')
|
|
304
|
+
.argument('<verb>', 'list | get | create | update | delete | connect')
|
|
281
305
|
.option('-t, --tag <tag>')
|
|
282
306
|
.option('-f, --file <path>', 'JSON body (or use interactive prompts)')
|
|
283
307
|
.option('-i, --interactive', 'Prompt for fields (default in TTY when -f omitted)')
|
|
@@ -309,16 +333,54 @@ const db = program.command('db').description('Database runtime (db-proxy)');
|
|
|
309
333
|
|
|
310
334
|
db.command('context').option('--json', 'JSON output').action(wrap((opts) => runDbContext(Boolean(opts.json))));
|
|
311
335
|
|
|
336
|
+
const dbMigrate = db
|
|
337
|
+
.command('migrate')
|
|
338
|
+
.description('Run pending migrations from ductape/database/migrations/')
|
|
339
|
+
.option('--env <tag>', 'Override environment slug')
|
|
340
|
+
.option('--db <tag>', 'Limit to one db entry from schema.json')
|
|
341
|
+
.option('--dry-run', 'Print what would run without applying')
|
|
342
|
+
.action(wrap((opts) => runDbMigrate({ env: opts.env, db: opts.db, dryRun: Boolean(opts.dryRun) })));
|
|
343
|
+
|
|
344
|
+
dbMigrate
|
|
345
|
+
.command('status')
|
|
346
|
+
.description('Show applied vs pending migrations')
|
|
347
|
+
.option('--env <tag>', 'Override environment slug')
|
|
348
|
+
.option('--db <tag>', 'Limit to one db entry')
|
|
349
|
+
.option('--json', 'JSON output')
|
|
350
|
+
.action(wrap((opts) => runDbMigrateStatus({ env: opts.env, db: opts.db, json: Boolean(opts.json) })));
|
|
351
|
+
|
|
352
|
+
dbMigrate
|
|
353
|
+
.command('rollback')
|
|
354
|
+
.description('Roll back the last N migrations')
|
|
355
|
+
.option('--env <tag>', 'Override environment slug')
|
|
356
|
+
.option('--db <tag>', 'Limit to one db entry')
|
|
357
|
+
.option('-n <count>', 'Number of migrations to roll back', '1')
|
|
358
|
+
.action(wrap((opts) => runDbMigrateRollback({ env: opts.env, db: opts.db, n: Number(opts.n) })));
|
|
359
|
+
|
|
360
|
+
const dbSchema = db.command('schema').description('Schema management for ductape/database/schema.json');
|
|
361
|
+
|
|
362
|
+
dbSchema
|
|
363
|
+
.command('generate')
|
|
364
|
+
.description('Diff schema.json vs applied migrations and write new migration files')
|
|
365
|
+
.option('--db <tag>', 'Limit to one db entry from schema.json')
|
|
366
|
+
.option('--destructive', 'Also generate drop migrations for removed fields/tables')
|
|
367
|
+
.action(wrap((opts) => runDbSchemaGenerate({ db: opts.db, destructive: Boolean(opts.destructive) })));
|
|
368
|
+
|
|
312
369
|
db
|
|
313
|
-
.
|
|
370
|
+
.argument('[verb]', 'connect | query | …')
|
|
314
371
|
.option('-f, --file <path>')
|
|
315
372
|
.option('--json', 'JSON output')
|
|
316
|
-
.action(
|
|
373
|
+
.action(
|
|
374
|
+
wrap((verb: string | undefined, opts) => {
|
|
375
|
+
if (!verb) throw new Error('Specify a verb: connect | query');
|
|
376
|
+
return runDb(verb, { file: opts.file, json: opts.json });
|
|
377
|
+
}),
|
|
378
|
+
);
|
|
317
379
|
|
|
318
380
|
const graph = program.command('graph').description('Graph runtime (graph-proxy)');
|
|
319
381
|
|
|
320
382
|
graph
|
|
321
|
-
.
|
|
383
|
+
.argument('<verb>', 'connect | query | …')
|
|
322
384
|
.option('-f, --file <path>')
|
|
323
385
|
.option('--json', 'JSON output')
|
|
324
386
|
.action(wrap((verb: string, opts) => runGraph(verb, { file: opts.file, json: opts.json })));
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { PROJECT_CONFIG_DIR } from './config.js';
|
|
4
|
+
|
|
5
|
+
export type ApplyItem = Record<string, unknown> & { tag: string };
|
|
6
|
+
|
|
7
|
+
function loadArrayFile(filePath: string): ApplyItem[] | null {
|
|
8
|
+
if (!fs.existsSync(filePath)) return null;
|
|
9
|
+
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
|
|
10
|
+
if (!Array.isArray(raw)) {
|
|
11
|
+
throw new Error(`${path.basename(filePath)} must be a JSON array.`);
|
|
12
|
+
}
|
|
13
|
+
return raw as ApplyItem[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function declarationPath(projectDir: string, filename: string): string {
|
|
17
|
+
return path.join(projectDir, PROJECT_CONFIG_DIR, filename);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function loadSessions(projectDir: string): ApplyItem[] | null {
|
|
21
|
+
return loadArrayFile(declarationPath(projectDir, 'sessions.json'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function loadNotifications(projectDir: string): ApplyItem[] | null {
|
|
25
|
+
return loadArrayFile(declarationPath(projectDir, 'notifications.json'));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function loadEvents(projectDir: string): ApplyItem[] | null {
|
|
29
|
+
return loadArrayFile(declarationPath(projectDir, 'events.json'));
|
|
30
|
+
}
|
package/src/lib/config.ts
CHANGED
|
@@ -8,7 +8,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
8
8
|
export const GLOBAL_DIR = path.join(os.homedir(), '.ductape');
|
|
9
9
|
export const CREDENTIALS_PATH = path.join(GLOBAL_DIR, 'credentials.json');
|
|
10
10
|
export const GLOBAL_CONFIG_PATH = path.join(GLOBAL_DIR, 'config.json');
|
|
11
|
-
export const
|
|
11
|
+
export const HUB_PLATFORM_DIR = path.join(GLOBAL_DIR, 'platform');
|
|
12
|
+
export const PROJECT_CONFIG_DIR = 'ductape';
|
|
12
13
|
export const PROJECT_CONFIG_PATH = path.join(PROJECT_CONFIG_DIR, 'config.json');
|
|
13
14
|
|
|
14
15
|
export interface Credentials {
|
|
@@ -198,7 +199,7 @@ export function loadProjectConfig(): ResolvedProjectConfig {
|
|
|
198
199
|
const workspace_id = resolveWorkspaceId(cfg, creds);
|
|
199
200
|
if (!workspace_id) {
|
|
200
201
|
throw new Error(
|
|
201
|
-
'No workspace resolved. Set workspace_tag in
|
|
202
|
+
'No workspace resolved. Set workspace_tag in ductape/config.json or run `ductape workspaces use`.',
|
|
202
203
|
);
|
|
203
204
|
}
|
|
204
205
|
return { ...cfg, workspace_id };
|
|
@@ -214,12 +215,16 @@ export function resolvePlatformDir(): string {
|
|
|
214
215
|
if (process.env.DUCTAPE_PLATFORM_DIR) {
|
|
215
216
|
return path.resolve(process.env.DUCTAPE_PLATFORM_DIR);
|
|
216
217
|
}
|
|
217
|
-
//
|
|
218
|
+
// Hub install: ~/.ductape/platform/ (written by `ductape install`)
|
|
219
|
+
if (fs.existsSync(path.join(HUB_PLATFORM_DIR, 'docker-compose.yml'))) {
|
|
220
|
+
return HUB_PLATFORM_DIR;
|
|
221
|
+
}
|
|
222
|
+
// Dev/monorepo: cli/ -> repo root -> platform/
|
|
218
223
|
const fromPackage = path.resolve(__dirname, '../../../platform');
|
|
219
224
|
if (fs.existsSync(path.join(fromPackage, 'docker-compose.yml'))) {
|
|
220
225
|
return fromPackage;
|
|
221
226
|
}
|
|
222
227
|
throw new Error(
|
|
223
|
-
'Platform
|
|
228
|
+
'Platform not installed. Run `ductape install` to pull the platform from Docker Hub.',
|
|
224
229
|
);
|
|
225
230
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export type FieldType =
|
|
2
|
+
| 'string'
|
|
3
|
+
| 'text'
|
|
4
|
+
| 'integer'
|
|
5
|
+
| 'bigint'
|
|
6
|
+
| 'smallint'
|
|
7
|
+
| 'decimal'
|
|
8
|
+
| 'float'
|
|
9
|
+
| 'double'
|
|
10
|
+
| 'uuid'
|
|
11
|
+
| 'boolean'
|
|
12
|
+
| 'date'
|
|
13
|
+
| 'time'
|
|
14
|
+
| 'datetime'
|
|
15
|
+
| 'timestamp'
|
|
16
|
+
| 'binary'
|
|
17
|
+
| 'blob'
|
|
18
|
+
| 'json'
|
|
19
|
+
| 'object'
|
|
20
|
+
| 'array'
|
|
21
|
+
| 'enum';
|
|
22
|
+
|
|
23
|
+
export interface IFieldDefinition {
|
|
24
|
+
name: string;
|
|
25
|
+
type: FieldType;
|
|
26
|
+
nullable?: boolean;
|
|
27
|
+
unique?: boolean;
|
|
28
|
+
primaryKey?: boolean;
|
|
29
|
+
autoGenerate?: boolean;
|
|
30
|
+
defaultValue?: unknown;
|
|
31
|
+
maxLength?: number;
|
|
32
|
+
precision?: number;
|
|
33
|
+
scale?: number;
|
|
34
|
+
enumValues?: string[];
|
|
35
|
+
arrayElementType?: FieldType;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ICreateCollectionOp {
|
|
39
|
+
type: 'createCollection';
|
|
40
|
+
name: string;
|
|
41
|
+
fields: IFieldDefinition[];
|
|
42
|
+
ifNotExists?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface IDropCollectionOp {
|
|
46
|
+
type: 'dropCollection';
|
|
47
|
+
name: string;
|
|
48
|
+
ifExists?: boolean;
|
|
49
|
+
cascade?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface IAddFieldOp {
|
|
53
|
+
type: 'addField';
|
|
54
|
+
collection: string;
|
|
55
|
+
field: IFieldDefinition;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface IDropFieldOp {
|
|
59
|
+
type: 'dropField';
|
|
60
|
+
collection: string;
|
|
61
|
+
fieldName: string;
|
|
62
|
+
cascade?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ICreateIndexOp {
|
|
66
|
+
type: 'createIndex';
|
|
67
|
+
collection: string;
|
|
68
|
+
name: string;
|
|
69
|
+
fields: Array<{ name: string; order?: 'asc' | 'desc' }>;
|
|
70
|
+
unique?: boolean;
|
|
71
|
+
ifNotExists?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface IDropIndexOp {
|
|
75
|
+
type: 'dropIndex';
|
|
76
|
+
collection: string;
|
|
77
|
+
name: string;
|
|
78
|
+
ifExists?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type IMigrationOperation =
|
|
82
|
+
| ICreateCollectionOp
|
|
83
|
+
| IDropCollectionOp
|
|
84
|
+
| IAddFieldOp
|
|
85
|
+
| IDropFieldOp
|
|
86
|
+
| ICreateIndexOp
|
|
87
|
+
| IDropIndexOp;
|
|
88
|
+
|
|
89
|
+
export interface IMigration {
|
|
90
|
+
tag: string;
|
|
91
|
+
name: string;
|
|
92
|
+
description?: string;
|
|
93
|
+
up: IMigrationOperation[];
|
|
94
|
+
down: IMigrationOperation[];
|
|
95
|
+
dependencies?: string[];
|
|
96
|
+
createdAt?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface IMigrationHistory {
|
|
100
|
+
tag: string;
|
|
101
|
+
name: string;
|
|
102
|
+
checksum?: string;
|
|
103
|
+
appliedAt?: string;
|
|
104
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { PROJECT_CONFIG_DIR } from './config.js';
|
|
4
|
+
import type { IMigration } from './db-types.js';
|
|
5
|
+
|
|
6
|
+
export function getMigrationsDir(projectDir: string, dbTag: string): string {
|
|
7
|
+
return path.join(projectDir, PROJECT_CONFIG_DIR, 'database', 'migrations', dbTag);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function loadMigrationFiles(projectDir: string, dbTag: string): IMigration[] {
|
|
11
|
+
const dir = getMigrationsDir(projectDir, dbTag);
|
|
12
|
+
if (!fs.existsSync(dir)) return [];
|
|
13
|
+
|
|
14
|
+
const files = fs
|
|
15
|
+
.readdirSync(dir)
|
|
16
|
+
.filter((f) => f.endsWith('.json'))
|
|
17
|
+
.sort();
|
|
18
|
+
|
|
19
|
+
return files.map((file) => {
|
|
20
|
+
const raw = JSON.parse(
|
|
21
|
+
fs.readFileSync(path.join(dir, file), 'utf8'),
|
|
22
|
+
) as IMigration;
|
|
23
|
+
return raw;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeTimestamp(counter: number): string {
|
|
28
|
+
const now = new Date();
|
|
29
|
+
const pad = (n: number, len = 2) => String(n).padStart(len, '0');
|
|
30
|
+
const ts =
|
|
31
|
+
String(now.getFullYear()) +
|
|
32
|
+
pad(now.getMonth() + 1) +
|
|
33
|
+
pad(now.getDate()) +
|
|
34
|
+
pad(now.getHours()) +
|
|
35
|
+
pad(now.getMinutes()) +
|
|
36
|
+
pad(now.getSeconds());
|
|
37
|
+
return `${ts}_${pad(counter, 3)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function writeMigrationFile(
|
|
41
|
+
projectDir: string,
|
|
42
|
+
dbTag: string,
|
|
43
|
+
migration: IMigration,
|
|
44
|
+
counter: number,
|
|
45
|
+
): string {
|
|
46
|
+
const dir = getMigrationsDir(projectDir, dbTag);
|
|
47
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
48
|
+
|
|
49
|
+
const safeName = migration.name
|
|
50
|
+
.toLowerCase()
|
|
51
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
52
|
+
.replace(/^_|_$/g, '');
|
|
53
|
+
const ts = makeTimestamp(counter);
|
|
54
|
+
const filename = `${ts}_${safeName}.json`;
|
|
55
|
+
const filePath = path.join(dir, filename);
|
|
56
|
+
fs.writeFileSync(filePath, JSON.stringify(migration, null, 2) + '\n');
|
|
57
|
+
return filePath;
|
|
58
|
+
}
|
package/src/lib/resources.ts
CHANGED
|
@@ -19,6 +19,8 @@ export const RESOURCE_MODULES: Record<string, SDKModule> = {
|
|
|
19
19
|
actions: 'actions',
|
|
20
20
|
notification: 'notifications',
|
|
21
21
|
notifications: 'notifications',
|
|
22
|
+
event: 'messageBrokers',
|
|
23
|
+
events: 'messageBrokers',
|
|
22
24
|
broker: 'messageBrokers',
|
|
23
25
|
brokers: 'messageBrokers',
|
|
24
26
|
'message-brokers': 'messageBrokers',
|
|
@@ -144,7 +146,7 @@ export function listResourceTypes(): string[] {
|
|
|
144
146
|
'features',
|
|
145
147
|
'actions',
|
|
146
148
|
'notifications',
|
|
147
|
-
'
|
|
149
|
+
'events',
|
|
148
150
|
'sessions',
|
|
149
151
|
'secrets',
|
|
150
152
|
'quotas',
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { PROJECT_CONFIG_DIR } from './config.js';
|
|
4
|
+
import type { FieldType, IFieldDefinition } from './db-types.js';
|
|
5
|
+
|
|
6
|
+
export interface MongooseFieldDef {
|
|
7
|
+
type?: string | string[];
|
|
8
|
+
required?: boolean;
|
|
9
|
+
unique?: boolean;
|
|
10
|
+
default?: unknown;
|
|
11
|
+
maxlength?: number;
|
|
12
|
+
enum?: string[];
|
|
13
|
+
primaryKey?: boolean;
|
|
14
|
+
autoGenerate?: boolean;
|
|
15
|
+
float?: boolean;
|
|
16
|
+
ref?: string;
|
|
17
|
+
index?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SchemaEntry {
|
|
21
|
+
db: string;
|
|
22
|
+
tables: Record<string, Record<string, MongooseFieldDef>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getSchemaPath(projectDir: string): string {
|
|
26
|
+
return path.join(projectDir, PROJECT_CONFIG_DIR, 'database', 'schema.json');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function loadSchemaFile(projectDir: string): SchemaEntry[] {
|
|
30
|
+
const schemaPath = getSchemaPath(projectDir);
|
|
31
|
+
if (!fs.existsSync(schemaPath)) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`No schema.json found at ${schemaPath}. Run \`ductape init\` to scaffold it.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
const raw = JSON.parse(fs.readFileSync(schemaPath, 'utf8')) as unknown;
|
|
37
|
+
if (!Array.isArray(raw)) {
|
|
38
|
+
throw new Error('schema.json must be a JSON array of { db, tables } objects.');
|
|
39
|
+
}
|
|
40
|
+
return raw as SchemaEntry[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TYPE_MAP: Record<string, FieldType> = {
|
|
44
|
+
string: 'string',
|
|
45
|
+
String: 'string',
|
|
46
|
+
text: 'text',
|
|
47
|
+
Text: 'text',
|
|
48
|
+
number: 'integer',
|
|
49
|
+
Number: 'integer',
|
|
50
|
+
integer: 'integer',
|
|
51
|
+
Integer: 'integer',
|
|
52
|
+
int: 'integer',
|
|
53
|
+
Int: 'integer',
|
|
54
|
+
boolean: 'boolean',
|
|
55
|
+
Boolean: 'boolean',
|
|
56
|
+
bool: 'boolean',
|
|
57
|
+
Bool: 'boolean',
|
|
58
|
+
date: 'datetime',
|
|
59
|
+
Date: 'datetime',
|
|
60
|
+
datetime: 'datetime',
|
|
61
|
+
DateTime: 'datetime',
|
|
62
|
+
timestamp: 'timestamp',
|
|
63
|
+
Timestamp: 'timestamp',
|
|
64
|
+
buffer: 'binary',
|
|
65
|
+
Buffer: 'binary',
|
|
66
|
+
binary: 'binary',
|
|
67
|
+
Binary: 'binary',
|
|
68
|
+
mixed: 'object',
|
|
69
|
+
Mixed: 'object',
|
|
70
|
+
object: 'object',
|
|
71
|
+
Object: 'object',
|
|
72
|
+
array: 'array',
|
|
73
|
+
Array: 'array',
|
|
74
|
+
objectid: 'uuid',
|
|
75
|
+
ObjectId: 'uuid',
|
|
76
|
+
uuid: 'uuid',
|
|
77
|
+
UUID: 'uuid',
|
|
78
|
+
decimal128: 'decimal',
|
|
79
|
+
Decimal128: 'decimal',
|
|
80
|
+
decimal: 'decimal',
|
|
81
|
+
Decimal: 'decimal',
|
|
82
|
+
map: 'object',
|
|
83
|
+
Map: 'object',
|
|
84
|
+
bigint: 'bigint',
|
|
85
|
+
BigInt: 'bigint',
|
|
86
|
+
float: 'float',
|
|
87
|
+
Float: 'float',
|
|
88
|
+
double: 'double',
|
|
89
|
+
Double: 'double',
|
|
90
|
+
json: 'json',
|
|
91
|
+
JSON: 'json',
|
|
92
|
+
blob: 'blob',
|
|
93
|
+
Blob: 'blob',
|
|
94
|
+
smallint: 'smallint',
|
|
95
|
+
SmallInt: 'smallint',
|
|
96
|
+
time: 'time',
|
|
97
|
+
Time: 'time',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function resolveBaseType(raw: string | string[] | undefined, def: MongooseFieldDef): FieldType {
|
|
101
|
+
const t = Array.isArray(raw) ? raw[0] : (raw ?? 'String');
|
|
102
|
+
if (def.enum?.length) return 'enum';
|
|
103
|
+
const mapped = TYPE_MAP[t];
|
|
104
|
+
if (!mapped) return 'string';
|
|
105
|
+
if (mapped === 'integer' && def.float) return 'float';
|
|
106
|
+
return mapped;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function parseField(name: string, def: MongooseFieldDef): IFieldDefinition {
|
|
110
|
+
const type = resolveBaseType(def.type, def);
|
|
111
|
+
const field: IFieldDefinition = { name, type };
|
|
112
|
+
|
|
113
|
+
if (def.required === true) field.nullable = false;
|
|
114
|
+
if (def.unique === true) field.unique = true;
|
|
115
|
+
if (def.primaryKey === true) field.primaryKey = true;
|
|
116
|
+
if (def.autoGenerate === true) field.autoGenerate = true;
|
|
117
|
+
if (def.maxlength != null) field.maxLength = def.maxlength;
|
|
118
|
+
|
|
119
|
+
if (def.default !== undefined) {
|
|
120
|
+
field.defaultValue =
|
|
121
|
+
def.default === 'now' || def.default === 'NOW' ? 'CURRENT_TIMESTAMP' : def.default;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (type === 'enum' && def.enum?.length) {
|
|
125
|
+
field.enumValues = def.enum;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return field;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface ParsedTable {
|
|
132
|
+
name: string;
|
|
133
|
+
fields: IFieldDefinition[];
|
|
134
|
+
indexFields: Array<{ fieldName: string; unique: boolean }>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function parseTableDef(
|
|
138
|
+
tableName: string,
|
|
139
|
+
fieldDefs: Record<string, MongooseFieldDef>,
|
|
140
|
+
): ParsedTable {
|
|
141
|
+
const fields: IFieldDefinition[] = [];
|
|
142
|
+
const indexFields: Array<{ fieldName: string; unique: boolean }> = [];
|
|
143
|
+
|
|
144
|
+
for (const [name, def] of Object.entries(fieldDefs)) {
|
|
145
|
+
fields.push(parseField(name, def));
|
|
146
|
+
if (def.index === true) {
|
|
147
|
+
indexFields.push({ fieldName: name, unique: def.unique === true });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { name: tableName, fields, indexFields };
|
|
152
|
+
}
|
package/src/lib/templates.ts
CHANGED
|
@@ -49,7 +49,7 @@ DUCTAPE_ENV=dev
|
|
|
49
49
|
const envPath = path.join(dir, '.env.ductape.example');
|
|
50
50
|
if (!fs.existsSync(envPath)) fs.writeFileSync(envPath, envContent);
|
|
51
51
|
|
|
52
|
-
const ductapeDir = path.join(dir, '
|
|
52
|
+
const ductapeDir = path.join(dir, 'ductape');
|
|
53
53
|
fs.mkdirSync(ductapeDir, { recursive: true });
|
|
54
54
|
|
|
55
55
|
const readme = `# Ductape project
|
|
@@ -66,6 +66,85 @@ Docs: https://docs.ductape.app/docs/cli/
|
|
|
66
66
|
const readmePath = path.join(ductapeDir, 'README.md');
|
|
67
67
|
if (!fs.existsSync(readmePath)) fs.writeFileSync(readmePath, readme);
|
|
68
68
|
|
|
69
|
+
const dbDir = path.join(ductapeDir, 'database');
|
|
70
|
+
const migrationsDir = path.join(dbDir, 'migrations');
|
|
71
|
+
fs.mkdirSync(migrationsDir, { recursive: true });
|
|
72
|
+
|
|
73
|
+
const schemaPath = path.join(dbDir, 'schema.json');
|
|
74
|
+
if (!fs.existsSync(schemaPath)) {
|
|
75
|
+
const schemaTemplate = [
|
|
76
|
+
{
|
|
77
|
+
db: 'your_db_tag',
|
|
78
|
+
tables: {
|
|
79
|
+
example_table: {
|
|
80
|
+
id: { type: 'String', primaryKey: true, autoGenerate: true },
|
|
81
|
+
name: { type: 'String', required: true },
|
|
82
|
+
createdAt: { type: 'Date', default: 'now' },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
fs.writeFileSync(schemaPath, JSON.stringify(schemaTemplate, null, 2) + '\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const sessionsPath = path.join(ductapeDir, 'sessions.json');
|
|
91
|
+
if (!fs.existsSync(sessionsPath)) {
|
|
92
|
+
const sessionsTemplate = [
|
|
93
|
+
{
|
|
94
|
+
tag: 'user-session',
|
|
95
|
+
name: 'User session',
|
|
96
|
+
description: 'Standard authenticated user session',
|
|
97
|
+
expiry: 604800,
|
|
98
|
+
refresh_expiry: 2592000,
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
fs.writeFileSync(sessionsPath, JSON.stringify(sessionsTemplate, null, 2) + '\n');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const notificationsPath = path.join(ductapeDir, 'notifications.json');
|
|
105
|
+
if (!fs.existsSync(notificationsPath)) {
|
|
106
|
+
const notificationsTemplate = [
|
|
107
|
+
{
|
|
108
|
+
tag: 'transactional-email',
|
|
109
|
+
name: 'Transactional email',
|
|
110
|
+
description: 'Email channel for transactional messages',
|
|
111
|
+
envs: [
|
|
112
|
+
{
|
|
113
|
+
slug: 'dev',
|
|
114
|
+
emails: {
|
|
115
|
+
host: 'smtp.example.com',
|
|
116
|
+
port: 587,
|
|
117
|
+
user: 'your-smtp-user',
|
|
118
|
+
pass: 'your-smtp-pass',
|
|
119
|
+
sender: 'no-reply@example.com',
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
fs.writeFileSync(notificationsPath, JSON.stringify(notificationsTemplate, null, 2) + '\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const eventsPath = path.join(ductapeDir, 'events.json');
|
|
129
|
+
if (!fs.existsSync(eventsPath)) {
|
|
130
|
+
const eventsTemplate = [
|
|
131
|
+
{
|
|
132
|
+
tag: 'main-event-bus',
|
|
133
|
+
name: 'Main event bus',
|
|
134
|
+
description: 'Primary message broker for application events',
|
|
135
|
+
envs: [
|
|
136
|
+
{
|
|
137
|
+
slug: 'dev',
|
|
138
|
+
type: 'kafka',
|
|
139
|
+
brokers: ['localhost:9092'],
|
|
140
|
+
client_id: 'my-app-dev',
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
fs.writeFileSync(eventsPath, JSON.stringify(eventsTemplate, null, 2) + '\n');
|
|
146
|
+
}
|
|
147
|
+
|
|
69
148
|
copyTemplate(lang, dir);
|
|
70
149
|
|
|
71
150
|
const gitignorePath = path.join(dir, '.gitignore');
|