@ductape/cli 0.3.15 → 0.3.17
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/cloud.d.ts +5 -0
- package/dist/commands/cloud.js +74 -0
- package/dist/commands/doctor.d.ts +4 -0
- package/dist/commands/doctor.js +123 -0
- package/dist/commands/features-sync.d.ts +12 -0
- package/dist/commands/features-sync.js +54 -0
- package/dist/commands/resources.d.ts +1 -0
- package/dist/commands/resources.js +37 -5
- package/dist/index.js +24 -1
- package/dist/lib/platform-api.js +10 -2
- package/dist/lib/templates.js +7 -0
- package/package.json +1 -1
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/cloud.js
CHANGED
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface FeaturesSyncOpts {
|
|
2
|
+
filter?: string;
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Persists code-first Ductape Features to the live product. Unlike `ductape db migrate` (which
|
|
6
|
+
* executes declarative migration files directly), a Feature's handler is real application code
|
|
7
|
+
* that typically depends on the app's own services (repositories, DB connections, DI). Ductape
|
|
8
|
+
* cannot safely execute that code in isolation — so this command delegates to a "features:sync"
|
|
9
|
+
* npm script that the project itself owns and controls, giving a single consistent entrypoint
|
|
10
|
+
* across every Ductape project regardless of framework.
|
|
11
|
+
*/
|
|
12
|
+
export declare function runFeaturesSync(opts: FeaturesSyncOpts): Promise<void>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { findProjectConfig } from '../lib/config.js';
|
|
5
|
+
import { fail } from '../lib/output.js';
|
|
6
|
+
const CONVENTION_HINT = `
|
|
7
|
+
Ductape convention for code-first Features:
|
|
8
|
+
1. Define every Feature under ductape/features/ (e.g. ductape/features/src/my-feature.ts),
|
|
9
|
+
each calling ductape.feature.define({ ... }) from a registerXFeature(ductape) export.
|
|
10
|
+
2. In your app's normal startup path, register only LOCAL function/operation handlers
|
|
11
|
+
(ductape.sdk.functions.register(...)) — no network call, safe on every boot.
|
|
12
|
+
3. Add a "features:sync" script to package.json that boots just enough of your app to
|
|
13
|
+
construct real dependencies (no HTTP listener) and calls every registerXFeature(...) once.
|
|
14
|
+
4. Run it explicitly with \`ductape features sync\` — never automatically on app boot.
|
|
15
|
+
|
|
16
|
+
This mirrors \`ductape db migrate\`: Features are defined in source, but persisted to the live
|
|
17
|
+
product via an explicit command, so a slow or unreachable Ductape API can never block your app
|
|
18
|
+
from starting.
|
|
19
|
+
`;
|
|
20
|
+
/**
|
|
21
|
+
* Persists code-first Ductape Features to the live product. Unlike `ductape db migrate` (which
|
|
22
|
+
* executes declarative migration files directly), a Feature's handler is real application code
|
|
23
|
+
* that typically depends on the app's own services (repositories, DB connections, DI). Ductape
|
|
24
|
+
* cannot safely execute that code in isolation — so this command delegates to a "features:sync"
|
|
25
|
+
* npm script that the project itself owns and controls, giving a single consistent entrypoint
|
|
26
|
+
* across every Ductape project regardless of framework.
|
|
27
|
+
*/
|
|
28
|
+
export async function runFeaturesSync(opts) {
|
|
29
|
+
const found = findProjectConfig();
|
|
30
|
+
if (!found)
|
|
31
|
+
fail('No linked project. Run `ductape link` from your project directory (or `ductape init`).');
|
|
32
|
+
const { dir } = found;
|
|
33
|
+
const packageJsonPath = path.join(dir, 'package.json');
|
|
34
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
35
|
+
fail(`No package.json found at ${dir}.\n${CONVENTION_HINT}`);
|
|
36
|
+
}
|
|
37
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
38
|
+
if (!pkg.scripts?.['features:sync']) {
|
|
39
|
+
fail(`package.json at ${dir} has no "features:sync" script.\n${CONVENTION_HINT}`);
|
|
40
|
+
}
|
|
41
|
+
const featuresDir = path.join(dir, 'ductape', 'features');
|
|
42
|
+
if (!fs.existsSync(featuresDir)) {
|
|
43
|
+
console.warn(`Warning: ductape/features/ does not exist at ${dir}. Proceeding anyway — your ` +
|
|
44
|
+
'"features:sync" script may look elsewhere, but the convention is ductape/features/.');
|
|
45
|
+
}
|
|
46
|
+
const npmArgs = ['run', 'features:sync'];
|
|
47
|
+
if (opts.filter)
|
|
48
|
+
npmArgs.push('--', opts.filter);
|
|
49
|
+
console.log(`Running "npm ${npmArgs.join(' ')}" in ${dir} ...\n`);
|
|
50
|
+
const result = spawnSync('npm', npmArgs, { cwd: dir, stdio: 'inherit' });
|
|
51
|
+
if (result.status !== 0) {
|
|
52
|
+
fail(`features:sync exited with code ${result.status ?? 1}.`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -25,6 +25,7 @@ export declare function runNotificationMessageCrud(verb: string, opts: {
|
|
|
25
25
|
notification?: string;
|
|
26
26
|
file?: string;
|
|
27
27
|
json?: boolean;
|
|
28
|
+
product?: string;
|
|
28
29
|
}): Promise<void>;
|
|
29
30
|
export declare function runResourceCrud(typeName: string, verb: string, opts: {
|
|
30
31
|
tag?: string;
|
|
@@ -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,
|
|
@@ -210,7 +230,9 @@ export async function runNotificationMessageCrud(verb, opts) {
|
|
|
210
230
|
}
|
|
211
231
|
const session = requireSession();
|
|
212
232
|
const proxy = getSdkProxy(session);
|
|
213
|
-
|
|
233
|
+
// Explicit --product avoids silently resolving to whatever product happens to be linked in
|
|
234
|
+
// the current working directory — see resources.ts's runResourceCrud for the same pattern.
|
|
235
|
+
const product = opts.product ?? session.project.product_tag;
|
|
214
236
|
const body = ['create', 'update'].includes(crud)
|
|
215
237
|
? await resolveBody({ file: opts.file, patch: crud === 'update', requireBody: crud === 'create', interactive: false })
|
|
216
238
|
: undefined;
|
|
@@ -324,15 +346,25 @@ export async function runResourceCrud(typeName, verb, opts, extraArgs) {
|
|
|
324
346
|
throw error;
|
|
325
347
|
}
|
|
326
348
|
try {
|
|
327
|
-
const
|
|
328
|
-
if (!
|
|
329
|
-
throw error;
|
|
330
|
-
|
|
349
|
+
const reconciliation = await reconcileCreatedComponent(proxy, module, productTag, createdTag);
|
|
350
|
+
if (!reconciliation.resource) {
|
|
351
|
+
throw new DuctapeOperationError(`${error.message} Reconciliation polled the administrative product catalogue for product "${productTag}" ${reconciliation.attempts} time(s) for ${reconciliation.elapsed_ms}ms and did not find "${createdTag}". If this product tag looks wrong, pass --product explicitly instead of relying on the linked project.`, { ...error.details, code: 'MUTATION_OUTCOME_UNKNOWN', mutationState: 'unknown' });
|
|
352
|
+
}
|
|
353
|
+
printJson({
|
|
354
|
+
created: true,
|
|
355
|
+
reconciled: true,
|
|
356
|
+
outcome: 'succeeded_after_reconciliation',
|
|
357
|
+
reconciliation: { attempts: reconciliation.attempts, elapsed_ms: reconciliation.elapsed_ms },
|
|
358
|
+
resource: reconciliation.resource,
|
|
359
|
+
}, Boolean(opts.json));
|
|
331
360
|
return;
|
|
332
361
|
}
|
|
333
362
|
catch (reconcileError) {
|
|
334
363
|
if (reconcileError === error)
|
|
335
364
|
throw error;
|
|
365
|
+
if (reconcileError instanceof DuctapeOperationError && reconcileError.details.code === 'MUTATION_OUTCOME_UNKNOWN') {
|
|
366
|
+
throw reconcileError;
|
|
367
|
+
}
|
|
336
368
|
throw new DuctapeOperationError(`${error.message} Reconciliation could not confirm whether "${createdTag}" exists.`, { ...error.details, code: 'MUTATION_OUTCOME_UNKNOWN', mutationState: 'unknown' }, { cause: reconcileError });
|
|
337
369
|
}
|
|
338
370
|
}
|
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,9 +14,10 @@ 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';
|
|
20
|
+
import { runFeaturesSync } from './commands/features-sync.js';
|
|
19
21
|
import { runDbSchemaGenerate, runDbSchemaPush } from './commands/db-schema.js';
|
|
20
22
|
import { runGraph } from './commands/graph.js';
|
|
21
23
|
import { runSecretImportEnv, runSecrets } from './commands/secrets.js';
|
|
@@ -84,6 +86,11 @@ program
|
|
|
84
86
|
skipWorkspaceSelect: Boolean(opts.skipWorkspaceSelect),
|
|
85
87
|
})));
|
|
86
88
|
program.command('logout').description('Clear local credentials').action(wrap(runLogout));
|
|
89
|
+
program
|
|
90
|
+
.command('doctor')
|
|
91
|
+
.description('Report CLI, SDK, MCP, API/schema, authentication, and project compatibility facts')
|
|
92
|
+
.option('--json', 'JSON output')
|
|
93
|
+
.action(wrap((opts) => runDoctor({ json: Boolean(opts.json), cliVersion: pkg.version })));
|
|
87
94
|
program
|
|
88
95
|
.command('whoami')
|
|
89
96
|
.description('Show login and linked project')
|
|
@@ -690,6 +697,7 @@ notificationMessages
|
|
|
690
697
|
.option('-t, --tag <tag>', 'Full notification:message tag')
|
|
691
698
|
.option('-n, --notification <tag>', 'Notification tag for list')
|
|
692
699
|
.option('-f, --file <path>', 'JSON template body for create/update')
|
|
700
|
+
.option('--product <tag>', 'Product tag (defaults to linked project)')
|
|
693
701
|
.option('--json', 'JSON output')
|
|
694
702
|
.action(wrap((verb, opts) => runNotificationMessageCrud(verb, opts)));
|
|
695
703
|
resources.command('types').option('--json', 'JSON output').action(wrap((opts) => runResourcesList(Boolean(opts.json))));
|
|
@@ -704,6 +712,13 @@ resources
|
|
|
704
712
|
.option('--json', 'JSON output')
|
|
705
713
|
.action(wrap((type, verb, opts) => runResourceCrud(type, verb, opts, [])));
|
|
706
714
|
const cloud = program.command('cloud').description('Cloud connections & resources');
|
|
715
|
+
cloud
|
|
716
|
+
.command('preflight')
|
|
717
|
+
.description('Read-only provider capability, connection, tier, instance, and environment preflight')
|
|
718
|
+
.option('--product <tag>', 'Product tag (defaults to linked project)')
|
|
719
|
+
.option('-f, --file <path>', 'Optional JSON with discovery requests')
|
|
720
|
+
.option('--json', 'JSON output')
|
|
721
|
+
.action(wrap((opts) => runCloudPreflight({ product: opts.product, file: opts.file, json: opts.json })));
|
|
707
722
|
cloud
|
|
708
723
|
.command('connections <verb> [id]')
|
|
709
724
|
.option('-f, --file <path>')
|
|
@@ -768,6 +783,14 @@ db
|
|
|
768
783
|
throw new Error('Specify a verb: connect | query');
|
|
769
784
|
return runDb(verb, { file: opts.file, json: opts.json });
|
|
770
785
|
}));
|
|
786
|
+
const features = program.command('features').description('Code-first Ductape Features (ductape/features/)');
|
|
787
|
+
features
|
|
788
|
+
.command('sync')
|
|
789
|
+
.description('Persist Features from ductape/features/ to the live product by running the project\'s own ' +
|
|
790
|
+
'"features:sync" npm script (see `ductape features sync --help` for the required convention). ' +
|
|
791
|
+
'Run explicitly, like `ductape db migrate` — never automatically on app boot.')
|
|
792
|
+
.argument('[filter]', 'Optional substring passed through to the project\'s features:sync script')
|
|
793
|
+
.action(wrap((filter) => runFeaturesSync({ filter })));
|
|
771
794
|
const graph = program.command('graph').description('Graph runtime (graph-proxy)');
|
|
772
795
|
graph
|
|
773
796
|
.argument('<verb>', 'connect | query | createAction | validateAction | updateAction | …')
|
package/dist/lib/platform-api.js
CHANGED
|
@@ -241,8 +241,16 @@ export async function connectMarketplaceApp(ctx, product, appTag, envs) {
|
|
|
241
241
|
if (!marketplaceApp || typeof marketplaceApp !== 'object')
|
|
242
242
|
throw new Error(`Marketplace app "${appTag}" was not found`);
|
|
243
243
|
const app = marketplaceApp;
|
|
244
|
-
|
|
245
|
-
|
|
244
|
+
// DT-021: this was a blanket "must be public" guard that also rejected an app the caller's own
|
|
245
|
+
// workspace owns and never even reaches the backend to find out whether it would allow it.
|
|
246
|
+
// ductape_marketplace_inspect has no such restriction for the same private app, so the read path
|
|
247
|
+
// already treats same-workspace ownership as sufficient — mirror that here for connect.
|
|
248
|
+
const isOwnedByCallerWorkspace = String(app.workspace_id ?? '') === String(ctx.workspaceId ?? '')
|
|
249
|
+
&& Boolean(ctx.workspaceId);
|
|
250
|
+
if (app.status !== 'public' && !isOwnedByCallerWorkspace) {
|
|
251
|
+
throw new Error(`App "${appTag}" is not public in the marketplace and is not owned by your workspace. ` +
|
|
252
|
+
'Only public marketplace apps or apps your own workspace created can be connected.');
|
|
253
|
+
}
|
|
246
254
|
const versions = Array.isArray(app.versions) ? app.versions : [];
|
|
247
255
|
const latest = versions.find((version) => version.latest === true) ?? versions[0];
|
|
248
256
|
const availableAppEnvs = new Set((Array.isArray(latest?.envs) ? latest.envs : []).map((env) => String(env && typeof env === 'object' ? env.slug ?? '' : env)).filter(Boolean));
|
package/dist/lib/templates.js
CHANGED
|
@@ -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