@ductape/cli 0.3.1 → 0.3.3

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/README.md CHANGED
@@ -77,6 +77,8 @@ ductape cloud resources list|import|provision …
77
77
  ductape db <verb> # db-proxy runtime
78
78
  ductape graph <verb> # graph-proxy runtime
79
79
  ductape secrets <verb>
80
+ ductape marketplace search payments --json
81
+ ductape marketplace get paystack --json
80
82
  ductape generate payload|snippet …
81
83
  ductape completion bash|zsh
82
84
  ```
@@ -0,0 +1,25 @@
1
+ interface PostmanCandidate {
2
+ uid: string;
3
+ name: string;
4
+ description: string;
5
+ publisher: string;
6
+ publisherUrl?: string;
7
+ collectionUrl?: string;
8
+ logo?: string;
9
+ forkCount: number;
10
+ verified: boolean;
11
+ official: boolean;
12
+ }
13
+ export declare function parseExplorePage(html: string): PostmanCandidate[];
14
+ export declare function reputation(candidate: PostmanCandidate): {
15
+ score: number;
16
+ reasons: string[];
17
+ };
18
+ export declare function runAppsCuratePostman(opts: {
19
+ profile?: string;
20
+ maxPages?: string;
21
+ minScore?: string;
22
+ state?: string;
23
+ apiKey?: string;
24
+ }): Promise<void>;
25
+ export {};
@@ -0,0 +1,328 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import input from '@inquirer/input';
4
+ import select from '@inquirer/select';
5
+ import { importAppFromFile } from '../lib/app-import.js';
6
+ import { getApp, listApps, listMarketplaceCategories, updateApp, } from '../lib/platform-api.js';
7
+ import { success } from '../lib/output.js';
8
+ import { requireWorkspaceContext } from '../lib/workspace-context.js';
9
+ const EXPLORE_URL = 'https://www.postman.com/explore/collections';
10
+ const DEFAULT_STATE = '.ductape/postman-marketplace-curator.json';
11
+ function text(value) {
12
+ return typeof value === 'string' ? value.trim() : '';
13
+ }
14
+ function number(value) {
15
+ return typeof value === 'number' ? value : Number(value) || 0;
16
+ }
17
+ function slugify(value) {
18
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
19
+ }
20
+ function walk(value, visit) {
21
+ if (Array.isArray(value)) {
22
+ value.forEach((item) => walk(item, visit));
23
+ return;
24
+ }
25
+ if (!value || typeof value !== 'object')
26
+ return;
27
+ const object = value;
28
+ visit(object);
29
+ Object.values(object).forEach((item) => walk(item, visit));
30
+ }
31
+ function candidateFromObject(item) {
32
+ const kind = text(item.entityType || item.type || item.elementType).toLowerCase();
33
+ const uid = text(item.uid || item.collectionUid || item.collectionId || item.id);
34
+ const name = text(item.name || item.title);
35
+ const hasCollectionIdentity = Boolean(item.collectionUid || item.collectionId);
36
+ if (!uid || !name || (kind ? !kind.includes('collection') : !hasCollectionIdentity))
37
+ return null;
38
+ const publisherObject = (item.publisher || item.organization || item.team || item.owner || {});
39
+ const publisher = text(item.publisherName || item.teamName || publisherObject.name || publisherObject.username);
40
+ const collectionUrl = text(item.url || item.href || item.publicUrl);
41
+ return {
42
+ uid,
43
+ name,
44
+ description: text(item.description || item.summary),
45
+ publisher,
46
+ publisherUrl: text(publisherObject.url || publisherObject.profileUrl) || undefined,
47
+ collectionUrl: collectionUrl || undefined,
48
+ logo: text(item.logo || item.icon || publisherObject.logo) || undefined,
49
+ forkCount: number(item.forkCount || item.forks || item.fork_count),
50
+ verified: Boolean(item.verified || item.isVerified || publisherObject.verified),
51
+ official: Boolean(item.official || item.isOfficial || item.publisherType === 'official'),
52
+ };
53
+ }
54
+ export function parseExplorePage(html) {
55
+ const found = new Map();
56
+ const scriptPattern = /<script[^>]*>([\s\S]*?)<\/script>/gi;
57
+ for (const match of html.matchAll(scriptPattern)) {
58
+ const raw = match[1]?.trim();
59
+ if (!raw || (!raw.startsWith('{') && !raw.startsWith('[')))
60
+ continue;
61
+ try {
62
+ walk(JSON.parse(raw), (item) => {
63
+ const candidate = candidateFromObject(item);
64
+ if (candidate)
65
+ found.set(candidate.uid, candidate);
66
+ });
67
+ }
68
+ catch {
69
+ // Framework payloads that are not plain JSON are covered by the URL fallback below.
70
+ }
71
+ }
72
+ const linkPattern = /https?:\/\/www\.postman\.com\/([^/"'?#]+)\/([^/"'?#]+)\/(?:collection|documentation)\/([a-zA-Z0-9-]+)(?:\/([^"'?#<]+))?/g;
73
+ for (const match of html.matchAll(linkPattern)) {
74
+ const uid = match[3];
75
+ if (!uid || found.has(uid))
76
+ continue;
77
+ found.set(uid, {
78
+ uid,
79
+ name: (match[4] || uid).split('/')[0].replace(/-/g, ' '),
80
+ description: '',
81
+ publisher: match[1],
82
+ collectionUrl: match[0],
83
+ forkCount: 0,
84
+ verified: false,
85
+ official: false,
86
+ });
87
+ }
88
+ return [...found.values()];
89
+ }
90
+ export function reputation(candidate) {
91
+ let score = 0;
92
+ const reasons = [];
93
+ if (candidate.official) {
94
+ score += 5;
95
+ reasons.push('official publisher');
96
+ }
97
+ if (candidate.verified) {
98
+ score += 4;
99
+ reasons.push('verified publisher');
100
+ }
101
+ if (candidate.forkCount >= 10_000) {
102
+ score += 4;
103
+ reasons.push(`${candidate.forkCount.toLocaleString()} forks`);
104
+ }
105
+ else if (candidate.forkCount >= 1_000) {
106
+ score += 3;
107
+ reasons.push(`${candidate.forkCount.toLocaleString()} forks`);
108
+ }
109
+ else if (candidate.forkCount >= 100) {
110
+ score += 2;
111
+ reasons.push(`${candidate.forkCount.toLocaleString()} forks`);
112
+ }
113
+ const publisher = slugify(candidate.publisher);
114
+ const name = slugify(candidate.name);
115
+ if (publisher && name && (name.includes(publisher) || publisher.includes(name.split('-')[0]))) {
116
+ score += 2;
117
+ reasons.push('publisher/API identity match');
118
+ }
119
+ if (candidate.publisherUrl) {
120
+ score += 1;
121
+ reasons.push('publisher profile');
122
+ }
123
+ return { score, reasons };
124
+ }
125
+ async function fetchJson(url, apiKey) {
126
+ const response = await fetch(url, {
127
+ headers: {
128
+ Accept: 'application/json',
129
+ ...(apiKey ? { 'X-Api-Key': apiKey } : {}),
130
+ },
131
+ });
132
+ if (!response.ok)
133
+ throw new Error(`${url} returned HTTP ${response.status}`);
134
+ return await response.json();
135
+ }
136
+ async function downloadCollection(candidate, apiKey) {
137
+ const urls = [
138
+ `https://api.postman.com/collections/${encodeURIComponent(candidate.uid)}`,
139
+ `https://api.getpostman.com/collections/${encodeURIComponent(candidate.uid)}`,
140
+ `https://www.postman.com/collections/${encodeURIComponent(candidate.uid)}`,
141
+ ];
142
+ let lastError;
143
+ for (const url of urls) {
144
+ try {
145
+ const result = await fetchJson(url, apiKey);
146
+ const collection = result.collection;
147
+ if (collection && typeof collection === 'object')
148
+ return collection;
149
+ if (result.info && result.item)
150
+ return result;
151
+ }
152
+ catch (error) {
153
+ lastError = error;
154
+ }
155
+ }
156
+ throw new Error(`Unable to download ${candidate.uid}. Set POSTMAN_API_KEY to use the official Postman API. ${String(lastError)}`);
157
+ }
158
+ function loadState(file) {
159
+ if (!fs.existsSync(file))
160
+ return { processed: {} };
161
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
162
+ }
163
+ function saveState(file, state) {
164
+ fs.mkdirSync(path.dirname(file), { recursive: true });
165
+ fs.writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`);
166
+ }
167
+ function suggestDomains(candidate, domains) {
168
+ const haystack = `${candidate.name} ${candidate.description}`.toLowerCase();
169
+ const aliases = {
170
+ payments: ['payment', 'checkout', 'payout', 'fintech'],
171
+ 'subscriptions-billing': ['subscription', 'billing', 'invoice'],
172
+ 'crm-sales': ['crm', 'sales', 'customer support'],
173
+ messaging: ['sms', 'message', 'chat', 'push'],
174
+ email: ['email'],
175
+ 'ai-machine-learning': [' ai ', 'machine learning', 'model'],
176
+ 'developer-tools': ['developer', 'api', 'cloud', 'code'],
177
+ 'maps-location': ['map', 'location', 'geocod'],
178
+ 'shipping-delivery': ['shipping', 'delivery', 'logistics'],
179
+ };
180
+ return domains
181
+ .filter((domain) => {
182
+ const slug = text(domain.slug);
183
+ return (aliases[slug] || []).some((term) => ` ${haystack} `.includes(term));
184
+ })
185
+ .map((domain) => text(domain.slug));
186
+ }
187
+ function domainIds(slugs, domains) {
188
+ return domains
189
+ .filter((domain) => slugs.includes(text(domain.slug)))
190
+ .map((domain) => text(domain._id))
191
+ .filter(Boolean);
192
+ }
193
+ function marketplaceMetadata(candidate, collection) {
194
+ const info = (collection.info || {});
195
+ const description = text(info.description) || candidate.description ||
196
+ `${candidate.name} API collection published by ${candidate.publisher}.`;
197
+ const requestCount = JSON.stringify(collection).match(/"request"\s*:/g)?.length ?? 0;
198
+ const source = candidate.collectionUrl || `https://www.postman.com/explore/collections`;
199
+ const aboutText = [
200
+ description,
201
+ `Imported from the public Postman API Network collection maintained by ${candidate.publisher || 'its publisher'}.`,
202
+ `This version contains approximately ${requestCount} documented requests. Review authentication, environment variables, and provider terms before production use.`,
203
+ `Source: ${source}`,
204
+ ].join('\n\n').slice(0, 5000);
205
+ const aboutHTML = `<p>${aboutText
206
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
207
+ .replace(/\n\n/g, '</p><p>')}</p>`.slice(0, 5000);
208
+ return {
209
+ app_name: candidate.name.slice(0, 100),
210
+ description: description.slice(0, 1000),
211
+ aboutText,
212
+ aboutHTML,
213
+ website: candidate.publisherUrl || candidate.collectionUrl,
214
+ ...(candidate.logo?.startsWith('http') ? { logo: candidate.logo } : {}),
215
+ require_whitelist: false,
216
+ };
217
+ }
218
+ async function findImportedApp(ctx, beforeIds, collectionName) {
219
+ const apps = await listApps(ctx, 'all');
220
+ const created = apps.find((app) => !beforeIds.has(text(app._id)));
221
+ const named = apps.find((app) => text(app.app_name) === collectionName);
222
+ const result = created || named;
223
+ if (!result?._id)
224
+ throw new Error(`Import completed but app "${collectionName}" could not be located`);
225
+ return result;
226
+ }
227
+ export async function runAppsCuratePostman(opts) {
228
+ const ctx = requireWorkspaceContext({ profile: opts.profile });
229
+ const maxPages = Math.max(1, Number(opts.maxPages || 250));
230
+ const minScore = Math.max(0, Number(opts.minScore || 5));
231
+ const stateFile = path.resolve(opts.state || DEFAULT_STATE);
232
+ const state = loadState(stateFile);
233
+ const domains = await listMarketplaceCategories(ctx);
234
+ const apiKey = opts.apiKey || process.env.POSTMAN_API_KEY;
235
+ let emptyPages = 0;
236
+ for (let page = 1; page <= maxPages && emptyPages < 2; page++) {
237
+ const url = `${EXPLORE_URL}?sort=forkCount&page=${page}&filter=this_week`;
238
+ const response = await fetch(url, { headers: { Accept: 'text/html' } });
239
+ if (!response.ok)
240
+ throw new Error(`Postman Explore page ${page} returned HTTP ${response.status}`);
241
+ const candidates = parseExplorePage(await response.text());
242
+ if (candidates.length === 0) {
243
+ emptyPages++;
244
+ continue;
245
+ }
246
+ emptyPages = 0;
247
+ for (const candidate of candidates) {
248
+ if (state.processed[candidate.uid])
249
+ continue;
250
+ const trust = reputation(candidate);
251
+ if (trust.score < minScore)
252
+ continue;
253
+ console.log(`\n${candidate.name} by ${candidate.publisher || 'unknown publisher'}`);
254
+ console.log(`Reputation ${trust.score}: ${trust.reasons.join(', ') || 'limited evidence'}`);
255
+ if (candidate.collectionUrl)
256
+ console.log(candidate.collectionUrl);
257
+ const decision = await select({
258
+ message: 'Add or update this Ductape app?',
259
+ choices: [
260
+ { name: 'Skip', value: 'skip' },
261
+ { name: 'Add as new app', value: 'add' },
262
+ { name: 'Update existing app/version', value: 'update' },
263
+ { name: 'Stop and save progress', value: 'stop' },
264
+ ],
265
+ });
266
+ if (decision === 'stop') {
267
+ saveState(stateFile, state);
268
+ return;
269
+ }
270
+ if (decision === 'skip') {
271
+ state.processed[candidate.uid] = 'skipped';
272
+ saveState(stateFile, state);
273
+ continue;
274
+ }
275
+ const defaultTag = `${slugify(candidate.publisher || candidate.name)}:${slugify(candidate.name)}`;
276
+ const appTag = await input({ message: 'Ductape app tag', default: defaultTag });
277
+ const version = await input({
278
+ message: 'App version',
279
+ default: new Date().toISOString().slice(0, 10).replace(/-/g, '.'),
280
+ });
281
+ const suggested = suggestDomains(candidate, domains);
282
+ const selectedSlugs = (await input({
283
+ message: 'Domain slugs (comma-separated)',
284
+ default: suggested.join(','),
285
+ })).split(',').map((item) => item.trim()).filter(Boolean);
286
+ const selectedDomainIds = domainIds(selectedSlugs, domains);
287
+ if (selectedDomainIds.length !== selectedSlugs.length) {
288
+ const known = new Set(domains.map((domain) => text(domain.slug)));
289
+ const unknown = selectedSlugs.filter((slug) => !known.has(slug));
290
+ throw new Error(`Unknown marketplace domain slug(s): ${unknown.join(', ')}`);
291
+ }
292
+ const collection = await downloadCollection(candidate, apiKey);
293
+ const tempFile = path.join(path.dirname(stateFile), `${slugify(candidate.uid)}.postman_collection.json`);
294
+ fs.mkdirSync(path.dirname(tempFile), { recursive: true });
295
+ fs.writeFileSync(tempFile, `${JSON.stringify(collection, null, 2)}\n`);
296
+ const before = await listApps(ctx, 'all');
297
+ const beforeIds = new Set(before.map((app) => text(app._id)));
298
+ let target;
299
+ if (decision === 'update') {
300
+ target = await getApp(ctx, { tag: appTag });
301
+ if (!target?._id)
302
+ throw new Error(`Existing app not found: ${appTag}`);
303
+ }
304
+ await importAppFromFile(ctx, {
305
+ filePath: tempFile,
306
+ format: 'postman',
307
+ appId: target ? text(target._id) : undefined,
308
+ update: decision === 'update',
309
+ version,
310
+ });
311
+ target = target || await findImportedApp(ctx, beforeIds, text(collection.info?.name) || candidate.name);
312
+ await updateApp(ctx, text(target._id), {
313
+ component: 'app',
314
+ tag: appTag,
315
+ version,
316
+ domains: selectedDomainIds,
317
+ status: 'pending_review',
318
+ workspace_id: ctx.workspaceId,
319
+ ...marketplaceMetadata(candidate, collection),
320
+ });
321
+ fs.unlinkSync(tempFile);
322
+ state.processed[candidate.uid] = decision === 'update' ? 'updated' : 'imported';
323
+ saveState(stateFile, state);
324
+ success(`${candidate.name} submitted for marketplace review as ${appTag}@${version}`);
325
+ }
326
+ }
327
+ saveState(stateFile, state);
328
+ }
@@ -0,0 +1,8 @@
1
+ export declare function runMarketplace(verb: string, opts: {
2
+ profile?: string;
3
+ query?: string;
4
+ category?: string;
5
+ limit?: string;
6
+ tag?: string;
7
+ json?: boolean;
8
+ }, args: string[]): Promise<void>;
@@ -0,0 +1,30 @@
1
+ import { getMarketplaceApp, listMarketplaceCategories, searchMarketplaceApps, } from '../lib/platform-api.js';
2
+ import { printJson } from '../lib/output.js';
3
+ import { requireWorkspaceContext } from '../lib/workspace-context.js';
4
+ export async function runMarketplace(verb, opts, args) {
5
+ const ctx = requireWorkspaceContext({ profile: opts.profile });
6
+ let result;
7
+ switch (verb.toLowerCase()) {
8
+ case 'search':
9
+ case 'list':
10
+ result = await searchMarketplaceApps(ctx, {
11
+ query: opts.query ?? (args.join(' ') || undefined),
12
+ category: opts.category,
13
+ limit: opts.limit ? Number(opts.limit) : undefined,
14
+ });
15
+ break;
16
+ case 'get': {
17
+ const tag = opts.tag ?? args[0];
18
+ if (!tag)
19
+ throw new Error('marketplace get requires <tag> or --tag <tag>');
20
+ result = await getMarketplaceApp(ctx, tag);
21
+ break;
22
+ }
23
+ case 'categories':
24
+ result = await listMarketplaceCategories(ctx);
25
+ break;
26
+ default:
27
+ throw new Error('Unknown marketplace verb. Use: search, list, get, categories');
28
+ }
29
+ printJson(result, Boolean(opts.json));
30
+ }
@@ -149,6 +149,13 @@ interface MigrationPlan {
149
149
  ai_working_standards: {
150
150
  global: string[];
151
151
  languages: Record<Language, string[]>;
152
+ feature_classification: {
153
+ definition: string;
154
+ categories: Record<'FEATURE' | 'FEATURE_STEP' | 'DOMAIN_SERVICE' | 'UTILITY' | 'INFRASTRUCTURE_ADAPTER', string>;
155
+ discovery: string[];
156
+ required_output_fields: string[];
157
+ grouping_rule: string;
158
+ };
152
159
  };
153
160
  output_paths: {
154
161
  plan: string;
@@ -479,6 +479,8 @@ const FILE_REVIEW_CHECKLIST = [
479
479
  'configuration and secret-name references',
480
480
  'database reads, writes, transactions, schemas, and migrations',
481
481
  'internal events, queues, schedules, and background work',
482
+ 'named capability candidates across exports, public methods, controllers, consumers, jobs, repeated orchestration, typed operations, documentation, and routes',
483
+ 'whether related low-level operations collectively form one FEATURE, FEATURE_STEP, DOMAIN_SERVICE, UTILITY, or INFRASTRUCTURE_ADAPTER boundary',
482
484
  'external HTTP/API integrations and authentication',
483
485
  'sessions, actor propagation, authorization, and privacy',
484
486
  'cache, storage, notifications, graph, and vector usage',
@@ -630,6 +632,27 @@ function buildPlan(source, files, scanPolicy, mode, destination, productTag, pro
630
632
  'Preserve request scope and dispose SDK-owned resources through the host lifecycle.',
631
633
  ],
632
634
  },
635
+ feature_classification: {
636
+ definition: 'A named, reusable product capability with a stable input/output contract that benefits from managed execution, composition, observability, retries, versioning, policy enforcement, or explicit execution steps. It may be synchronous and local.',
637
+ categories: {
638
+ FEATURE: 'Independently meaningful product capability; make it a standalone Ductape Feature.',
639
+ FEATURE_STEP: 'Meaningful stage without a useful independent boundary; expose it as a named ctx.step inside another Feature.',
640
+ DOMAIN_SERVICE: 'Reusable domain logic without a useful independent managed-execution boundary; keep it as domain logic called by a Feature.',
641
+ UTILITY: 'Low-level hashing, formatting, redaction, normalization, conversion, or type-guard helper; keep it as a utility.',
642
+ INFRASTRUCTURE_ADAPTER: 'Database, Event, HTTP, cache, storage, transport, provider, or framework integration; keep it as an infrastructure adapter.',
643
+ },
644
+ discovery: [
645
+ 'Exported functions and public service methods; export alone is neither sufficient nor necessary.',
646
+ 'Controller entry points, Event consumers, scheduled jobs, and repeated orchestration sequences.',
647
+ 'Domain operations reused from several locations and functions with substantial typed inputs and outputs.',
648
+ 'Functions composing several meaningful domain stages and product terminology in documentation and API routes.',
649
+ ],
650
+ required_output_fields: [
651
+ 'candidate', 'classification', 'executionStyle', 'evidence', 'suggestedSteps',
652
+ 'signalsRequired', 'eventsRequired', 'recommendation',
653
+ ],
654
+ grouping_rule: 'Group related low-level operations into one coherent capability candidate; never recommend one Feature per exported function.',
655
+ },
633
656
  },
634
657
  output_paths: {
635
658
  plan: planPath,
@@ -665,6 +688,20 @@ ${plan.ai_working_standards.global.map((rule) => `- ${rule}`).join('\n')}
665
688
 
666
689
  ${languageSections || 'No supported server language was detected. The AI must inspect the repository before proceeding.'}
667
690
 
691
+ ## Feature capability classification
692
+
693
+ ${plan.ai_working_standards.feature_classification.definition}
694
+
695
+ ${Object.entries(plan.ai_working_standards.feature_classification.categories)
696
+ .map(([category, rule]) => `- **${category}**: ${rule}`).join('\n')}
697
+
698
+ Discovery must cover:
699
+ ${plan.ai_working_standards.feature_classification.discovery.map((item) => `- ${item}`).join('\n')}
700
+
701
+ - ${plan.ai_working_standards.feature_classification.grouping_rule}
702
+ - Every recommendation must include: ${plan.ai_working_standards.feature_classification.required_output_fields.join(', ')}.
703
+ - Signals and Events are optional; synchronous multi-step capabilities remain valid Feature candidates.
704
+
668
705
  ## Workflow
669
706
 
670
707
  1. Before this plan, create missing project-level E2E tests, run them against the original codebase, and record a passing checksum-bound \`migration-e2e\` baseline.
package/dist/index.js CHANGED
@@ -25,6 +25,7 @@ import { runCompletion } from './commands/completion.js';
25
25
  import { runProducts, runProductApps, runProductComponents, runProductEnvironments } from './commands/products.js';
26
26
  import { runApps } from './commands/apps.js';
27
27
  import { runAppsImport } from './commands/apps-import.js';
28
+ import { runMarketplace } from './commands/marketplace.js';
28
29
  import { runApply } from './commands/apply.js';
29
30
  import { runMigrateCodebase } from './commands/migrate-codebase.js';
30
31
  import { runMigrationReviewInit, runMigrationReviewRecord, runMigrationReviewFollowUp, runMigrationReviewExclude, runMigrationReviewParity, runMigrationReviewReopen, runMigrationReviewMove, runMigrationReviewMerge, runMigrationReviewSign, runMigrationReviewSnapshot, runMigrationReviewVerifySignature, runMigrationReviewValidate, } from './commands/migration-review.js';
@@ -563,15 +564,20 @@ productEnvironments
563
564
  productEnvironments
564
565
  .command('create <product_tag>')
565
566
  .description('Idempotently create a product environment and verify persistence')
566
- .requiredOption('-f, --file <path>', 'Environment JSON: env_name, description, slug, active?')
567
+ // NOTE: cannot use -f/--file here it collides with the parent `products` command's own
568
+ // -f/--file option (used by `products create`/`products update`). Commander resolves a flag
569
+ // shared by an ancestor and a descendant command against the ancestor, so the value never
570
+ // reaches this subcommand's own opts, and requiredOption fails even when -f is supplied.
571
+ .requiredOption('--env-file <path>', 'Environment JSON: env_name, description, slug, active?')
567
572
  .option('--json', 'JSON output')
568
- .action(wrap((productTag, opts) => runProductEnvironments('create', { product: productTag, file: opts.file, json: opts.json }, [])));
573
+ .action(wrap((productTag, opts) => runProductEnvironments('create', { product: productTag, file: opts.envFile, json: opts.json }, [])));
569
574
  productEnvironments
570
575
  .command('update <product_tag> <slug>')
571
576
  .description('Update a product environment and verify persistence')
572
- .requiredOption('-f, --file <path>', 'Environment patch JSON')
577
+ // See NOTE above on `create` — same -f/--file collision with the parent `products` command.
578
+ .requiredOption('--env-file <path>', 'Environment patch JSON')
573
579
  .option('--json', 'JSON output')
574
- .action(wrap((productTag, slug, opts) => runProductEnvironments('update', { product: productTag, slug, file: opts.file, json: opts.json }, [])));
580
+ .action(wrap((productTag, slug, opts) => runProductEnvironments('update', { product: productTag, slug, file: opts.envFile, json: opts.json }, [])));
575
581
  productEnvironments
576
582
  .command('get <product_tag> [slug]')
577
583
  .description('Fetch a single product environment by slug')
@@ -615,6 +621,18 @@ apps
615
621
  version: opts.version,
616
622
  json: opts.json,
617
623
  })));
624
+ program
625
+ .command('marketplace')
626
+ .description('Discover public apps by capability and inspect their actions')
627
+ .argument('<verb>', 'search | list | get | categories')
628
+ .argument('[terms...]', 'Capability terms for search, or app tag for get')
629
+ .option('--profile <name>')
630
+ .option('-q, --query <query>', 'Capability search query')
631
+ .option('-c, --category <category>', 'Filter by category/domain name')
632
+ .option('-l, --limit <count>', 'Maximum results', '20')
633
+ .option('-t, --tag <tag>', 'Marketplace app tag for get')
634
+ .option('--json', 'JSON output')
635
+ .action(wrap((verb, terms, opts) => runMarketplace(verb, opts, terms)));
618
636
  const events = program.command('events').description('Message broker CRUD (sdk-proxy, requires access key login)');
619
637
  const eventTopics = events.command('topics').description('Topic CRUD for a message broker');
620
638
  eventTopics
@@ -17,6 +17,14 @@ export declare function createApp(ctx: WorkspaceContext, body: Record<string, un
17
17
  export declare function updateApp(ctx: WorkspaceContext, appId: string, body: Record<string, unknown>): Promise<unknown>;
18
18
  export declare function updateAppViaProxy(ctx: WorkspaceContext, tag: string, body: Record<string, unknown>): Promise<unknown>;
19
19
  export declare function deleteApp(ctx: WorkspaceContext, appId: string): Promise<unknown>;
20
+ export interface MarketplaceSearchOptions {
21
+ query?: string;
22
+ category?: string;
23
+ limit?: number;
24
+ }
25
+ export declare function listMarketplaceCategories(ctx: WorkspaceContext): Promise<unknown[]>;
26
+ export declare function getMarketplaceApp(ctx: WorkspaceContext, tag: string): Promise<unknown>;
27
+ export declare function searchMarketplaceApps(ctx: WorkspaceContext, opts: MarketplaceSearchOptions): Promise<unknown[]>;
20
28
  export interface TierQuery {
21
29
  provider?: string;
22
30
  resource_type?: string;
@@ -135,6 +135,61 @@ export async function deleteApp(ctx, appId) {
135
135
  };
136
136
  return client(ctx).delete(`/apps/v1/${appId}?${new URLSearchParams(q).toString()}`);
137
137
  }
138
+ function searchableMarketplaceText(app) {
139
+ if (!app || typeof app !== 'object')
140
+ return '';
141
+ const value = app;
142
+ const version = value.currentVersion && typeof value.currentVersion === 'object'
143
+ ? value.currentVersion
144
+ : {};
145
+ const actions = Array.isArray(version.actions) ? version.actions : [];
146
+ const webhooks = Array.isArray(version.webhooks) ? version.webhooks : [];
147
+ return [
148
+ value.app_name,
149
+ value.tag,
150
+ value.description,
151
+ value.aboutText,
152
+ ...(Array.isArray(value.domains) ? value.domains : []),
153
+ ...actions.flatMap((action) => {
154
+ if (!action || typeof action !== 'object')
155
+ return [];
156
+ const item = action;
157
+ return [item.name, item.tag, item.description];
158
+ }),
159
+ ...webhooks.flatMap((webhook) => {
160
+ if (!webhook || typeof webhook !== 'object')
161
+ return [];
162
+ const item = webhook;
163
+ return [item.name, item.tag, item.description];
164
+ }),
165
+ ].filter((item) => typeof item === 'string').join(' ').toLowerCase();
166
+ }
167
+ export async function listMarketplaceCategories(ctx) {
168
+ const result = await client(ctx).getPath('/apps/v1/domains');
169
+ return unwrapList(result);
170
+ }
171
+ export async function getMarketplaceApp(ctx, tag) {
172
+ const result = await client(ctx).getPath('/apps/v1/fetch/tag', { tag });
173
+ return unwrapOne(result);
174
+ }
175
+ export async function searchMarketplaceApps(ctx, opts) {
176
+ const result = await client(ctx).getPath('/apps/v1/domains/all');
177
+ const apps = unwrapList(result);
178
+ const terms = (opts.query ?? '').toLowerCase().split(/\s+/).filter(Boolean);
179
+ const category = opts.category?.toLowerCase();
180
+ const filtered = apps.filter((app) => {
181
+ const text = searchableMarketplaceText(app);
182
+ if (terms.length > 0 && !terms.every((term) => text.includes(term)))
183
+ return false;
184
+ if (!category)
185
+ return true;
186
+ if (!app || typeof app !== 'object')
187
+ return false;
188
+ const domains = app.domains;
189
+ return Array.isArray(domains) && domains.some((domain) => typeof domain === 'string' && domain.toLowerCase().includes(category));
190
+ });
191
+ return filtered.slice(0, Math.max(1, Math.min(opts.limit ?? 20, 100)));
192
+ }
138
193
  export async function listCloudTiers(ctx, q) {
139
194
  const params = workspaceAuthQuery(ctx);
140
195
  if (q.provider)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ductape/cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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",