@ductape/cli 0.2.5 → 0.2.7
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/dist/commands/db-schema.js +207 -2
- package/dist/commands/products.d.ts +6 -0
- package/dist/commands/products.js +23 -0
- package/dist/index.js +16 -1
- package/dist/lib/resources.js +3 -1
- package/package.json +1 -1
- package/src/commands/db-schema.ts +176 -2
- package/src/commands/products.ts +29 -0
- package/src/index.ts +23 -1
- package/src/lib/resources.ts +3 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { findProjectConfig } from '../lib/config.js';
|
|
3
|
-
import { loadSchemaFile, parseTableDef } from '../lib/schema-loader.js';
|
|
4
|
+
import { loadSchemaFile, getSchemaPath, parseTableDef } from '../lib/schema-loader.js';
|
|
4
5
|
import { loadMigrationFiles, writeMigrationFile } from '../lib/migration-files.js';
|
|
5
6
|
import { fail } from '../lib/output.js';
|
|
7
|
+
import { getDbProxy, requireSession } from '../lib/proxy/context.js';
|
|
6
8
|
function replayMigrations(migrations) {
|
|
7
9
|
const schema = new Map();
|
|
8
10
|
for (const migration of migrations) {
|
|
@@ -29,16 +31,211 @@ function replayMigrations(migrations) {
|
|
|
29
31
|
}
|
|
30
32
|
return schema;
|
|
31
33
|
}
|
|
34
|
+
function columnTypeToMongooseType(raw) {
|
|
35
|
+
// Normalise: uppercase, collapse whitespace (handles "character varying", "timestamp without time zone", etc.)
|
|
36
|
+
const t = raw.toUpperCase().replace(/\s+/g, ' ').trim();
|
|
37
|
+
switch (t) {
|
|
38
|
+
case 'BIGINT':
|
|
39
|
+
case 'INT8': return 'bigint';
|
|
40
|
+
case 'SMALLINT':
|
|
41
|
+
case 'TINYINT':
|
|
42
|
+
case 'INT2': return 'smallint';
|
|
43
|
+
case 'INTEGER':
|
|
44
|
+
case 'INT':
|
|
45
|
+
case 'INT4':
|
|
46
|
+
case 'NUMBER': return 'number';
|
|
47
|
+
case 'FLOAT':
|
|
48
|
+
case 'REAL':
|
|
49
|
+
case 'FLOAT4':
|
|
50
|
+
case 'FLOAT8':
|
|
51
|
+
case 'DOUBLE PRECISION': return 'number'; // caller sets float:true
|
|
52
|
+
case 'DOUBLE': return 'double';
|
|
53
|
+
case 'DECIMAL':
|
|
54
|
+
case 'NUMERIC':
|
|
55
|
+
case 'DECIMAL128': return 'decimal';
|
|
56
|
+
case 'TEXT':
|
|
57
|
+
case 'MEDIUMTEXT':
|
|
58
|
+
case 'LONGTEXT':
|
|
59
|
+
case 'TINYTEXT': return 'text';
|
|
60
|
+
case 'STRING':
|
|
61
|
+
case 'VARCHAR':
|
|
62
|
+
case 'CHARACTER VARYING':
|
|
63
|
+
case 'CHAR':
|
|
64
|
+
case 'CHARACTER':
|
|
65
|
+
case 'BPCHAR': return 'string';
|
|
66
|
+
case 'BOOLEAN':
|
|
67
|
+
case 'BOOL': return 'boolean';
|
|
68
|
+
case 'DATE':
|
|
69
|
+
case 'DATETIME': return 'date';
|
|
70
|
+
case 'TIME':
|
|
71
|
+
case 'TIME WITHOUT TIME ZONE':
|
|
72
|
+
case 'TIME WITH TIME ZONE': return 'time';
|
|
73
|
+
case 'TIMESTAMP':
|
|
74
|
+
case 'TIMESTAMPTZ':
|
|
75
|
+
case 'TIMESTAMP WITHOUT TIME ZONE':
|
|
76
|
+
case 'TIMESTAMP WITH TIME ZONE': return 'timestamp';
|
|
77
|
+
case 'JSON':
|
|
78
|
+
case 'JSONB': return 'json';
|
|
79
|
+
case 'BINARY':
|
|
80
|
+
case 'VARBINARY':
|
|
81
|
+
case 'BYTEA': return 'binary';
|
|
82
|
+
case 'BLOB':
|
|
83
|
+
case 'MEDIUMBLOB':
|
|
84
|
+
case 'LONGBLOB':
|
|
85
|
+
case 'TINYBLOB': return 'blob';
|
|
86
|
+
case 'UUID':
|
|
87
|
+
case 'OBJECTID': return 'uuid';
|
|
88
|
+
case 'OBJECT':
|
|
89
|
+
case 'MIXED':
|
|
90
|
+
case 'MAP':
|
|
91
|
+
case 'HSTORE': return 'mixed';
|
|
92
|
+
case 'ARRAY': return 'array';
|
|
93
|
+
default: return 'string';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function buildMongooseFieldDef(col) {
|
|
97
|
+
const rawType = String(col.type ?? 'string');
|
|
98
|
+
const mongooseType = columnTypeToMongooseType(rawType);
|
|
99
|
+
const def = { type: mongooseType };
|
|
100
|
+
const upperType = rawType.toUpperCase().trim();
|
|
101
|
+
if (upperType === 'FLOAT' || upperType === 'REAL' || upperType === 'FLOAT4' || upperType === 'FLOAT8' || upperType === 'DOUBLE PRECISION')
|
|
102
|
+
def.float = true;
|
|
103
|
+
// Handle both IColumnDefinition style (primaryKey/unique) and IColumnInfo style (isPrimaryKey/isUnique)
|
|
104
|
+
if (col.nullable === false || col.required === true)
|
|
105
|
+
def.required = true;
|
|
106
|
+
if (col.unique === true || col.isUnique === true)
|
|
107
|
+
def.unique = true;
|
|
108
|
+
if (col.primaryKey === true || col.isPrimaryKey === true)
|
|
109
|
+
def.primaryKey = true;
|
|
110
|
+
if (col.autoIncrement === true || col.isAutoIncrement === true || col.autoGenerate === true)
|
|
111
|
+
def.autoGenerate = true;
|
|
112
|
+
// Handle both camelCase (defaultValue) and snake_case (default_value) from different adapters
|
|
113
|
+
const defVal = col.defaultValue ?? col.default_value;
|
|
114
|
+
if (defVal !== undefined && defVal !== null)
|
|
115
|
+
def.default = defVal;
|
|
116
|
+
// Handle both camelCase (length/maxLength) and snake_case (max_length) from different adapters
|
|
117
|
+
const len = col.length ?? col.maxLength ?? col.max_length;
|
|
118
|
+
if (typeof len === 'number')
|
|
119
|
+
def.maxlength = len;
|
|
120
|
+
if (Array.isArray(col.enumValues) && col.enumValues.length > 0) {
|
|
121
|
+
def.type = 'string';
|
|
122
|
+
def.enum = col.enumValues;
|
|
123
|
+
}
|
|
124
|
+
return def;
|
|
125
|
+
}
|
|
126
|
+
async function tryHydrateFromCloud(dir, schemaPath, opts) {
|
|
127
|
+
if (!opts.db)
|
|
128
|
+
return [];
|
|
129
|
+
let session;
|
|
130
|
+
try {
|
|
131
|
+
session = requireSession();
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
const proxy = getDbProxy(session);
|
|
137
|
+
const envSlug = session.project.env_slug;
|
|
138
|
+
const productTag = session.project.product_tag;
|
|
139
|
+
const dbContext = { database: opts.db, env: envSlug, product: productTag };
|
|
140
|
+
let tableNames = [];
|
|
141
|
+
try {
|
|
142
|
+
const raw = await proxy.execute('schema.list', [null], dbContext);
|
|
143
|
+
if (Array.isArray(raw)) {
|
|
144
|
+
tableNames = raw.map((r) => typeof r === 'string' ? r : typeof r.name === 'string' ? String(r.name) : null).filter((n) => n !== null);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
if (tableNames.length === 0)
|
|
151
|
+
return [];
|
|
152
|
+
const tables = {};
|
|
153
|
+
for (const tableName of tableNames) {
|
|
154
|
+
try {
|
|
155
|
+
const tableSchema = await proxy.execute('schema.describe', [tableName], dbContext);
|
|
156
|
+
const cols = Array.isArray(tableSchema?.columns)
|
|
157
|
+
? tableSchema.columns
|
|
158
|
+
: Array.isArray(tableSchema)
|
|
159
|
+
? tableSchema
|
|
160
|
+
: [];
|
|
161
|
+
if (cols.length === 0) {
|
|
162
|
+
tables[tableName] = {};
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const indexedFields = new Set();
|
|
166
|
+
const uniqueIndexFields = new Set();
|
|
167
|
+
const rawIndexes = tableSchema?.indexes;
|
|
168
|
+
if (Array.isArray(rawIndexes)) {
|
|
169
|
+
for (const idx of rawIndexes) {
|
|
170
|
+
const idxCols = Array.isArray(idx.columns) ? idx.columns : [];
|
|
171
|
+
if (idxCols.length === 1) {
|
|
172
|
+
const fieldName = String(idxCols[0].name ?? idxCols[0]);
|
|
173
|
+
if (idx.unique)
|
|
174
|
+
uniqueIndexFields.add(fieldName);
|
|
175
|
+
else
|
|
176
|
+
indexedFields.add(fieldName);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const fieldMap = {};
|
|
181
|
+
for (const col of cols) {
|
|
182
|
+
const name = String(col.name ?? '');
|
|
183
|
+
if (!name)
|
|
184
|
+
continue;
|
|
185
|
+
const def = buildMongooseFieldDef(col);
|
|
186
|
+
if (uniqueIndexFields.has(name)) {
|
|
187
|
+
def.unique = true;
|
|
188
|
+
def.index = true;
|
|
189
|
+
}
|
|
190
|
+
else if (indexedFields.has(name))
|
|
191
|
+
def.index = true;
|
|
192
|
+
fieldMap[name] = def;
|
|
193
|
+
}
|
|
194
|
+
tables[tableName] = fieldMap;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
tables[tableName] = {};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const entry = { db: opts.db, tables };
|
|
201
|
+
const entries = [entry];
|
|
202
|
+
fs.writeFileSync(schemaPath, JSON.stringify(entries, null, 2), 'utf8');
|
|
203
|
+
console.log(`[${opts.db}] Hydrated schema.json from cloud (${tableNames.length} collection(s)): ${tableNames.join(', ')}`);
|
|
204
|
+
return entries;
|
|
205
|
+
}
|
|
32
206
|
export async function runDbSchemaGenerate(opts) {
|
|
33
207
|
const found = findProjectConfig();
|
|
34
208
|
if (!found)
|
|
35
209
|
fail('No linked project. Run `ductape link` from your project directory.');
|
|
36
210
|
const { dir } = found;
|
|
37
|
-
const
|
|
211
|
+
const schemaPath = getSchemaPath(dir);
|
|
212
|
+
let schemaEntries;
|
|
213
|
+
let wasScaffolded = false;
|
|
214
|
+
if (!fs.existsSync(schemaPath)) {
|
|
215
|
+
// Create directory and empty schema.json first
|
|
216
|
+
fs.mkdirSync(path.dirname(schemaPath), { recursive: true });
|
|
217
|
+
fs.writeFileSync(schemaPath, '[]', 'utf8');
|
|
218
|
+
wasScaffolded = true;
|
|
219
|
+
console.log(`Created ${path.relative(dir, schemaPath)}`);
|
|
220
|
+
// Try to hydrate from the cloud (requires --db and an active session)
|
|
221
|
+
schemaEntries = await tryHydrateFromCloud(dir, schemaPath, opts);
|
|
222
|
+
if (schemaEntries.length === 0) {
|
|
223
|
+
const hint = opts.db
|
|
224
|
+
? `(no existing collections found in "${opts.db}" for env "${requireSilent()?.project?.env_slug ?? 'default'}")`
|
|
225
|
+
: '(pass --db <tag> to auto-import existing collections from the cloud)';
|
|
226
|
+
console.log(`schema.json is empty ${hint}. Add your collection definitions and run \`ductape db schema generate\` again.`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
schemaEntries = loadSchemaFile(dir);
|
|
232
|
+
}
|
|
38
233
|
const targets = opts.db
|
|
39
234
|
? schemaEntries.filter((e) => e.db === opts.db)
|
|
40
235
|
: schemaEntries;
|
|
41
236
|
if (targets.length === 0) {
|
|
237
|
+
if (wasScaffolded)
|
|
238
|
+
return; // already printed a message above
|
|
42
239
|
fail(opts.db ? `No db entry found for tag "${opts.db}".` : 'schema.json is empty.');
|
|
43
240
|
}
|
|
44
241
|
let totalGenerated = 0;
|
|
@@ -133,3 +330,11 @@ export async function runDbSchemaGenerate(opts) {
|
|
|
133
330
|
console.log(`\nGenerated ${totalGenerated} migration file(s). Run \`ductape db migrate\` to apply them.`);
|
|
134
331
|
}
|
|
135
332
|
}
|
|
333
|
+
function requireSilent() {
|
|
334
|
+
try {
|
|
335
|
+
return requireSession();
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
@@ -12,3 +12,9 @@ export declare function runProductApps(verb: string, opts: {
|
|
|
12
12
|
product?: string;
|
|
13
13
|
json?: boolean;
|
|
14
14
|
}, extraArgs: string[]): Promise<void>;
|
|
15
|
+
export declare function runProductEnvironments(verb: string, opts: {
|
|
16
|
+
product?: string;
|
|
17
|
+
tag?: string;
|
|
18
|
+
slug?: string;
|
|
19
|
+
json?: boolean;
|
|
20
|
+
}, extraArgs: string[]): Promise<void>;
|
|
@@ -5,6 +5,7 @@ import { bodyRequiredHint, resolveBody } from '../lib/read-body.js';
|
|
|
5
5
|
import { createProduct, deleteProduct, getProduct, listProductApps, listProducts, updateProduct, } from '../lib/platform-api.js';
|
|
6
6
|
import { printJson } from '../lib/output.js';
|
|
7
7
|
import { requireWorkspaceContext } from '../lib/workspace-context.js';
|
|
8
|
+
import { requireSession, getSdkProxy } from '../lib/proxy/context.js';
|
|
8
9
|
const VERBS = ['list', 'get', 'create', 'update', 'delete'];
|
|
9
10
|
export async function runProducts(verb, opts, extraArgs) {
|
|
10
11
|
const v = verb.toLowerCase();
|
|
@@ -72,3 +73,25 @@ export async function runProductApps(verb, opts, extraArgs) {
|
|
|
72
73
|
const result = await listProductApps(ctx, productId);
|
|
73
74
|
printJson(result, Boolean(opts.json));
|
|
74
75
|
}
|
|
76
|
+
export async function runProductEnvironments(verb, opts, extraArgs) {
|
|
77
|
+
const v = verb.toLowerCase();
|
|
78
|
+
const allowed = ['list', 'get', 'fetch'];
|
|
79
|
+
if (!allowed.includes(v)) {
|
|
80
|
+
throw new Error(`Unknown environments verb "${verb}". Use: ${allowed.join(', ')}`);
|
|
81
|
+
}
|
|
82
|
+
const session = requireSession();
|
|
83
|
+
const proxy = getSdkProxy(session);
|
|
84
|
+
const productTag = opts.product ?? extraArgs[0] ?? session.project?.product_tag;
|
|
85
|
+
if (!productTag)
|
|
86
|
+
throw new Error('Provide the product tag as the first argument or --product <tag>');
|
|
87
|
+
if (v === 'list') {
|
|
88
|
+
const result = await proxy.execute('product', 'environments.list', [productTag]);
|
|
89
|
+
printJson(result, Boolean(opts.json));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const slug = opts.slug ?? opts.tag ?? extraArgs[1] ?? extraArgs[0];
|
|
93
|
+
if (!slug)
|
|
94
|
+
throw new Error('Provide the environment slug as the second argument or --slug <slug>');
|
|
95
|
+
const result = await proxy.execute('product', 'environments.fetch', [productTag, slug]);
|
|
96
|
+
printJson(result, Boolean(opts.json));
|
|
97
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import { runSecrets } from './commands/secrets.js';
|
|
|
22
22
|
import { runWorkspacesCurrent, runWorkspacesList, runWorkspacesRefresh, runWorkspacesUse, } from './commands/workspaces.js';
|
|
23
23
|
import { runGeneratePayload, runGenerateSnippet } from './commands/generate.js';
|
|
24
24
|
import { runCompletion } from './commands/completion.js';
|
|
25
|
-
import { runProducts, runProductApps } from './commands/products.js';
|
|
25
|
+
import { runProducts, runProductApps, runProductEnvironments } from './commands/products.js';
|
|
26
26
|
import { runApps } from './commands/apps.js';
|
|
27
27
|
import { runAppsImport } from './commands/apps-import.js';
|
|
28
28
|
import { runApply } from './commands/apply.js';
|
|
@@ -192,6 +192,21 @@ productApps
|
|
|
192
192
|
.option('--product <id>', 'Product _id')
|
|
193
193
|
.option('--json', 'JSON output')
|
|
194
194
|
.action(wrap((opts) => runProductApps('list', opts, [])));
|
|
195
|
+
const productEnvironments = products
|
|
196
|
+
.command('environments')
|
|
197
|
+
.description('Product environments (list, get)');
|
|
198
|
+
productEnvironments
|
|
199
|
+
.command('list [product_tag]')
|
|
200
|
+
.description('List all environments for a product')
|
|
201
|
+
.option('--product <tag>', 'Product tag (defaults to linked project)')
|
|
202
|
+
.option('--json', 'JSON output')
|
|
203
|
+
.action(wrap((productTagArg, opts) => runProductEnvironments('list', { product: opts.product ?? productTagArg, json: opts.json }, [])));
|
|
204
|
+
productEnvironments
|
|
205
|
+
.command('get <product_tag> [slug]')
|
|
206
|
+
.description('Fetch a single product environment by slug')
|
|
207
|
+
.option('--slug <slug>', 'Environment slug')
|
|
208
|
+
.option('--json', 'JSON output')
|
|
209
|
+
.action(wrap((productTagArg, slugArg, opts) => runProductEnvironments('get', { product: productTagArg, slug: opts.slug ?? slugArg, json: opts.json }, [])));
|
|
195
210
|
const apps = program
|
|
196
211
|
.command('apps')
|
|
197
212
|
.description('Workspace integration apps CRUD (REST)')
|
package/dist/lib/resources.js
CHANGED
|
@@ -65,7 +65,9 @@ export function buildCrudParams(module, method, productTag, args) {
|
|
|
65
65
|
return [payload];
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
-
if (module === 'storage' && method === 'create') {
|
|
68
|
+
if ((module === 'storage' || module === 'databases') && method === 'create') {
|
|
69
|
+
// databases.create and storage.create expect a single config object with product embedded,
|
|
70
|
+
// not the default (productTag, data) two-argument form.
|
|
69
71
|
const payload = body && typeof body === 'object'
|
|
70
72
|
? { product: productTag, ...body }
|
|
71
73
|
: { product: productTag };
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { findProjectConfig } from '../lib/config.js';
|
|
3
|
-
import { loadSchemaFile, parseTableDef } from '../lib/schema-loader.js';
|
|
4
|
+
import { loadSchemaFile, getSchemaPath, parseTableDef } from '../lib/schema-loader.js';
|
|
4
5
|
import { loadMigrationFiles, writeMigrationFile } from '../lib/migration-files.js';
|
|
5
6
|
import { fail } from '../lib/output.js';
|
|
7
|
+
import { getDbProxy, requireSession } from '../lib/proxy/context.js';
|
|
8
|
+
import type { SchemaEntry, MongooseFieldDef } from '../lib/schema-loader.js';
|
|
6
9
|
import type {
|
|
7
10
|
IMigration,
|
|
8
11
|
IMigrationOperation,
|
|
@@ -43,17 +46,184 @@ function replayMigrations(migrations: IMigration[]): Map<string, ReconstructedTa
|
|
|
43
46
|
return schema;
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
function columnTypeToMongooseType(raw: string): string {
|
|
50
|
+
// Normalise: uppercase, collapse whitespace (handles "character varying", "timestamp without time zone", etc.)
|
|
51
|
+
const t = raw.toUpperCase().replace(/\s+/g, ' ').trim();
|
|
52
|
+
switch (t) {
|
|
53
|
+
case 'BIGINT': case 'INT8': return 'bigint';
|
|
54
|
+
case 'SMALLINT': case 'TINYINT': case 'INT2': return 'smallint';
|
|
55
|
+
case 'INTEGER': case 'INT': case 'INT4': case 'NUMBER': return 'number';
|
|
56
|
+
case 'FLOAT': case 'REAL': case 'FLOAT4': case 'FLOAT8': case 'DOUBLE PRECISION': return 'number'; // caller sets float:true
|
|
57
|
+
case 'DOUBLE': return 'double';
|
|
58
|
+
case 'DECIMAL': case 'NUMERIC': case 'DECIMAL128': return 'decimal';
|
|
59
|
+
case 'TEXT': case 'MEDIUMTEXT': case 'LONGTEXT': case 'TINYTEXT': return 'text';
|
|
60
|
+
case 'STRING': case 'VARCHAR': case 'CHARACTER VARYING': case 'CHAR': case 'CHARACTER': case 'BPCHAR': return 'string';
|
|
61
|
+
case 'BOOLEAN': case 'BOOL': return 'boolean';
|
|
62
|
+
case 'DATE': case 'DATETIME': return 'date';
|
|
63
|
+
case 'TIME': case 'TIME WITHOUT TIME ZONE': case 'TIME WITH TIME ZONE': return 'time';
|
|
64
|
+
case 'TIMESTAMP': case 'TIMESTAMPTZ': case 'TIMESTAMP WITHOUT TIME ZONE': case 'TIMESTAMP WITH TIME ZONE': return 'timestamp';
|
|
65
|
+
case 'JSON': case 'JSONB': return 'json';
|
|
66
|
+
case 'BINARY': case 'VARBINARY': case 'BYTEA': return 'binary';
|
|
67
|
+
case 'BLOB': case 'MEDIUMBLOB': case 'LONGBLOB': case 'TINYBLOB': return 'blob';
|
|
68
|
+
case 'UUID': case 'OBJECTID': return 'uuid';
|
|
69
|
+
case 'OBJECT': case 'MIXED': case 'MAP': case 'HSTORE': return 'mixed';
|
|
70
|
+
case 'ARRAY': return 'array';
|
|
71
|
+
default: return 'string';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildMongooseFieldDef(col: Record<string, unknown>): MongooseFieldDef {
|
|
76
|
+
const rawType = String(col.type ?? 'string');
|
|
77
|
+
const mongooseType = columnTypeToMongooseType(rawType);
|
|
78
|
+
const def: MongooseFieldDef = { type: mongooseType };
|
|
79
|
+
|
|
80
|
+
const upperType = rawType.toUpperCase().trim();
|
|
81
|
+
if (upperType === 'FLOAT' || upperType === 'REAL' || upperType === 'FLOAT4' || upperType === 'FLOAT8' || upperType === 'DOUBLE PRECISION') def.float = true;
|
|
82
|
+
|
|
83
|
+
// Handle both IColumnDefinition style (primaryKey/unique) and IColumnInfo style (isPrimaryKey/isUnique)
|
|
84
|
+
if (col.nullable === false || col.required === true) def.required = true;
|
|
85
|
+
if (col.unique === true || col.isUnique === true) def.unique = true;
|
|
86
|
+
if (col.primaryKey === true || col.isPrimaryKey === true) def.primaryKey = true;
|
|
87
|
+
if (col.autoIncrement === true || col.isAutoIncrement === true || col.autoGenerate === true) def.autoGenerate = true;
|
|
88
|
+
|
|
89
|
+
// Handle both camelCase (defaultValue) and snake_case (default_value) from different adapters
|
|
90
|
+
const defVal = col.defaultValue ?? col.default_value;
|
|
91
|
+
if (defVal !== undefined && defVal !== null) def.default = defVal as unknown;
|
|
92
|
+
|
|
93
|
+
// Handle both camelCase (length/maxLength) and snake_case (max_length) from different adapters
|
|
94
|
+
const len = col.length ?? col.maxLength ?? col.max_length;
|
|
95
|
+
if (typeof len === 'number') def.maxlength = len;
|
|
96
|
+
|
|
97
|
+
if (Array.isArray(col.enumValues) && (col.enumValues as string[]).length > 0) {
|
|
98
|
+
def.type = 'string';
|
|
99
|
+
def.enum = col.enumValues as string[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return def;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function tryHydrateFromCloud(
|
|
106
|
+
dir: string,
|
|
107
|
+
schemaPath: string,
|
|
108
|
+
opts: SchemaGenerateOpts,
|
|
109
|
+
): Promise<SchemaEntry[]> {
|
|
110
|
+
if (!opts.db) return [];
|
|
111
|
+
|
|
112
|
+
let session: ReturnType<typeof requireSession>;
|
|
113
|
+
try {
|
|
114
|
+
session = requireSession();
|
|
115
|
+
} catch {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const proxy = getDbProxy(session);
|
|
120
|
+
const envSlug = session.project.env_slug;
|
|
121
|
+
const productTag = session.project.product_tag;
|
|
122
|
+
const dbContext = { database: opts.db, env: envSlug, product: productTag };
|
|
123
|
+
|
|
124
|
+
let tableNames: string[] = [];
|
|
125
|
+
try {
|
|
126
|
+
const raw = await proxy.execute<unknown>('schema.list', [null], dbContext);
|
|
127
|
+
if (Array.isArray(raw)) {
|
|
128
|
+
tableNames = (raw as unknown[]).map((r) =>
|
|
129
|
+
typeof r === 'string' ? r : typeof (r as Record<string, unknown>).name === 'string' ? String((r as Record<string, unknown>).name) : null,
|
|
130
|
+
).filter((n): n is string => n !== null);
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (tableNames.length === 0) return [];
|
|
137
|
+
|
|
138
|
+
const tables: Record<string, Record<string, MongooseFieldDef>> = {};
|
|
139
|
+
|
|
140
|
+
for (const tableName of tableNames) {
|
|
141
|
+
try {
|
|
142
|
+
const tableSchema = await proxy.execute<unknown>('schema.describe', [tableName], dbContext);
|
|
143
|
+
const cols: unknown[] = Array.isArray((tableSchema as Record<string, unknown>)?.columns)
|
|
144
|
+
? ((tableSchema as Record<string, unknown>).columns as unknown[])
|
|
145
|
+
: Array.isArray(tableSchema)
|
|
146
|
+
? (tableSchema as unknown[])
|
|
147
|
+
: [];
|
|
148
|
+
|
|
149
|
+
if (cols.length === 0) {
|
|
150
|
+
tables[tableName] = {};
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const indexedFields = new Set<string>();
|
|
155
|
+
const uniqueIndexFields = new Set<string>();
|
|
156
|
+
const rawIndexes = (tableSchema as Record<string, unknown>)?.indexes;
|
|
157
|
+
if (Array.isArray(rawIndexes)) {
|
|
158
|
+
for (const idx of rawIndexes as Record<string, unknown>[]) {
|
|
159
|
+
const idxCols = Array.isArray(idx.columns) ? (idx.columns as Record<string, unknown>[]) : [];
|
|
160
|
+
if (idxCols.length === 1) {
|
|
161
|
+
const fieldName = String(idxCols[0].name ?? idxCols[0]);
|
|
162
|
+
if (idx.unique) uniqueIndexFields.add(fieldName);
|
|
163
|
+
else indexedFields.add(fieldName);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const fieldMap: Record<string, MongooseFieldDef> = {};
|
|
169
|
+
for (const col of cols as Record<string, unknown>[]) {
|
|
170
|
+
const name = String(col.name ?? '');
|
|
171
|
+
if (!name) continue;
|
|
172
|
+
const def = buildMongooseFieldDef(col);
|
|
173
|
+
if (uniqueIndexFields.has(name)) { def.unique = true; def.index = true; }
|
|
174
|
+
else if (indexedFields.has(name)) def.index = true;
|
|
175
|
+
fieldMap[name] = def;
|
|
176
|
+
}
|
|
177
|
+
tables[tableName] = fieldMap;
|
|
178
|
+
} catch {
|
|
179
|
+
tables[tableName] = {};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const entry: SchemaEntry = { db: opts.db, tables };
|
|
184
|
+
const entries = [entry];
|
|
185
|
+
fs.writeFileSync(schemaPath, JSON.stringify(entries, null, 2), 'utf8');
|
|
186
|
+
console.log(`[${opts.db}] Hydrated schema.json from cloud (${tableNames.length} collection(s)): ${tableNames.join(', ')}`);
|
|
187
|
+
return entries;
|
|
188
|
+
}
|
|
189
|
+
|
|
46
190
|
export async function runDbSchemaGenerate(opts: SchemaGenerateOpts): Promise<void> {
|
|
47
191
|
const found = findProjectConfig();
|
|
48
192
|
if (!found) fail('No linked project. Run `ductape link` from your project directory.');
|
|
49
193
|
|
|
50
194
|
const { dir } = found;
|
|
51
|
-
const
|
|
195
|
+
const schemaPath = getSchemaPath(dir);
|
|
196
|
+
|
|
197
|
+
let schemaEntries: SchemaEntry[];
|
|
198
|
+
let wasScaffolded = false;
|
|
199
|
+
|
|
200
|
+
if (!fs.existsSync(schemaPath)) {
|
|
201
|
+
// Create directory and empty schema.json first
|
|
202
|
+
fs.mkdirSync(path.dirname(schemaPath), { recursive: true });
|
|
203
|
+
fs.writeFileSync(schemaPath, '[]', 'utf8');
|
|
204
|
+
wasScaffolded = true;
|
|
205
|
+
console.log(`Created ${path.relative(dir, schemaPath)}`);
|
|
206
|
+
|
|
207
|
+
// Try to hydrate from the cloud (requires --db and an active session)
|
|
208
|
+
schemaEntries = await tryHydrateFromCloud(dir, schemaPath, opts);
|
|
209
|
+
|
|
210
|
+
if (schemaEntries.length === 0) {
|
|
211
|
+
const hint = opts.db
|
|
212
|
+
? `(no existing collections found in "${opts.db}" for env "${requireSilent()?.project?.env_slug ?? 'default'}")`
|
|
213
|
+
: '(pass --db <tag> to auto-import existing collections from the cloud)';
|
|
214
|
+
console.log(`schema.json is empty ${hint}. Add your collection definitions and run \`ductape db schema generate\` again.`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
schemaEntries = loadSchemaFile(dir);
|
|
219
|
+
}
|
|
220
|
+
|
|
52
221
|
const targets = opts.db
|
|
53
222
|
? schemaEntries.filter((e) => e.db === opts.db)
|
|
54
223
|
: schemaEntries;
|
|
55
224
|
|
|
56
225
|
if (targets.length === 0) {
|
|
226
|
+
if (wasScaffolded) return; // already printed a message above
|
|
57
227
|
fail(opts.db ? `No db entry found for tag "${opts.db}".` : 'schema.json is empty.');
|
|
58
228
|
}
|
|
59
229
|
|
|
@@ -161,3 +331,7 @@ export async function runDbSchemaGenerate(opts: SchemaGenerateOpts): Promise<voi
|
|
|
161
331
|
console.log(`\nGenerated ${totalGenerated} migration file(s). Run \`ductape db migrate\` to apply them.`);
|
|
162
332
|
}
|
|
163
333
|
}
|
|
334
|
+
|
|
335
|
+
function requireSilent(): ReturnType<typeof requireSession> | null {
|
|
336
|
+
try { return requireSession(); } catch { return null; }
|
|
337
|
+
}
|
package/src/commands/products.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from '../lib/platform-api.js';
|
|
13
13
|
import { printJson } from '../lib/output.js';
|
|
14
14
|
import { requireWorkspaceContext } from '../lib/workspace-context.js';
|
|
15
|
+
import { requireSession, getSdkProxy } from '../lib/proxy/context.js';
|
|
15
16
|
|
|
16
17
|
const VERBS = ['list', 'get', 'create', 'update', 'delete'] as const;
|
|
17
18
|
type Verb = (typeof VERBS)[number];
|
|
@@ -102,3 +103,31 @@ export async function runProductApps(
|
|
|
102
103
|
const result = await listProductApps(ctx, productId);
|
|
103
104
|
printJson(result, Boolean(opts.json));
|
|
104
105
|
}
|
|
106
|
+
|
|
107
|
+
export async function runProductEnvironments(
|
|
108
|
+
verb: string,
|
|
109
|
+
opts: { product?: string; tag?: string; slug?: string; json?: boolean },
|
|
110
|
+
extraArgs: string[],
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
const v = verb.toLowerCase();
|
|
113
|
+
const allowed = ['list', 'get', 'fetch'];
|
|
114
|
+
if (!allowed.includes(v)) {
|
|
115
|
+
throw new Error(`Unknown environments verb "${verb}". Use: ${allowed.join(', ')}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const session = requireSession();
|
|
119
|
+
const proxy = getSdkProxy(session);
|
|
120
|
+
const productTag = opts.product ?? extraArgs[0] ?? session.project?.product_tag;
|
|
121
|
+
if (!productTag) throw new Error('Provide the product tag as the first argument or --product <tag>');
|
|
122
|
+
|
|
123
|
+
if (v === 'list') {
|
|
124
|
+
const result = await proxy.execute('product', 'environments.list', [productTag]);
|
|
125
|
+
printJson(result, Boolean(opts.json));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const slug = opts.slug ?? opts.tag ?? extraArgs[1] ?? extraArgs[0];
|
|
130
|
+
if (!slug) throw new Error('Provide the environment slug as the second argument or --slug <slug>');
|
|
131
|
+
const result = await proxy.execute('product', 'environments.fetch', [productTag, slug]);
|
|
132
|
+
printJson(result, Boolean(opts.json));
|
|
133
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
} from './commands/workspaces.js';
|
|
28
28
|
import { runGeneratePayload, runGenerateSnippet } from './commands/generate.js';
|
|
29
29
|
import { runCompletion } from './commands/completion.js';
|
|
30
|
-
import { runProducts, runProductApps } from './commands/products.js';
|
|
30
|
+
import { runProducts, runProductApps, runProductEnvironments } from './commands/products.js';
|
|
31
31
|
import { runApps } from './commands/apps.js';
|
|
32
32
|
import { runAppsImport } from './commands/apps-import.js';
|
|
33
33
|
import { runApply, type ApplyType } from './commands/apply.js';
|
|
@@ -248,6 +248,28 @@ productApps
|
|
|
248
248
|
.option('--json', 'JSON output')
|
|
249
249
|
.action(wrap((opts) => runProductApps('list', opts, [])));
|
|
250
250
|
|
|
251
|
+
const productEnvironments = products
|
|
252
|
+
.command('environments')
|
|
253
|
+
.description('Product environments (list, get)');
|
|
254
|
+
|
|
255
|
+
productEnvironments
|
|
256
|
+
.command('list [product_tag]')
|
|
257
|
+
.description('List all environments for a product')
|
|
258
|
+
.option('--product <tag>', 'Product tag (defaults to linked project)')
|
|
259
|
+
.option('--json', 'JSON output')
|
|
260
|
+
.action(wrap((productTagArg: string | undefined, opts) =>
|
|
261
|
+
runProductEnvironments('list', { product: opts.product ?? productTagArg, json: opts.json }, []),
|
|
262
|
+
));
|
|
263
|
+
|
|
264
|
+
productEnvironments
|
|
265
|
+
.command('get <product_tag> [slug]')
|
|
266
|
+
.description('Fetch a single product environment by slug')
|
|
267
|
+
.option('--slug <slug>', 'Environment slug')
|
|
268
|
+
.option('--json', 'JSON output')
|
|
269
|
+
.action(wrap((productTagArg: string, slugArg: string | undefined, opts) =>
|
|
270
|
+
runProductEnvironments('get', { product: productTagArg, slug: opts.slug ?? slugArg, json: opts.json }, []),
|
|
271
|
+
));
|
|
272
|
+
|
|
251
273
|
const apps = program
|
|
252
274
|
.command('apps')
|
|
253
275
|
.description('Workspace integration apps CRUD (REST)')
|
package/src/lib/resources.ts
CHANGED
|
@@ -79,7 +79,9 @@ export function buildCrudParams(
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
if (module === 'storage' && method === 'create') {
|
|
82
|
+
if ((module === 'storage' || module === 'databases') && method === 'create') {
|
|
83
|
+
// databases.create and storage.create expect a single config object with product embedded,
|
|
84
|
+
// not the default (productTag, data) two-argument form.
|
|
83
85
|
const payload =
|
|
84
86
|
body && typeof body === 'object'
|
|
85
87
|
? { product: productTag, ...(body as object) }
|