@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.
- package/dist/commands/apply.js +44 -3
- package/dist/commands/products.d.ts +6 -0
- package/dist/commands/products.js +85 -0
- package/dist/commands/resources.d.ts +6 -0
- package/dist/commands/resources.js +50 -0
- package/dist/index.js +26 -2
- package/package.json +1 -1
- package/src/commands/apply.ts +51 -3
- package/src/commands/products.ts +95 -0
- package/src/commands/resources.ts +52 -0
- package/src/index.ts +30 -2
- package/test/apply-loaders.test.ts +40 -0
- package/test/platform-api.test.ts +9 -0
package/dist/commands/apply.js
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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
package/src/commands/apply.ts
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
89
|
-
continue;
|
|
137
|
+
fail(`[${t}] parse error: ${err instanceof Error ? err.message : String(err)}`);
|
|
90
138
|
}
|
|
91
139
|
|
|
92
140
|
if (items === null) {
|
package/src/commands/products.ts
CHANGED
|
@@ -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
|
});
|