@ductape/cli 0.3.16 → 0.3.18
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/app-actions.d.ts +8 -0
- package/dist/commands/app-actions.js +56 -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 +4 -2
- package/dist/index.js +54 -0
- package/dist/lib/platform-api.d.ts +5 -0
- package/dist/lib/platform-api.js +30 -2
- package/package.json +1 -1
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type CommandInteractiveFlags } from '../lib/interactive-opts.js';
|
|
2
|
+
export declare function runAppActions(verb: string, opts: {
|
|
3
|
+
profile?: string;
|
|
4
|
+
app?: string;
|
|
5
|
+
action?: string;
|
|
6
|
+
file?: string;
|
|
7
|
+
json?: boolean;
|
|
8
|
+
} & CommandInteractiveFlags): Promise<void>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { toInteractiveOpts } from '../lib/interactive-opts.js';
|
|
2
|
+
import { bodyRequiredHint, resolveBody } from '../lib/read-body.js';
|
|
3
|
+
import { createAppAction, deleteAppAction, getAppAction, listAppActions, updateAppAction, } from '../lib/platform-api.js';
|
|
4
|
+
import { printJson } from '../lib/output.js';
|
|
5
|
+
import { requireWorkspaceContext } from '../lib/workspace-context.js';
|
|
6
|
+
const VERBS = ['list', 'get', 'create', 'update', 'delete'];
|
|
7
|
+
export async function runAppActions(verb, opts) {
|
|
8
|
+
const v = verb.toLowerCase();
|
|
9
|
+
if (!VERBS.includes(v)) {
|
|
10
|
+
throw new Error(`Unknown apps actions verb "${verb}". Use: ${VERBS.join(', ')}`);
|
|
11
|
+
}
|
|
12
|
+
if (!opts.app)
|
|
13
|
+
throw new Error('Provide --app <app_tag>');
|
|
14
|
+
const ctx = requireWorkspaceContext({ profile: opts.profile });
|
|
15
|
+
const interactive = toInteractiveOpts(opts);
|
|
16
|
+
const body = v === 'create' || v === 'update'
|
|
17
|
+
? await resolveBody({
|
|
18
|
+
...interactive,
|
|
19
|
+
file: opts.file,
|
|
20
|
+
patch: v === 'update',
|
|
21
|
+
requireBody: v === 'create',
|
|
22
|
+
})
|
|
23
|
+
: undefined;
|
|
24
|
+
let result;
|
|
25
|
+
switch (v) {
|
|
26
|
+
case 'list':
|
|
27
|
+
result = await listAppActions(ctx, opts.app);
|
|
28
|
+
break;
|
|
29
|
+
case 'get':
|
|
30
|
+
if (!opts.action)
|
|
31
|
+
throw new Error('Provide --action <action_tag>');
|
|
32
|
+
result = await getAppAction(ctx, opts.app, opts.action);
|
|
33
|
+
break;
|
|
34
|
+
case 'create':
|
|
35
|
+
if (!body || Object.keys(body).length === 0) {
|
|
36
|
+
throw new Error(`create requires a body. ${bodyRequiredHint('apps:actions:create')}`);
|
|
37
|
+
}
|
|
38
|
+
result = await createAppAction(ctx, opts.app, body);
|
|
39
|
+
break;
|
|
40
|
+
case 'update': {
|
|
41
|
+
const actionTag = opts.action ?? body?.tag;
|
|
42
|
+
if (!actionTag)
|
|
43
|
+
throw new Error('update requires --action <action_tag>');
|
|
44
|
+
result = await updateAppAction(ctx, opts.app, actionTag, body ?? {});
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
case 'delete':
|
|
48
|
+
if (!opts.action)
|
|
49
|
+
throw new Error('Provide --action <action_tag>');
|
|
50
|
+
result = await deleteAppAction(ctx, opts.app, opts.action);
|
|
51
|
+
break;
|
|
52
|
+
default:
|
|
53
|
+
throw new Error(`Unsupported: apps actions ${v}`);
|
|
54
|
+
}
|
|
55
|
+
printJson(result, Boolean(opts.json));
|
|
56
|
+
}
|
|
@@ -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;
|
|
@@ -230,7 +230,9 @@ export async function runNotificationMessageCrud(verb, opts) {
|
|
|
230
230
|
}
|
|
231
231
|
const session = requireSession();
|
|
232
232
|
const proxy = getSdkProxy(session);
|
|
233
|
-
|
|
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;
|
|
234
236
|
const body = ['create', 'update'].includes(crud)
|
|
235
237
|
? await resolveBody({ file: opts.file, patch: crud === 'update', requireBody: crud === 'create', interactive: false })
|
|
236
238
|
: undefined;
|
|
@@ -346,7 +348,7 @@ export async function runResourceCrud(typeName, verb, opts, extraArgs) {
|
|
|
346
348
|
try {
|
|
347
349
|
const reconciliation = await reconcileCreatedComponent(proxy, module, productTag, createdTag);
|
|
348
350
|
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' });
|
|
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' });
|
|
350
352
|
}
|
|
351
353
|
printJson({
|
|
352
354
|
created: true,
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import { runResourceCrud, runResourcesList, runEventTopicCrud, runNotificationMe
|
|
|
17
17
|
import { runCloud, runCloudPreflight } from './commands/cloud.js';
|
|
18
18
|
import { runDb, runDbContext } from './commands/db.js';
|
|
19
19
|
import { runDbMigrate, runDbMigrateStatus, runDbMigrateRollback } from './commands/db-migrate.js';
|
|
20
|
+
import { runFeaturesSync } from './commands/features-sync.js';
|
|
20
21
|
import { runDbSchemaGenerate, runDbSchemaPush } from './commands/db-schema.js';
|
|
21
22
|
import { runGraph } from './commands/graph.js';
|
|
22
23
|
import { runSecretImportEnv, runSecrets } from './commands/secrets.js';
|
|
@@ -26,6 +27,7 @@ import { runCompletion } from './commands/completion.js';
|
|
|
26
27
|
import { runProducts, runProductApps, runProductAppActions, runProductComponents, runProductEnvironments } from './commands/products.js';
|
|
27
28
|
import { runApps } from './commands/apps.js';
|
|
28
29
|
import { runAppsImport } from './commands/apps-import.js';
|
|
30
|
+
import { runAppActions } from './commands/app-actions.js';
|
|
29
31
|
import { runMarketplace } from './commands/marketplace.js';
|
|
30
32
|
import { runApply } from './commands/apply.js';
|
|
31
33
|
import { runMigrateCodebase } from './commands/migrate-codebase.js';
|
|
@@ -662,6 +664,49 @@ apps
|
|
|
662
664
|
version: opts.version,
|
|
663
665
|
json: opts.json,
|
|
664
666
|
})));
|
|
667
|
+
const appActions = apps.command('actions').description("An app's own actions (endpoints). resource is a path relative to each of the app's " +
|
|
668
|
+
'environment base_urls (e.g. "/v1/users/{id}"), not a full URL.');
|
|
669
|
+
appActions
|
|
670
|
+
.command('list')
|
|
671
|
+
.requiredOption('--app <tag>', 'App tag')
|
|
672
|
+
.option('--profile <name>')
|
|
673
|
+
.option('--json', 'JSON output')
|
|
674
|
+
.action(wrap((opts) => runAppActions('list', opts)));
|
|
675
|
+
appActions
|
|
676
|
+
.command('get')
|
|
677
|
+
.requiredOption('--app <tag>', 'App tag')
|
|
678
|
+
.requiredOption('--action <tag>', 'Action tag')
|
|
679
|
+
.option('--profile <name>')
|
|
680
|
+
.option('--json', 'JSON output')
|
|
681
|
+
.action(wrap((opts) => runAppActions('get', opts)));
|
|
682
|
+
appActions
|
|
683
|
+
.command('create')
|
|
684
|
+
.requiredOption('--app <tag>', 'App tag')
|
|
685
|
+
// NOTE: cannot use -f/--file here — it collides with the parent `apps` command's own -f/--file
|
|
686
|
+
// option (used by `apps create`/`apps update`). Commander resolves a flag shared by an ancestor
|
|
687
|
+
// and a descendant command against the ancestor, so the value never reaches this subcommand's
|
|
688
|
+
// own opts, and requiredOption fails even when -f is supplied. Same fix as productEnvironments
|
|
689
|
+
// create/update (--env-file) above.
|
|
690
|
+
.requiredOption('--action-file <path>', 'JSON body: { tag, name, resource, method, description?, params?, query?, headers?, body?, response? }')
|
|
691
|
+
.option('--profile <name>')
|
|
692
|
+
.option('--json', 'JSON output')
|
|
693
|
+
.action(wrap((opts) => runAppActions('create', { ...opts, file: opts.actionFile })));
|
|
694
|
+
appActions
|
|
695
|
+
.command('update')
|
|
696
|
+
.requiredOption('--app <tag>', 'App tag')
|
|
697
|
+
.requiredOption('--action <tag>', 'Action tag')
|
|
698
|
+
// See NOTE above on `create` — same -f/--file collision with the parent `apps` command.
|
|
699
|
+
.requiredOption('--action-file <path>', 'JSON body (partial update)')
|
|
700
|
+
.option('--profile <name>')
|
|
701
|
+
.option('--json', 'JSON output')
|
|
702
|
+
.action(wrap((opts) => runAppActions('update', { ...opts, file: opts.actionFile })));
|
|
703
|
+
appActions
|
|
704
|
+
.command('delete')
|
|
705
|
+
.requiredOption('--app <tag>', 'App tag')
|
|
706
|
+
.requiredOption('--action <tag>', 'Action tag')
|
|
707
|
+
.option('--profile <name>')
|
|
708
|
+
.option('--json', 'JSON output')
|
|
709
|
+
.action(wrap((opts) => runAppActions('delete', opts)));
|
|
665
710
|
program
|
|
666
711
|
.command('marketplace')
|
|
667
712
|
.description('Discover public apps by capability and inspect their actions')
|
|
@@ -696,6 +741,7 @@ notificationMessages
|
|
|
696
741
|
.option('-t, --tag <tag>', 'Full notification:message tag')
|
|
697
742
|
.option('-n, --notification <tag>', 'Notification tag for list')
|
|
698
743
|
.option('-f, --file <path>', 'JSON template body for create/update')
|
|
744
|
+
.option('--product <tag>', 'Product tag (defaults to linked project)')
|
|
699
745
|
.option('--json', 'JSON output')
|
|
700
746
|
.action(wrap((verb, opts) => runNotificationMessageCrud(verb, opts)));
|
|
701
747
|
resources.command('types').option('--json', 'JSON output').action(wrap((opts) => runResourcesList(Boolean(opts.json))));
|
|
@@ -781,6 +827,14 @@ db
|
|
|
781
827
|
throw new Error('Specify a verb: connect | query');
|
|
782
828
|
return runDb(verb, { file: opts.file, json: opts.json });
|
|
783
829
|
}));
|
|
830
|
+
const features = program.command('features').description('Code-first Ductape Features (ductape/features/)');
|
|
831
|
+
features
|
|
832
|
+
.command('sync')
|
|
833
|
+
.description('Persist Features from ductape/features/ to the live product by running the project\'s own ' +
|
|
834
|
+
'"features:sync" npm script (see `ductape features sync --help` for the required convention). ' +
|
|
835
|
+
'Run explicitly, like `ductape db migrate` — never automatically on app boot.')
|
|
836
|
+
.argument('[filter]', 'Optional substring passed through to the project\'s features:sync script')
|
|
837
|
+
.action(wrap((filter) => runFeaturesSync({ filter })));
|
|
784
838
|
const graph = program.command('graph').description('Graph runtime (graph-proxy)');
|
|
785
839
|
graph
|
|
786
840
|
.argument('<verb>', 'connect | query | createAction | validateAction | updateAction | …')
|
|
@@ -24,6 +24,11 @@ export declare function createApp(ctx: WorkspaceContext, body: Record<string, un
|
|
|
24
24
|
export declare function updateApp(ctx: WorkspaceContext, appId: string, body: Record<string, unknown>): Promise<unknown>;
|
|
25
25
|
export declare function updateAppViaProxy(ctx: WorkspaceContext, tag: string, body: Record<string, unknown>): Promise<unknown>;
|
|
26
26
|
export declare function deleteApp(ctx: WorkspaceContext, appId: string): Promise<unknown>;
|
|
27
|
+
export declare function listAppActions(ctx: WorkspaceContext, appTag: string): Promise<unknown>;
|
|
28
|
+
export declare function getAppAction(ctx: WorkspaceContext, appTag: string, actionTag: string): Promise<unknown>;
|
|
29
|
+
export declare function createAppAction(ctx: WorkspaceContext, appTag: string, body: Record<string, unknown>): Promise<unknown>;
|
|
30
|
+
export declare function updateAppAction(ctx: WorkspaceContext, appTag: string, actionTag: string, body: Record<string, unknown>): Promise<unknown>;
|
|
31
|
+
export declare function deleteAppAction(ctx: WorkspaceContext, appTag: string, actionTag: string): Promise<unknown>;
|
|
27
32
|
export interface MarketplaceSearchOptions {
|
|
28
33
|
query?: string;
|
|
29
34
|
category?: string;
|
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));
|
|
@@ -325,6 +333,26 @@ export async function deleteApp(ctx, appId) {
|
|
|
325
333
|
};
|
|
326
334
|
return client(ctx).delete(`/apps/v1/${appId}?${new URLSearchParams(q).toString()}`);
|
|
327
335
|
}
|
|
336
|
+
// —— App actions (sdk-proxy — admin op, requires access key) ——
|
|
337
|
+
//
|
|
338
|
+
// resource is a path RELATIVE to each of the app's environment base_urls
|
|
339
|
+
// (e.g. "/v1/users/{id}", not a full URL) — Ductape joins base_url + resource
|
|
340
|
+
// per environment at call time.
|
|
341
|
+
export async function listAppActions(ctx, appTag) {
|
|
342
|
+
return unwrapList(await proxy(ctx).execute('actions', 'list', [appTag]));
|
|
343
|
+
}
|
|
344
|
+
export async function getAppAction(ctx, appTag, actionTag) {
|
|
345
|
+
return unwrapOne(await proxy(ctx).execute('actions', 'fetch', [appTag, actionTag]));
|
|
346
|
+
}
|
|
347
|
+
export async function createAppAction(ctx, appTag, body) {
|
|
348
|
+
return unwrapOne(await proxy(ctx).execute('actions', 'create', [appTag, body]));
|
|
349
|
+
}
|
|
350
|
+
export async function updateAppAction(ctx, appTag, actionTag, body) {
|
|
351
|
+
return unwrapOne(await proxy(ctx).execute('actions', 'update', [appTag, actionTag, body]));
|
|
352
|
+
}
|
|
353
|
+
export async function deleteAppAction(ctx, appTag, actionTag) {
|
|
354
|
+
return proxy(ctx).execute('actions', 'delete', [appTag, actionTag]);
|
|
355
|
+
}
|
|
328
356
|
function searchableMarketplaceText(app) {
|
|
329
357
|
if (!app || typeof app !== 'object')
|
|
330
358
|
return '';
|
package/package.json
CHANGED