@ductape/cli 0.2.19 → 0.2.22

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.
@@ -13,20 +13,62 @@ async function syncItems(type, items, productTag, proxy, dryRun) {
13
13
  const listResult = await proxy.execute(module, 'list', buildCrudParams(module, 'list', productTag, {}));
14
14
  const existingList = Array.isArray(listResult) ? listResult : [];
15
15
  const existingTags = new Set(existingList.map((r) => r.tag ?? '').filter(Boolean));
16
+ const existingNotificationMessages = new Set();
17
+ if (type === 'notifications') {
18
+ for (const notification of existingList) {
19
+ const record = notification;
20
+ for (const message of record.messages ?? []) {
21
+ if (record.tag && message.tag) {
22
+ existingNotificationMessages.add(message.tag.includes(':') ? message.tag : `${record.tag}:${message.tag}`);
23
+ }
24
+ }
25
+ }
26
+ }
16
27
  for (const item of items) {
17
28
  if (!item.tag) {
18
29
  console.warn(` [${type}] skipped — missing "tag" field`);
19
30
  continue;
20
31
  }
21
32
  const verb = existingTags.has(item.tag) ? 'update' : 'create';
33
+ const nestedMessages = type === 'notifications' && Array.isArray(item.messages)
34
+ ? item.messages
35
+ : [];
36
+ const componentBody = type === 'notifications'
37
+ ? Object.fromEntries(Object.entries(item).filter(([key]) => key !== 'messages'))
38
+ : item;
22
39
  if (dryRun) {
23
40
  console.log(` [${type}] would ${verb}: ${item.tag}`);
41
+ for (const message of nestedMessages) {
42
+ const rawTag = String(message.tag ?? '');
43
+ const fullTag = rawTag.includes(':') ? rawTag : `${item.tag}:${rawTag}`;
44
+ const messageVerb = existingNotificationMessages.has(fullTag) ? 'update' : 'create';
45
+ console.log(` [notifications] would ${messageVerb} message: ${fullTag}`);
46
+ }
24
47
  continue;
25
48
  }
26
49
  try {
27
- const params = buildCrudParams(module, verb, productTag, { tag: item.tag, body: item });
50
+ const params = buildCrudParams(module, verb, productTag, {
51
+ tag: item.tag,
52
+ body: componentBody,
53
+ });
28
54
  await proxy.execute(module, verb, params);
29
55
  console.log(` [${type}] ${verb}d: ${item.tag}`);
56
+ for (const message of nestedMessages) {
57
+ const rawTag = String(message.tag ?? '');
58
+ if (!rawTag)
59
+ throw new Error(`notification "${item.tag}" contains a message without "tag"`);
60
+ const fullTag = rawTag.includes(':') ? rawTag : `${item.tag}:${rawTag}`;
61
+ if (!fullTag.startsWith(`${item.tag}:`)) {
62
+ throw new Error(`message tag "${fullTag}" must belong to notification "${item.tag}"`);
63
+ }
64
+ const messageBody = { ...message, tag: fullTag };
65
+ const messageVerb = existingNotificationMessages.has(fullTag) ? 'update' : 'create';
66
+ const messageParams = messageVerb === 'create'
67
+ ? [productTag, messageBody]
68
+ : [productTag, fullTag, messageBody];
69
+ await proxy.execute('notifications', `messages.${messageVerb}`, messageParams);
70
+ console.log(` [notifications] ${messageVerb}d message: ${fullTag}`);
71
+ }
30
72
  }
31
73
  catch (err) {
32
74
  console.error(` [${type}] ${verb} failed for "${item.tag}": ${err instanceof Error ? err.message : String(err)}`);
@@ -54,8 +96,7 @@ export async function runApply(type, opts) {
54
96
  items = loaders[t]();
55
97
  }
56
98
  catch (err) {
57
- console.error(`[${t}] parse error: ${err instanceof Error ? err.message : String(err)}`);
58
- continue;
99
+ fail(`[${t}] parse error: ${err instanceof Error ? err.message : String(err)}`);
59
100
  }
60
101
  if (items === null) {
61
102
  console.log(`[${t}] no ductape/${t}.json found — skipping`);
@@ -12,6 +12,12 @@ export declare function runProductApps(verb: string, opts: {
12
12
  product?: string;
13
13
  json?: boolean;
14
14
  }, extraArgs: string[]): Promise<void>;
15
+ export declare function runProductComponents(verb: string, opts: {
16
+ profile?: string;
17
+ tag?: string;
18
+ type?: string;
19
+ json?: boolean;
20
+ }): Promise<void>;
15
21
  export declare function runProductEnvironments(verb: string, opts: {
16
22
  product?: string;
17
23
  tag?: string;
@@ -73,6 +73,91 @@ export async function runProductApps(verb, opts, extraArgs) {
73
73
  const result = await listProductApps(ctx, productId);
74
74
  printJson(result, Boolean(opts.json));
75
75
  }
76
+ const COMPONENT_KEYS = {
77
+ apps: ['apps'],
78
+ databases: ['databases'],
79
+ storage: ['storage'],
80
+ caches: ['caches'],
81
+ graphs: ['graphs'],
82
+ vectors: ['vectors', 'vector_databases'],
83
+ notifications: ['notifications'],
84
+ events: ['message_brokers', 'messageBrokers', 'brokers'],
85
+ sessions: ['sessions'],
86
+ features: ['features'],
87
+ jobs: ['jobs'],
88
+ };
89
+ function unwrapProduct(value) {
90
+ if (!value || typeof value !== 'object')
91
+ return {};
92
+ const record = value;
93
+ return record.data && typeof record.data === 'object'
94
+ ? record.data
95
+ : record;
96
+ }
97
+ function summarizeComponent(value) {
98
+ const component = (value && typeof value === 'object' ? value : {});
99
+ const summary = {};
100
+ for (const key of ['_id', 'id', 'tag', 'name', 'description', 'type', 'status']) {
101
+ if (component[key] !== undefined)
102
+ summary[key] = component[key];
103
+ }
104
+ if (Array.isArray(component.envs)) {
105
+ summary.envs = component.envs.map((env) => {
106
+ const item = (env && typeof env === 'object' ? env : {});
107
+ return { slug: item.slug, type: item.type, cloud: item.cloud };
108
+ });
109
+ }
110
+ for (const nestedKey of ['topics', 'messages']) {
111
+ if (Array.isArray(component[nestedKey])) {
112
+ summary[nestedKey] = component[nestedKey].map((nested) => {
113
+ const item = (nested && typeof nested === 'object' ? nested : {});
114
+ return {
115
+ tag: item.tag,
116
+ name: item.name,
117
+ description: item.description,
118
+ };
119
+ });
120
+ }
121
+ }
122
+ return summary;
123
+ }
124
+ export async function runProductComponents(verb, opts) {
125
+ const normalizedVerb = verb.toLowerCase();
126
+ if (!['list', 'get'].includes(normalizedVerb)) {
127
+ throw new Error('Product components verb must be: list | get');
128
+ }
129
+ if (!opts.tag)
130
+ throw new Error('--tag <product_tag> is required');
131
+ const ctx = requireWorkspaceContext({ profile: opts.profile });
132
+ const product = unwrapProduct(await getProduct(ctx, { tag: opts.tag }));
133
+ const selectedTypes = normalizedVerb === 'get'
134
+ ? [opts.type?.toLowerCase()].filter(Boolean)
135
+ : Object.keys(COMPONENT_KEYS);
136
+ if (normalizedVerb === 'get' && !selectedTypes.length) {
137
+ throw new Error('--type <component_type> is required for get');
138
+ }
139
+ for (const type of selectedTypes) {
140
+ if (!COMPONENT_KEYS[type]) {
141
+ throw new Error(`Unknown component type "${type}". Use: ${Object.keys(COMPONENT_KEYS).join(', ')}`);
142
+ }
143
+ }
144
+ const components = {};
145
+ for (const type of selectedTypes) {
146
+ const sourceKey = COMPONENT_KEYS[type].find((key) => Array.isArray(product[key]));
147
+ components[type] = sourceKey
148
+ ? product[sourceKey].map(summarizeComponent)
149
+ : [];
150
+ }
151
+ const envSource = Array.isArray(product.envs) ? product.envs : [];
152
+ printJson({
153
+ tag: product.tag ?? opts.tag,
154
+ environments: envSource.map((env) => {
155
+ const item = (env && typeof env === 'object' ? env : {});
156
+ return { slug: item.slug, name: item.name ?? item.env_name };
157
+ }),
158
+ components,
159
+ }, Boolean(opts.json));
160
+ }
76
161
  export async function runProductEnvironments(verb, opts, extraArgs) {
77
162
  const v = verb.toLowerCase();
78
163
  const allowed = ['list', 'get', 'fetch'];
@@ -59,6 +59,21 @@ export async function runEventTopicCrud(verb, opts, extraArgs) {
59
59
  }
60
60
  const proxy = getSdkProxy(session);
61
61
  const result = await proxy.execute('messageBrokers', method, params);
62
+ if (crud === 'create') {
63
+ const fullTag = String(body?.tag ?? '');
64
+ if (!fullTag.includes(':')) {
65
+ throw new Error('Topic create response could not be verified because body.tag is invalid');
66
+ }
67
+ const verified = await proxy.execute('messageBrokers', 'topics.fetch', [productTag, fullTag]);
68
+ if (!verified) {
69
+ throw new Error(`Topic creation returned without an error, but "${fullTag}" was not found during verification`);
70
+ }
71
+ printJson({ created: true, topic: verified }, Boolean(opts.json));
72
+ return;
73
+ }
74
+ if (crud === 'get' && !result) {
75
+ throw new Error(`Topic "${tag}" was not found`);
76
+ }
62
77
  printJson(result, Boolean(opts.json));
63
78
  }
64
79
  export async function runNotificationMessageCrud(verb, opts) {
@@ -91,7 +106,25 @@ export async function runNotificationMessageCrud(verb, opts) {
91
106
  throw new Error('--tag <notification:message> is required');
92
107
  params = [product, opts.tag, body ?? {}];
93
108
  }
94
- printJson(await proxy.execute('notifications', method, params), Boolean(opts.json));
109
+ try {
110
+ printJson(await proxy.execute('notifications', method, params), Boolean(opts.json));
111
+ }
112
+ catch (error) {
113
+ if (crud !== 'list' || !opts.notification)
114
+ throw error;
115
+ // Compatibility fallback for platform versions whose message lookup uses a stale product
116
+ // snapshot while the component inventory already contains the notification.
117
+ const listed = await proxy.execute('notifications', 'list', buildCrudParams('notifications', 'list', product, {}));
118
+ const notifications = Array.isArray(listed)
119
+ ? listed
120
+ : Array.isArray(listed?.data)
121
+ ? listed.data
122
+ : [];
123
+ const notification = notifications.find((item) => item.tag === opts.notification);
124
+ if (!notification)
125
+ throw error;
126
+ printJson(notification.messages ?? [], Boolean(opts.json));
127
+ }
95
128
  }
96
129
  export async function runResourceCrud(typeName, verb, opts, extraArgs) {
97
130
  const crud = verb.toLowerCase();
package/dist/index.js CHANGED
@@ -22,7 +22,7 @@ import { runSecrets } from './commands/secrets.js';
22
22
  import { runWorkspacesCurrent, runWorkspacesList, runWorkspacesRefresh, runWorkspacesUse, } from './commands/workspaces.js';
23
23
  import { runGeneratePayload, runGenerateSnippet } from './commands/generate.js';
24
24
  import { runCompletion } from './commands/completion.js';
25
- import { runProducts, runProductApps, runProductEnvironments } from './commands/products.js';
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
28
  import { runApply } from './commands/apply.js';
@@ -192,6 +192,22 @@ productApps
192
192
  .option('--product <id>', 'Product _id')
193
193
  .option('--json', 'JSON output')
194
194
  .action(wrap((opts) => runProductApps('list', opts, [])));
195
+ const productComponents = products
196
+ .command('components')
197
+ .description('Compact, non-secret product component inventory');
198
+ productComponents
199
+ .command('list')
200
+ .requiredOption('-t, --tag <tag>', 'Product tag')
201
+ .option('--profile <name>')
202
+ .option('--json', 'JSON output')
203
+ .action(wrap((opts) => runProductComponents('list', opts)));
204
+ productComponents
205
+ .command('get')
206
+ .requiredOption('-t, --tag <tag>', 'Product tag')
207
+ .requiredOption('--type <type>', 'Component type, e.g. notifications or events')
208
+ .option('--profile <name>')
209
+ .option('--json', 'JSON output')
210
+ .action(wrap((opts) => runProductComponents('get', opts)));
195
211
  const productEnvironments = products
196
212
  .command('environments')
197
213
  .description('Product environments (list, get)');
package/dist/lib/http.js CHANGED
@@ -13,6 +13,10 @@ export async function parseJsonResponse(res, url) {
13
13
  return JSON.parse(text);
14
14
  }
15
15
  catch {
16
+ if (res.status === 504) {
17
+ throw new Error(`Ductape proxy gateway timeout (HTTP 504) for ${url}. The operation did not return a ` +
18
+ 'verifiable result; retry only after checking whether the asset was created.');
19
+ }
16
20
  const htmlHint = text.trimStart().startsWith('<')
17
21
  ? [
18
22
  '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ductape/cli",
3
- "version": "0.2.19",
3
+ "version": "0.2.22",
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",
@@ -36,6 +36,19 @@ async function syncItems(
36
36
  const existingTags = new Set(
37
37
  existingList.map((r) => (r as { tag?: string }).tag ?? '').filter(Boolean),
38
38
  );
39
+ const existingNotificationMessages = new Set<string>();
40
+ if (type === 'notifications') {
41
+ for (const notification of existingList) {
42
+ const record = notification as { tag?: string; messages?: Array<{ tag?: string }> };
43
+ for (const message of record.messages ?? []) {
44
+ if (record.tag && message.tag) {
45
+ existingNotificationMessages.add(
46
+ message.tag.includes(':') ? message.tag : `${record.tag}:${message.tag}`,
47
+ );
48
+ }
49
+ }
50
+ }
51
+ }
39
52
 
40
53
  for (const item of items) {
41
54
  if (!item.tag) {
@@ -44,16 +57,52 @@ async function syncItems(
44
57
  }
45
58
 
46
59
  const verb: 'create' | 'update' = existingTags.has(item.tag) ? 'update' : 'create';
60
+ const nestedMessages =
61
+ type === 'notifications' && Array.isArray(item.messages)
62
+ ? item.messages as Array<Record<string, unknown>>
63
+ : [];
64
+ const componentBody =
65
+ type === 'notifications'
66
+ ? Object.fromEntries(Object.entries(item).filter(([key]) => key !== 'messages'))
67
+ : item;
47
68
 
48
69
  if (dryRun) {
49
70
  console.log(` [${type}] would ${verb}: ${item.tag}`);
71
+ for (const message of nestedMessages) {
72
+ const rawTag = String(message.tag ?? '');
73
+ const fullTag = rawTag.includes(':') ? rawTag : `${item.tag}:${rawTag}`;
74
+ const messageVerb = existingNotificationMessages.has(fullTag) ? 'update' : 'create';
75
+ console.log(` [notifications] would ${messageVerb} message: ${fullTag}`);
76
+ }
50
77
  continue;
51
78
  }
52
79
 
53
80
  try {
54
- const params = buildCrudParams(module, verb, productTag, { tag: item.tag, body: item });
81
+ const params = buildCrudParams(module, verb, productTag, {
82
+ tag: item.tag,
83
+ body: componentBody,
84
+ });
55
85
  await proxy.execute(module, verb, params);
56
86
  console.log(` [${type}] ${verb}d: ${item.tag}`);
87
+
88
+ for (const message of nestedMessages) {
89
+ const rawTag = String(message.tag ?? '');
90
+ if (!rawTag) throw new Error(`notification "${item.tag}" contains a message without "tag"`);
91
+ const fullTag = rawTag.includes(':') ? rawTag : `${item.tag}:${rawTag}`;
92
+ if (!fullTag.startsWith(`${item.tag}:`)) {
93
+ throw new Error(
94
+ `message tag "${fullTag}" must belong to notification "${item.tag}"`,
95
+ );
96
+ }
97
+ const messageBody = { ...message, tag: fullTag };
98
+ const messageVerb = existingNotificationMessages.has(fullTag) ? 'update' : 'create';
99
+ const messageParams =
100
+ messageVerb === 'create'
101
+ ? [productTag, messageBody]
102
+ : [productTag, fullTag, messageBody];
103
+ await proxy.execute('notifications', `messages.${messageVerb}`, messageParams);
104
+ console.log(` [notifications] ${messageVerb}d message: ${fullTag}`);
105
+ }
57
106
  } catch (err) {
58
107
  console.error(
59
108
  ` [${type}] ${verb} failed for "${item.tag}": ${err instanceof Error ? err.message : String(err)}`,
@@ -85,8 +134,7 @@ export async function runApply(type: ApplyType | undefined, opts: ApplyOpts): Pr
85
134
  try {
86
135
  items = loaders[t]();
87
136
  } catch (err) {
88
- console.error(`[${t}] parse error: ${err instanceof Error ? err.message : String(err)}`);
89
- continue;
137
+ fail(`[${t}] parse error: ${err instanceof Error ? err.message : String(err)}`);
90
138
  }
91
139
 
92
140
  if (items === null) {
@@ -104,6 +104,101 @@ export async function runProductApps(
104
104
  printJson(result, Boolean(opts.json));
105
105
  }
106
106
 
107
+ const COMPONENT_KEYS: Record<string, string[]> = {
108
+ apps: ['apps'],
109
+ databases: ['databases'],
110
+ storage: ['storage'],
111
+ caches: ['caches'],
112
+ graphs: ['graphs'],
113
+ vectors: ['vectors', 'vector_databases'],
114
+ notifications: ['notifications'],
115
+ events: ['message_brokers', 'messageBrokers', 'brokers'],
116
+ sessions: ['sessions'],
117
+ features: ['features'],
118
+ jobs: ['jobs'],
119
+ };
120
+
121
+ function unwrapProduct(value: unknown): Record<string, unknown> {
122
+ if (!value || typeof value !== 'object') return {};
123
+ const record = value as Record<string, unknown>;
124
+ return record.data && typeof record.data === 'object'
125
+ ? (record.data as Record<string, unknown>)
126
+ : record;
127
+ }
128
+
129
+ function summarizeComponent(value: unknown): Record<string, unknown> {
130
+ const component = (value && typeof value === 'object' ? value : {}) as Record<string, unknown>;
131
+ const summary: Record<string, unknown> = {};
132
+ for (const key of ['_id', 'id', 'tag', 'name', 'description', 'type', 'status']) {
133
+ if (component[key] !== undefined) summary[key] = component[key];
134
+ }
135
+ if (Array.isArray(component.envs)) {
136
+ summary.envs = component.envs.map((env) => {
137
+ const item = (env && typeof env === 'object' ? env : {}) as Record<string, unknown>;
138
+ return { slug: item.slug, type: item.type, cloud: item.cloud };
139
+ });
140
+ }
141
+ for (const nestedKey of ['topics', 'messages']) {
142
+ if (Array.isArray(component[nestedKey])) {
143
+ summary[nestedKey] = (component[nestedKey] as unknown[]).map((nested) => {
144
+ const item = (nested && typeof nested === 'object' ? nested : {}) as Record<string, unknown>;
145
+ return {
146
+ tag: item.tag,
147
+ name: item.name,
148
+ description: item.description,
149
+ };
150
+ });
151
+ }
152
+ }
153
+ return summary;
154
+ }
155
+
156
+ export async function runProductComponents(
157
+ verb: string,
158
+ opts: { profile?: string; tag?: string; type?: string; json?: boolean },
159
+ ): Promise<void> {
160
+ const normalizedVerb = verb.toLowerCase();
161
+ if (!['list', 'get'].includes(normalizedVerb)) {
162
+ throw new Error('Product components verb must be: list | get');
163
+ }
164
+ if (!opts.tag) throw new Error('--tag <product_tag> is required');
165
+
166
+ const ctx = requireWorkspaceContext({ profile: opts.profile });
167
+ const product = unwrapProduct(await getProduct(ctx, { tag: opts.tag }));
168
+ const selectedTypes =
169
+ normalizedVerb === 'get'
170
+ ? [opts.type?.toLowerCase()].filter(Boolean) as string[]
171
+ : Object.keys(COMPONENT_KEYS);
172
+ if (normalizedVerb === 'get' && !selectedTypes.length) {
173
+ throw new Error('--type <component_type> is required for get');
174
+ }
175
+ for (const type of selectedTypes) {
176
+ if (!COMPONENT_KEYS[type]) {
177
+ throw new Error(`Unknown component type "${type}". Use: ${Object.keys(COMPONENT_KEYS).join(', ')}`);
178
+ }
179
+ }
180
+
181
+ const components: Record<string, unknown[]> = {};
182
+ for (const type of selectedTypes) {
183
+ const sourceKey = COMPONENT_KEYS[type].find((key) => Array.isArray(product[key]));
184
+ components[type] = sourceKey
185
+ ? (product[sourceKey] as unknown[]).map(summarizeComponent)
186
+ : [];
187
+ }
188
+ const envSource = Array.isArray(product.envs) ? product.envs : [];
189
+ printJson(
190
+ {
191
+ tag: product.tag ?? opts.tag,
192
+ environments: envSource.map((env) => {
193
+ const item = (env && typeof env === 'object' ? env : {}) as Record<string, unknown>;
194
+ return { slug: item.slug, name: item.name ?? item.env_name };
195
+ }),
196
+ components,
197
+ },
198
+ Boolean(opts.json),
199
+ );
200
+ }
201
+
107
202
  export async function runProductEnvironments(
108
203
  verb: string,
109
204
  opts: { product?: string; tag?: string; slug?: string; json?: boolean },
@@ -70,6 +70,27 @@ export async function runEventTopicCrud(
70
70
 
71
71
  const proxy = getSdkProxy(session);
72
72
  const result = await proxy.execute('messageBrokers', method, params);
73
+ if (crud === 'create') {
74
+ const fullTag = String((body as { tag?: unknown } | undefined)?.tag ?? '');
75
+ if (!fullTag.includes(':')) {
76
+ throw new Error('Topic create response could not be verified because body.tag is invalid');
77
+ }
78
+ const verified = await proxy.execute(
79
+ 'messageBrokers',
80
+ 'topics.fetch',
81
+ [productTag, fullTag],
82
+ );
83
+ if (!verified) {
84
+ throw new Error(
85
+ `Topic creation returned without an error, but "${fullTag}" was not found during verification`,
86
+ );
87
+ }
88
+ printJson({ created: true, topic: verified }, Boolean(opts.json));
89
+ return;
90
+ }
91
+ if (crud === 'get' && !result) {
92
+ throw new Error(`Topic "${tag}" was not found`);
93
+ }
73
94
  printJson(result, Boolean(opts.json));
74
95
  }
75
96
 
@@ -100,7 +121,29 @@ export async function runNotificationMessageCrud(
100
121
  if (!opts.tag) throw new Error('--tag <notification:message> is required');
101
122
  params = [product, opts.tag, body ?? {}];
102
123
  }
103
- printJson(await proxy.execute('notifications', method, params), Boolean(opts.json));
124
+ try {
125
+ printJson(await proxy.execute('notifications', method, params), Boolean(opts.json));
126
+ } catch (error) {
127
+ if (crud !== 'list' || !opts.notification) throw error;
128
+
129
+ // Compatibility fallback for platform versions whose message lookup uses a stale product
130
+ // snapshot while the component inventory already contains the notification.
131
+ const listed = await proxy.execute<unknown>(
132
+ 'notifications',
133
+ 'list',
134
+ buildCrudParams('notifications', 'list', product, {}),
135
+ );
136
+ const notifications = Array.isArray(listed)
137
+ ? listed
138
+ : Array.isArray((listed as { data?: unknown[] } | null)?.data)
139
+ ? (listed as { data: unknown[] }).data
140
+ : [];
141
+ const notification = notifications.find(
142
+ (item) => (item as { tag?: string }).tag === opts.notification,
143
+ ) as { messages?: unknown[] } | undefined;
144
+ if (!notification) throw error;
145
+ printJson(notification.messages ?? [], Boolean(opts.json));
146
+ }
104
147
  }
105
148
 
106
149
  export async function runResourceCrud(
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  } from './commands/workspaces.js';
28
28
  import { runGeneratePayload, runGenerateSnippet } from './commands/generate.js';
29
29
  import { runCompletion } from './commands/completion.js';
30
- import { runProducts, runProductApps, runProductEnvironments } from './commands/products.js';
30
+ import { runProducts, runProductApps, runProductComponents, runProductEnvironments } from './commands/products.js';
31
31
  import { runApps } from './commands/apps.js';
32
32
  import { runAppsImport } from './commands/apps-import.js';
33
33
  import { runApply, type ApplyType } from './commands/apply.js';
@@ -248,6 +248,25 @@ productApps
248
248
  .option('--json', 'JSON output')
249
249
  .action(wrap((opts) => runProductApps('list', opts, [])));
250
250
 
251
+ const productComponents = products
252
+ .command('components')
253
+ .description('Compact, non-secret product component inventory');
254
+
255
+ productComponents
256
+ .command('list')
257
+ .requiredOption('-t, --tag <tag>', 'Product tag')
258
+ .option('--profile <name>')
259
+ .option('--json', 'JSON output')
260
+ .action(wrap((opts) => runProductComponents('list', opts)));
261
+
262
+ productComponents
263
+ .command('get')
264
+ .requiredOption('-t, --tag <tag>', 'Product tag')
265
+ .requiredOption('--type <type>', 'Component type, e.g. notifications or events')
266
+ .option('--profile <name>')
267
+ .option('--json', 'JSON output')
268
+ .action(wrap((opts) => runProductComponents('get', opts)));
269
+
251
270
  const productEnvironments = products
252
271
  .command('environments')
253
272
  .description('Product environments (list, get)');
package/src/lib/http.ts CHANGED
@@ -16,6 +16,12 @@ export async function parseJsonResponse<T = Record<string, unknown>>(
16
16
  try {
17
17
  return JSON.parse(text) as T;
18
18
  } catch {
19
+ if (res.status === 504) {
20
+ throw new Error(
21
+ `Ductape proxy gateway timeout (HTTP 504) for ${url}. The operation did not return a ` +
22
+ 'verifiable result; retry only after checking whether the asset was created.',
23
+ );
24
+ }
19
25
  const htmlHint = text.trimStart().startsWith('<')
20
26
  ? [
21
27
  '',
@@ -0,0 +1,40 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+ import { loadNotifications } from '../src/lib/apply-loaders.js';
7
+
8
+ test('notifications declaration must be a top-level array', () => {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ductape-notifications-'));
10
+ const configDir = path.join(dir, 'ductape');
11
+ fs.mkdirSync(configDir);
12
+ fs.writeFileSync(
13
+ path.join(configDir, 'notifications.json'),
14
+ JSON.stringify({ notifications: [], messages: [] }),
15
+ );
16
+
17
+ assert.throws(
18
+ () => loadNotifications(dir),
19
+ /notifications\.json must be a JSON array/,
20
+ );
21
+ });
22
+
23
+ test('notifications declaration accepts nested messages', () => {
24
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ductape-notifications-'));
25
+ const configDir = path.join(dir, 'ductape');
26
+ fs.mkdirSync(configDir);
27
+ fs.writeFileSync(
28
+ path.join(configDir, 'notifications.json'),
29
+ JSON.stringify([
30
+ {
31
+ tag: 'game-alerts-critical',
32
+ messages: [{ tag: 'game-alerts-critical:match-launch', name: 'Match launch' }],
33
+ },
34
+ ]),
35
+ );
36
+
37
+ const result = loadNotifications(dir);
38
+ assert.equal(result?.length, 1);
39
+ assert.equal(result?.[0].tag, 'game-alerts-critical');
40
+ });
@@ -0,0 +1,15 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { parseJsonResponse } from '../src/lib/http.js';
4
+
5
+ test('surfaces an HTML gateway timeout as a structured proxy failure', async () => {
6
+ const response = new Response('<html><h1>504 Gateway Time-out</h1></html>', {
7
+ status: 504,
8
+ headers: { 'content-type': 'text/html' },
9
+ });
10
+
11
+ await assert.rejects(
12
+ () => parseJsonResponse(response, 'https://api.ductape.app/proxy/v1/sdk-proxy/execute'),
13
+ /Ductape proxy gateway timeout \(HTTP 504\).*did not return a verifiable result/,
14
+ );
15
+ });