@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.
@@ -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 schemaEntries = loadSchemaFile(dir);
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)')
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@ductape/cli",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Ductape CLI — local platform, login, link projects, and manage resources via the proxy (Workbench-compatible)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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 schemaEntries = loadSchemaFile(dir);
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
+ }
@@ -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)')
@@ -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) }