@ductape/cli 0.3.15 → 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
+ }
@@ -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,
@@ -324,15 +344,25 @@ export async function runResourceCrud(typeName, verb, opts, extraArgs) {
324
344
  throw error;
325
345
  }
326
346
  try {
327
- const reconciled = await proxy.execute(module, 'fetch', buildCrudParams(module, 'fetch', productTag, { tag: createdTag }));
328
- if (!reconciled)
329
- throw error;
330
- 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));
331
358
  return;
332
359
  }
333
360
  catch (reconcileError) {
334
361
  if (reconcileError === error)
335
362
  throw error;
363
+ if (reconcileError instanceof DuctapeOperationError && reconcileError.details.code === 'MUTATION_OUTCOME_UNKNOWN') {
364
+ throw reconcileError;
365
+ }
336
366
  throw new DuctapeOperationError(`${error.message} Reconciliation could not confirm whether "${createdTag}" exists.`, { ...error.details, code: 'MUTATION_OUTCOME_UNKNOWN', mutationState: 'unknown' }, { cause: reconcileError });
337
367
  }
338
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>')
@@ -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.15",
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",