@ductape/cli 0.2.18 → 0.2.20

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'];
@@ -5,6 +5,12 @@ export declare function runEventTopicCrud(verb: string, opts: {
5
5
  file?: string;
6
6
  json?: boolean;
7
7
  } & CommandInteractiveFlags, extraArgs: string[]): Promise<void>;
8
+ export declare function runNotificationMessageCrud(verb: string, opts: {
9
+ tag?: string;
10
+ notification?: string;
11
+ file?: string;
12
+ json?: boolean;
13
+ }): Promise<void>;
8
14
  export declare function runResourceCrud(typeName: string, verb: string, opts: {
9
15
  tag?: string;
10
16
  file?: string;
@@ -61,6 +61,56 @@ export async function runEventTopicCrud(verb, opts, extraArgs) {
61
61
  const result = await proxy.execute('messageBrokers', method, params);
62
62
  printJson(result, Boolean(opts.json));
63
63
  }
64
+ export async function runNotificationMessageCrud(verb, opts) {
65
+ const crud = verb.toLowerCase();
66
+ if (!['create', 'list', 'get', 'update'].includes(crud)) {
67
+ throw new Error('Notification message verb must be: create | list | get | update');
68
+ }
69
+ const session = requireSession();
70
+ const proxy = getSdkProxy(session);
71
+ const product = session.project.product_tag;
72
+ const body = ['create', 'update'].includes(crud)
73
+ ? await resolveBody({ file: opts.file, patch: crud === 'update', requireBody: crud === 'create', interactive: false })
74
+ : undefined;
75
+ const method = `messages.${crud === 'get' ? 'fetch' : crud}`;
76
+ let params;
77
+ if (crud === 'create')
78
+ params = [product, body ?? {}];
79
+ else if (crud === 'list') {
80
+ if (!opts.notification)
81
+ throw new Error('--notification <notification-tag> is required');
82
+ params = [product, opts.notification];
83
+ }
84
+ else if (crud === 'get') {
85
+ if (!opts.tag)
86
+ throw new Error('--tag <notification:message> is required');
87
+ params = [product, opts.tag];
88
+ }
89
+ else {
90
+ if (!opts.tag)
91
+ throw new Error('--tag <notification:message> is required');
92
+ params = [product, opts.tag, body ?? {}];
93
+ }
94
+ try {
95
+ printJson(await proxy.execute('notifications', method, params), Boolean(opts.json));
96
+ }
97
+ catch (error) {
98
+ if (crud !== 'list' || !opts.notification)
99
+ throw error;
100
+ // Compatibility fallback for platform versions whose message lookup uses a stale product
101
+ // snapshot while the component inventory already contains the notification.
102
+ const listed = await proxy.execute('notifications', 'list', buildCrudParams('notifications', 'list', product, {}));
103
+ const notifications = Array.isArray(listed)
104
+ ? listed
105
+ : Array.isArray(listed?.data)
106
+ ? listed.data
107
+ : [];
108
+ const notification = notifications.find((item) => item.tag === opts.notification);
109
+ if (!notification)
110
+ throw error;
111
+ printJson(notification.messages ?? [], Boolean(opts.json));
112
+ }
113
+ }
64
114
  export async function runResourceCrud(typeName, verb, opts, extraArgs) {
65
115
  const crud = verb.toLowerCase();
66
116
  if (!CRUD_VERBS.includes(crud)) {
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import { runUnlink } from './commands/unlink.js';
12
12
  import { runInit } from './commands/init.js';
13
13
  import { runInstall } from './commands/install.js';
14
14
  import { runStart, runStop, runStatus } from './commands/platform.js';
15
- import { runResourceCrud, runResourcesList, runEventTopicCrud } from './commands/resources.js';
15
+ import { runResourceCrud, runResourcesList, runEventTopicCrud, runNotificationMessageCrud } from './commands/resources.js';
16
16
  import { runCloud } from './commands/cloud.js';
17
17
  import { runDb, runDbContext } from './commands/db.js';
18
18
  import { runDbMigrate, runDbMigrateStatus, runDbMigrateRollback } from './commands/db-migrate.js';
@@ -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)');
@@ -255,6 +271,14 @@ eventTopics
255
271
  .option('--json', 'JSON output')
256
272
  .action(wrap((verb, opts) => runEventTopicCrud(verb, opts, [])));
257
273
  const resources = program.command('resources').description('Component CRUD (sdk-proxy)');
274
+ const notificationMessages = program.command('notifications').description('Notification templates');
275
+ notificationMessages
276
+ .command('messages <verb>')
277
+ .option('-t, --tag <tag>', 'Full notification:message tag')
278
+ .option('-n, --notification <tag>', 'Notification tag for list')
279
+ .option('-f, --file <path>', 'JSON template body for create/update')
280
+ .option('--json', 'JSON output')
281
+ .action(wrap((verb, opts) => runNotificationMessageCrud(verb, opts)));
258
282
  resources.command('types').option('--json', 'JSON output').action(wrap((opts) => runResourcesList(Boolean(opts.json))));
259
283
  resources
260
284
  .argument('<type>', 'Resource type (e.g. storage, database, cache …)')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ductape/cli",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
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 },
@@ -73,6 +73,58 @@ export async function runEventTopicCrud(
73
73
  printJson(result, Boolean(opts.json));
74
74
  }
75
75
 
76
+ export async function runNotificationMessageCrud(
77
+ verb: string,
78
+ opts: { tag?: string; notification?: string; file?: string; json?: boolean },
79
+ ): Promise<void> {
80
+ const crud = verb.toLowerCase();
81
+ if (!['create', 'list', 'get', 'update'].includes(crud)) {
82
+ throw new Error('Notification message verb must be: create | list | get | update');
83
+ }
84
+ const session = requireSession();
85
+ const proxy = getSdkProxy(session);
86
+ const product = session.project.product_tag;
87
+ const body = ['create', 'update'].includes(crud)
88
+ ? await resolveBody({ file: opts.file, patch: crud === 'update', requireBody: crud === 'create', interactive: false })
89
+ : undefined;
90
+ const method = `messages.${crud === 'get' ? 'fetch' : crud}`;
91
+ let params: unknown[];
92
+ if (crud === 'create') params = [product, body ?? {}];
93
+ else if (crud === 'list') {
94
+ if (!opts.notification) throw new Error('--notification <notification-tag> is required');
95
+ params = [product, opts.notification];
96
+ } else if (crud === 'get') {
97
+ if (!opts.tag) throw new Error('--tag <notification:message> is required');
98
+ params = [product, opts.tag];
99
+ } else {
100
+ if (!opts.tag) throw new Error('--tag <notification:message> is required');
101
+ params = [product, opts.tag, body ?? {}];
102
+ }
103
+ try {
104
+ printJson(await proxy.execute('notifications', method, params), Boolean(opts.json));
105
+ } catch (error) {
106
+ if (crud !== 'list' || !opts.notification) throw error;
107
+
108
+ // Compatibility fallback for platform versions whose message lookup uses a stale product
109
+ // snapshot while the component inventory already contains the notification.
110
+ const listed = await proxy.execute<unknown>(
111
+ 'notifications',
112
+ 'list',
113
+ buildCrudParams('notifications', 'list', product, {}),
114
+ );
115
+ const notifications = Array.isArray(listed)
116
+ ? listed
117
+ : Array.isArray((listed as { data?: unknown[] } | null)?.data)
118
+ ? (listed as { data: unknown[] }).data
119
+ : [];
120
+ const notification = notifications.find(
121
+ (item) => (item as { tag?: string }).tag === opts.notification,
122
+ ) as { messages?: unknown[] } | undefined;
123
+ if (!notification) throw error;
124
+ printJson(notification.messages ?? [], Boolean(opts.json));
125
+ }
126
+ }
127
+
76
128
  export async function runResourceCrud(
77
129
  typeName: string,
78
130
  verb: string,
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@ import { runUnlink } from './commands/unlink.js';
12
12
  import { runInit } from './commands/init.js';
13
13
  import { runInstall } from './commands/install.js';
14
14
  import { runStart, runStop, runStatus } from './commands/platform.js';
15
- import { runResourceCrud, runResourcesList, runEventTopicCrud } from './commands/resources.js';
15
+ import { runResourceCrud, runResourcesList, runEventTopicCrud, runNotificationMessageCrud } from './commands/resources.js';
16
16
  import { runCloud } from './commands/cloud.js';
17
17
  import { runDb, runDbContext } from './commands/db.js';
18
18
  import { runDbMigrate, runDbMigrateStatus, runDbMigrateRollback } from './commands/db-migrate.js';
@@ -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)');
@@ -332,6 +351,15 @@ eventTopics
332
351
 
333
352
  const resources = program.command('resources').description('Component CRUD (sdk-proxy)');
334
353
 
354
+ const notificationMessages = program.command('notifications').description('Notification templates');
355
+ notificationMessages
356
+ .command('messages <verb>')
357
+ .option('-t, --tag <tag>', 'Full notification:message tag')
358
+ .option('-n, --notification <tag>', 'Notification tag for list')
359
+ .option('-f, --file <path>', 'JSON template body for create/update')
360
+ .option('--json', 'JSON output')
361
+ .action(wrap((verb: string, opts) => runNotificationMessageCrud(verb, opts)));
362
+
335
363
  resources.command('types').option('--json', 'JSON output').action(wrap((opts) => runResourcesList(Boolean(opts.json))));
336
364
 
337
365
  resources
@@ -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
+ });
@@ -30,4 +30,13 @@ describe('platform-api helpers', () => {
30
30
  /getPath<unknown>\(`\/integrations\/v1\/fetch\/tag`/,
31
31
  );
32
32
  });
33
+
34
+ it('exposes notification message administration', () => {
35
+ const source = readFileSync(
36
+ fileURLToPath(new URL('../src/commands/resources.ts', import.meta.url)),
37
+ 'utf8',
38
+ );
39
+ assert.match(source, /runNotificationMessageCrud/);
40
+ assert.match(source, /`messages\.\$\{crud === 'get' \? 'fetch' : crud\}`/);
41
+ });
33
42
  });