@ductape/cli 0.3.14 → 0.3.16

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,4 +1,9 @@
1
1
  type CloudTarget = 'connections' | 'resources' | 'tiers';
2
+ export declare function runCloudPreflight(opts: {
3
+ product?: string;
4
+ file?: string;
5
+ json?: boolean;
6
+ }): Promise<void>;
2
7
  export declare function runCloud(target: CloudTarget, verb: string, opts: {
3
8
  id?: string;
4
9
  file?: string;
@@ -3,6 +3,80 @@ import { getSdkProxy, requireSession } from '../lib/proxy/context.js';
3
3
  import { printJson } from '../lib/output.js';
4
4
  import { listCloudTiers } from '../lib/platform-api.js';
5
5
  import { requireWorkspaceContext } from '../lib/workspace-context.js';
6
+ const CLOUD_SERVICE_CAPABILITIES = [
7
+ { service: 's3', provider: 'aws', types: ['storage'], actions: ['list', 'import', 'provision'] },
8
+ { service: 'gcs', provider: 'gcp', types: ['storage'], actions: ['list', 'import', 'provision'] },
9
+ { service: 'azure_blob', provider: 'azure', types: ['storage'], actions: ['list', 'import', 'provision'] },
10
+ { service: 'rds', provider: 'aws', types: ['database'], actions: ['list', 'import', 'provision'] },
11
+ { service: 'cloud_sql', provider: 'gcp', types: ['database'], actions: ['list', 'import', 'provision'] },
12
+ { service: 'azure_database', provider: 'azure', types: ['database'], actions: ['list', 'import', 'provision'] },
13
+ { service: 'mongodb_atlas', provider: 'mongodb_atlas', types: ['database'], actions: ['list', 'import', 'provision'] },
14
+ { service: 'neo4j_aura', provider: 'neo4j_aura', types: ['graph'], actions: ['list', 'import', 'provision'] },
15
+ { service: 'vertex_ai_vector_search', provider: 'gcp', types: ['vector'], actions: ['list', 'import', 'provision'] },
16
+ { service: 'opensearch', provider: 'aws', types: ['vector'], actions: ['list', 'import', 'provision'] },
17
+ { service: 'pubsub', provider: 'gcp', types: ['messageBroker'], actions: ['list', 'import', 'provision'] },
18
+ { service: 'sns_sqs', provider: 'aws', types: ['messageBroker'], actions: ['list', 'import', 'provision'] },
19
+ ];
20
+ export async function runCloudPreflight(opts) {
21
+ const session = requireSession();
22
+ const proxy = getSdkProxy(session);
23
+ const product = opts.product ?? session.project.product_tag;
24
+ const input = readJsonBody(opts.file);
25
+ const [connectionsResult, environmentsResult] = await Promise.allSettled([
26
+ proxy.execute('cloud', 'connections.list', []),
27
+ proxy.execute('product', 'environments.list', [product]),
28
+ ]);
29
+ const ctx = requireWorkspaceContext();
30
+ const tiersResult = await Promise.allSettled([listCloudTiers(ctx, {})]);
31
+ const discoveries = Array.isArray(input?.discoveries) ? input.discoveries : [];
32
+ const discoveryResults = await Promise.all(discoveries.map(async (request) => {
33
+ try {
34
+ return { request, ok: true, resources: await proxy.execute('cloud', 'resources.list', [request]) };
35
+ }
36
+ catch (error) {
37
+ return { request, ok: false, error: error instanceof Error ? error.message : String(error) };
38
+ }
39
+ }));
40
+ const environments = environmentsResult.status === 'fulfilled' ? environmentsResult.value : null;
41
+ const envList = Array.isArray(environments)
42
+ ? environments
43
+ : environments && typeof environments === 'object' && Array.isArray(environments.data)
44
+ ? environments.data
45
+ : [];
46
+ const inactive = envList.filter((env) => env && typeof env === 'object' && env.active === false);
47
+ const unresolved = [];
48
+ if (connectionsResult.status === 'rejected')
49
+ unresolved.push(`Cloud connections could not be listed: ${String(connectionsResult.reason)}`);
50
+ if (environmentsResult.status === 'rejected')
51
+ unresolved.push(`Product environments could not be listed: ${String(environmentsResult.reason)}`);
52
+ if (tiersResult[0].status === 'rejected')
53
+ unresolved.push(`Cloud tiers could not be listed: ${String(tiersResult[0].reason)}`);
54
+ if (discoveries.length === 0)
55
+ unresolved.push('No instance discovery requests supplied; pass --file with {"discoveries":[{cloud,service,type,...}]}.');
56
+ if (inactive.length > 0)
57
+ unresolved.push('Inactive environments still require an explicit configuration decision; preflight does not assume shared or provisioned infrastructure.');
58
+ for (const result of discoveryResults)
59
+ if (!result.ok)
60
+ unresolved.push(`Instance discovery failed for ${String(result.request.service ?? 'unknown service')}: ${result.error}`);
61
+ printJson({
62
+ product,
63
+ read_only: true,
64
+ supported_services: CLOUD_SERVICE_CAPABILITIES,
65
+ required_discovery_fields: ['cloud', 'service', 'type'],
66
+ connections: connectionsResult.status === 'fulfilled' ? connectionsResult.value : null,
67
+ environments: envList,
68
+ inactive_environment_policy: 'explicit_configuration_required_no_automatic_provisioning',
69
+ tiers: tiersResult[0].status === 'fulfilled' ? tiersResult[0].value : null,
70
+ instances: discoveryResults,
71
+ mutation_actions: {
72
+ import_existing: ['import-persist', 'import-persist-all'],
73
+ provision_new: ['provision-persist', 'provision-persist-all'],
74
+ destructive_deprovision: false,
75
+ },
76
+ unresolved_decisions: unresolved,
77
+ ready: unresolved.length === 0,
78
+ }, Boolean(opts.json));
79
+ }
6
80
  export async function runCloud(target, verb, opts, extraArgs) {
7
81
  if (target === 'tiers') {
8
82
  const ctx = requireWorkspaceContext();
@@ -0,0 +1,4 @@
1
+ export declare function runDoctor(opts: {
2
+ json?: boolean;
3
+ cliVersion: string;
4
+ }): Promise<void>;
@@ -0,0 +1,123 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { findProjectConfig, getActiveProfileName, getApiUrl, loadCredentials, loadGlobalConfig, } from '../lib/config.js';
5
+ function findUp(name, start = process.cwd()) {
6
+ let dir = path.resolve(start);
7
+ const root = path.parse(dir).root;
8
+ while (true) {
9
+ const candidate = path.join(dir, name);
10
+ if (fs.existsSync(candidate))
11
+ return candidate;
12
+ if (dir === root)
13
+ return null;
14
+ dir = path.dirname(dir);
15
+ }
16
+ }
17
+ function readJson(file) {
18
+ if (!file)
19
+ return null;
20
+ try {
21
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ function dependencyVersion(pkg, name) {
28
+ for (const key of ['dependencies', 'devDependencies', 'peerDependencies']) {
29
+ const values = pkg?.[key];
30
+ if (values && typeof values === 'object' && typeof values[name] === 'string') {
31
+ return values[name];
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+ function configuredMcpVersion(config) {
37
+ const servers = config?.mcpServers;
38
+ if (!servers || typeof servers !== 'object')
39
+ return null;
40
+ for (const value of Object.values(servers)) {
41
+ if (!value || typeof value !== 'object')
42
+ continue;
43
+ const args = value.args;
44
+ if (!Array.isArray(args))
45
+ continue;
46
+ const spec = args.find((arg) => typeof arg === 'string' && arg.startsWith('@ductape/mcp'));
47
+ if (typeof spec === 'string')
48
+ return spec.replace(/^@ductape\/mcp@?/, '') || 'unversioned';
49
+ }
50
+ return null;
51
+ }
52
+ export async function runDoctor(opts) {
53
+ const project = findProjectConfig();
54
+ const profile = project?.config
55
+ ? getActiveProfileName(project.config)
56
+ : loadGlobalConfig().default_profile;
57
+ const apiUrl = getApiUrl(profile);
58
+ const credentials = loadCredentials();
59
+ const packageFile = findUp('package.json');
60
+ const pkg = readJson(packageFile);
61
+ const mcpFile = findUp('.mcp.json');
62
+ const warnings = [];
63
+ let schema;
64
+ try {
65
+ const response = await fetch(`${apiUrl}/proxy/v1/schema`, {
66
+ headers: { Accept: 'application/json' },
67
+ signal: AbortSignal.timeout(15_000),
68
+ });
69
+ const contentType = response.headers.get('content-type') ?? '';
70
+ const body = await response.text();
71
+ const isJson = contentType.toLowerCase().includes('json');
72
+ schema = {
73
+ reachable: response.ok && isJson,
74
+ status: response.status,
75
+ content_type: contentType,
76
+ revision: response.ok && isJson
77
+ ? `sha256:${createHash('sha256').update(body).digest('hex')}`
78
+ : null,
79
+ api_version: response.headers.get('x-api-version'),
80
+ request_id: response.headers.get('x-request-id') ?? response.headers.get('x-correlation-id'),
81
+ };
82
+ if (!response.ok || !isJson)
83
+ warnings.push(`Schema endpoint returned HTTP ${response.status} (${contentType || 'unknown content type'}).`);
84
+ }
85
+ catch (error) {
86
+ schema = { reachable: false, error: error instanceof Error ? error.message : String(error) };
87
+ warnings.push('The configured API/schema endpoint is not reachable.');
88
+ }
89
+ const versions = {
90
+ cli: opts.cliVersion,
91
+ sdk: dependencyVersion(pkg, '@ductape/sdk'),
92
+ nestjs: dependencyVersion(pkg, '@ductape/nestjs'),
93
+ mcp: configuredMcpVersion(readJson(mcpFile)),
94
+ };
95
+ if (!credentials)
96
+ warnings.push('Not logged in; administrative CLI operations will fail.');
97
+ if (!project)
98
+ warnings.push('No linked Ductape project was found from the current directory.');
99
+ if (!versions.sdk)
100
+ warnings.push('@ductape/sdk is not declared in the nearest package.json.');
101
+ if (!versions.mcp)
102
+ warnings.push('No @ductape/mcp package was found in the nearest .mcp.json.');
103
+ const result = {
104
+ ok: warnings.length === 0,
105
+ versions,
106
+ api: { profile, url: apiUrl, schema },
107
+ authentication: {
108
+ logged_in: Boolean(credentials),
109
+ user_id: credentials?.user_id ?? null,
110
+ email: credentials?.email ?? null,
111
+ active_workspace_id: loadGlobalConfig().active_workspace_id ?? null,
112
+ },
113
+ linkage: project ? { root: project.dir, ...project.config } : null,
114
+ sources: { package_json: packageFile, mcp_json: mcpFile },
115
+ warnings,
116
+ };
117
+ if (opts.json)
118
+ console.log(JSON.stringify(result, null, 2));
119
+ else {
120
+ console.log(`Ductape doctor: ${result.ok ? 'ready' : `${warnings.length} warning(s)`}`);
121
+ console.log(JSON.stringify(result, null, 2));
122
+ }
123
+ }
@@ -1,4 +1,4 @@
1
- import { buildCrudParams, listResourceTypes, proxyMethodForVerb, resolveResourceType, } from '../lib/resources.js';
1
+ import { buildCrudParams, listComponentsFromProduct, listResourceTypes, proxyMethodForVerb, resolveResourceType, } from '../lib/resources.js';
2
2
  import { resolveResourceSchema } from '../lib/forms/index.js';
3
3
  import { isInteractive, promptTag } from '../lib/forms/prompt.js';
4
4
  import { toInteractiveOpts } from '../lib/interactive-opts.js';
@@ -19,6 +19,26 @@ function unwrapArray(value) {
19
19
  }
20
20
  return undefined;
21
21
  }
22
+ async function reconcileCreatedComponent(proxy, module, productTag, tag, timeoutMs = 20_000) {
23
+ const started = Date.now();
24
+ let attempts = 0;
25
+ do {
26
+ attempts += 1;
27
+ try {
28
+ const product = await proxy.execute('product', 'fetch', [productTag]);
29
+ const catalogue = listComponentsFromProduct(module, product);
30
+ const resource = catalogue?.find((item) => item && typeof item === 'object' && item.tag === tag);
31
+ if (resource)
32
+ return { resource, attempts, elapsed_ms: Date.now() - started };
33
+ }
34
+ catch {
35
+ // Preserve the original mutation error. Reconciliation is a bounded best-effort read.
36
+ }
37
+ if (Date.now() - started < timeoutMs)
38
+ await new Promise((resolve) => setTimeout(resolve, 2_000));
39
+ } while (Date.now() - started < timeoutMs);
40
+ return { resource: null, attempts, elapsed_ms: Date.now() - started };
41
+ }
22
42
  /**
23
43
  * Session inventories on older platform versions can fail when the component does not exist.
24
44
  * Confirm the state through the product catalogue before treating that failure as an empty list,
@@ -302,6 +322,14 @@ export async function runResourceCrud(typeName, verb, opts, extraArgs) {
302
322
  body: payload,
303
323
  });
304
324
  const proxy = getSdkProxy(session);
325
+ if (crud === 'list') {
326
+ const product = await proxy.execute('product', 'fetch', [productTag]);
327
+ const catalogue = listComponentsFromProduct(module, product);
328
+ if (catalogue !== undefined) {
329
+ printJson(catalogue, Boolean(opts.json));
330
+ return;
331
+ }
332
+ }
305
333
  if (crud === 'list' && module === 'sessions') {
306
334
  printJson(await listSessionsWithProductFallback(proxy, productTag), Boolean(opts.json));
307
335
  return;
@@ -316,15 +344,25 @@ export async function runResourceCrud(typeName, verb, opts, extraArgs) {
316
344
  throw error;
317
345
  }
318
346
  try {
319
- const reconciled = await proxy.execute(module, 'fetch', buildCrudParams(module, 'fetch', productTag, { tag: createdTag }));
320
- if (!reconciled)
321
- throw error;
322
- printJson({ created: true, reconciled: true, outcome: 'succeeded_after_reconciliation', resource: reconciled }, Boolean(opts.json));
347
+ const reconciliation = await reconcileCreatedComponent(proxy, module, productTag, createdTag);
348
+ if (!reconciliation.resource) {
349
+ throw new DuctapeOperationError(`${error.message} Reconciliation polled the administrative product catalogue ${reconciliation.attempts} time(s) for ${reconciliation.elapsed_ms}ms and did not find "${createdTag}".`, { ...error.details, code: 'MUTATION_OUTCOME_UNKNOWN', mutationState: 'unknown' });
350
+ }
351
+ printJson({
352
+ created: true,
353
+ reconciled: true,
354
+ outcome: 'succeeded_after_reconciliation',
355
+ reconciliation: { attempts: reconciliation.attempts, elapsed_ms: reconciliation.elapsed_ms },
356
+ resource: reconciliation.resource,
357
+ }, Boolean(opts.json));
323
358
  return;
324
359
  }
325
360
  catch (reconcileError) {
326
361
  if (reconcileError === error)
327
362
  throw error;
363
+ if (reconcileError instanceof DuctapeOperationError && reconcileError.details.code === 'MUTATION_OUTCOME_UNKNOWN') {
364
+ throw reconcileError;
365
+ }
328
366
  throw new DuctapeOperationError(`${error.message} Reconciliation could not confirm whether "${createdTag}" exists.`, { ...error.details, code: 'MUTATION_OUTCOME_UNKNOWN', mutationState: 'unknown' }, { cause: reconcileError });
329
367
  }
330
368
  }
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { runLogin } from './commands/login.js';
7
7
  import { runLogout } from './commands/logout.js';
8
8
  import { runWhoami } from './commands/whoami.js';
9
+ import { runDoctor } from './commands/doctor.js';
9
10
  import { runProfilesList, runProfilesUse } from './commands/profiles.js';
10
11
  import { runLink } from './commands/link.js';
11
12
  import { runUnlink } from './commands/unlink.js';
@@ -13,7 +14,7 @@ import { runInit } from './commands/init.js';
13
14
  import { runInstall } from './commands/install.js';
14
15
  import { runStart, runStop, runStatus } from './commands/platform.js';
15
16
  import { runResourceCrud, runResourcesList, runEventTopicCrud, runNotificationMessageCrud } from './commands/resources.js';
16
- import { runCloud } from './commands/cloud.js';
17
+ import { runCloud, runCloudPreflight } from './commands/cloud.js';
17
18
  import { runDb, runDbContext } from './commands/db.js';
18
19
  import { runDbMigrate, runDbMigrateStatus, runDbMigrateRollback } from './commands/db-migrate.js';
19
20
  import { runDbSchemaGenerate, runDbSchemaPush } from './commands/db-schema.js';
@@ -84,6 +85,11 @@ program
84
85
  skipWorkspaceSelect: Boolean(opts.skipWorkspaceSelect),
85
86
  })));
86
87
  program.command('logout').description('Clear local credentials').action(wrap(runLogout));
88
+ program
89
+ .command('doctor')
90
+ .description('Report CLI, SDK, MCP, API/schema, authentication, and project compatibility facts')
91
+ .option('--json', 'JSON output')
92
+ .action(wrap((opts) => runDoctor({ json: Boolean(opts.json), cliVersion: pkg.version })));
87
93
  program
88
94
  .command('whoami')
89
95
  .description('Show login and linked project')
@@ -704,6 +710,13 @@ resources
704
710
  .option('--json', 'JSON output')
705
711
  .action(wrap((type, verb, opts) => runResourceCrud(type, verb, opts, [])));
706
712
  const cloud = program.command('cloud').description('Cloud connections & resources');
713
+ cloud
714
+ .command('preflight')
715
+ .description('Read-only provider capability, connection, tier, instance, and environment preflight')
716
+ .option('--product <tag>', 'Product tag (defaults to linked project)')
717
+ .option('-f, --file <path>', 'Optional JSON with discovery requests')
718
+ .option('--json', 'JSON output')
719
+ .action(wrap((opts) => runCloudPreflight({ product: opts.product, file: opts.file, json: opts.json })));
707
720
  cloud
708
721
  .command('connections <verb> [id]')
709
722
  .option('-f, --file <path>')
@@ -10,3 +10,4 @@ export declare function buildCrudParams(module: SDKModule, method: string, produ
10
10
  body?: unknown;
11
11
  }): unknown[];
12
12
  export declare function listResourceTypes(): string[];
13
+ export declare function listComponentsFromProduct(module: SDKModule, value: unknown): unknown[] | undefined;
@@ -85,8 +85,8 @@ export function buildCrudParams(module, method, productTag, args) {
85
85
  return [{ product: productTag, tag, ...(body ?? {}) }];
86
86
  }
87
87
  }
88
- if ((module === 'storage' || module === 'databases') && method === 'create') {
89
- // databases.create and storage.create expect a single config object with product embedded,
88
+ if ((module === 'storage' || module === 'databases' || module === 'graph') && method === 'create') {
89
+ // These create methods expect a single config object with product embedded,
90
90
  // not the default (productTag, data) two-argument form.
91
91
  const payload = body && typeof body === 'object'
92
92
  ? { product: productTag, ...body }
@@ -170,3 +170,28 @@ export function listResourceTypes() {
170
170
  'app',
171
171
  ];
172
172
  }
173
+ const PRODUCT_COMPONENT_KEYS = {
174
+ graph: ['graphs'],
175
+ databases: ['databases'],
176
+ storage: ['storage'],
177
+ vector: ['vectors', 'vector_databases'],
178
+ caches: ['caches'],
179
+ notifications: ['notifications'],
180
+ messageBrokers: ['message_brokers', 'messageBrokers', 'brokers'],
181
+ sessions: ['sessions'],
182
+ quotas: ['quota', 'quotas'],
183
+ health: ['healthchecks'],
184
+ fallback: ['fallback', 'fallbacks'],
185
+ models: ['models'],
186
+ };
187
+ export function listComponentsFromProduct(module, value) {
188
+ const keys = PRODUCT_COMPONENT_KEYS[module];
189
+ if (!keys || !value || typeof value !== 'object')
190
+ return undefined;
191
+ const envelope = value;
192
+ const product = envelope.data && typeof envelope.data === 'object'
193
+ ? envelope.data
194
+ : envelope;
195
+ const key = keys.find((candidate) => Array.isArray(product[candidate]));
196
+ return key ? product[key] : [];
197
+ }
@@ -90,7 +90,14 @@ Docs: https://docs.ductape.app/docs/cli/
90
90
  name: 'User session',
91
91
  description: 'Standard authenticated user session',
92
92
  expiry: 604800,
93
+ period: 'seconds',
93
94
  refresh_expiry: 2592000,
95
+ refresh_period: 'seconds',
96
+ refresh_rotation: 'rotate_on_use',
97
+ selector: 'userId',
98
+ schema: {
99
+ userId: { type: 'string', required: true },
100
+ },
94
101
  },
95
102
  ];
96
103
  fs.writeFileSync(sessionsPath, JSON.stringify(sessionsTemplate, null, 2) + '\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ductape/cli",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
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",