@ductape/cli 0.3.9 → 0.3.10
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/CHANGELOG.md +1 -0
- package/README.md +12 -0
- package/dist/commands/resources.d.ts +1 -0
- package/dist/commands/resources.js +40 -4
- package/dist/index.js +2 -1
- package/dist/lib/event-topics.d.ts +29 -0
- package/dist/lib/event-topics.js +155 -0
- package/dist/lib/templates.js +25 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
### Added
|
|
13
13
|
|
|
14
|
+
- Canonical one-topic-per-file Events validation, `events topics validate`, preflighted `create-all`, publisher-reference checks, and an init-generated `ductape/validate-events.sh` CI script.
|
|
14
15
|
- `ductape apps import <file> --type postman|openapi` (Postman v2.1 / OpenAPI 3.0)
|
|
15
16
|
- Project config uses `workspace_tag` (CLI resolves `workspace_id` automatically)
|
|
16
17
|
- `ductape products` and `ductape apps` CRUD (workspace REST + sdk-proxy update)
|
package/README.md
CHANGED
|
@@ -72,6 +72,8 @@ ductape apps list|get|create|update|delete|import
|
|
|
72
72
|
ductape init|link|unlink
|
|
73
73
|
ductape start|stop|status # status explains proxy 502 when backend is down
|
|
74
74
|
ductape resources <type> <verb> # CRUD via sdk-proxy (interactive prompts or -f JSON)
|
|
75
|
+
ductape events topics validate --dir ductape/events
|
|
76
|
+
ductape events topics create-all --dir ductape/events
|
|
75
77
|
ductape cloud connections list|create|get|update|complete|validate|delete
|
|
76
78
|
ductape cloud resources list|import|provision …
|
|
77
79
|
ductape db <verb> # db-proxy runtime
|
|
@@ -83,6 +85,16 @@ ductape generate payload|snippet …
|
|
|
83
85
|
ductape completion bash|zsh
|
|
84
86
|
```
|
|
85
87
|
|
|
88
|
+
## Event topic assets
|
|
89
|
+
|
|
90
|
+
Ductape topic assets are individual, directly importable files at
|
|
91
|
+
`ductape/events/<topic-tag>.topic.json`. Each file contains exactly one topic object whose fully
|
|
92
|
+
qualified `tag` is `broker-tag:topic-tag`; the filename uses the unqualified topic portion.
|
|
93
|
+
Aggregate catalogues, manifests, envelope registries, and custom event registries are rejected.
|
|
94
|
+
|
|
95
|
+
Run `ductape events topics validate --dir ductape/events --json` in CI. `create-all` validates every
|
|
96
|
+
file and publisher reference before performing its first remote mutation.
|
|
97
|
+
|
|
86
98
|
## Documentation
|
|
87
99
|
|
|
88
100
|
Full reference: [docs/docs/cli/](../docs/docs/cli/index.mdx)
|
|
@@ -14,6 +14,7 @@ export declare function runResourcesList(json: boolean): void;
|
|
|
14
14
|
export declare function runEventTopicCrud(verb: string, opts: {
|
|
15
15
|
tag?: string;
|
|
16
16
|
file?: string;
|
|
17
|
+
dir?: string;
|
|
17
18
|
json?: boolean;
|
|
18
19
|
} & CommandInteractiveFlags, extraArgs: string[]): Promise<void>;
|
|
19
20
|
export declare function runNotificationMessageCrud(verb: string, opts: {
|
|
@@ -5,6 +5,7 @@ import { toInteractiveOpts } from '../lib/interactive-opts.js';
|
|
|
5
5
|
import { bodyRequiredHint, resolveBody } from '../lib/read-body.js';
|
|
6
6
|
import { requireSession, getSdkProxy } from '../lib/proxy/context.js';
|
|
7
7
|
import { printJson } from '../lib/output.js';
|
|
8
|
+
import { readCanonicalTopicFile, topicBodyForTransport, validateEventTopicsProject, } from '../lib/event-topics.js';
|
|
8
9
|
const CRUD_VERBS = ['create', 'list', 'get', 'update', 'delete', 'connect'];
|
|
9
10
|
export function resolveResourceProductTag(override, linkedProductTag) {
|
|
10
11
|
return override?.trim() || linkedProductTag;
|
|
@@ -47,12 +48,41 @@ export function runResourcesList(json) {
|
|
|
47
48
|
printJson({ resource_types: listResourceTypes() }, json);
|
|
48
49
|
}
|
|
49
50
|
export async function runEventTopicCrud(verb, opts, extraArgs) {
|
|
51
|
+
if (verb === 'validate') {
|
|
52
|
+
const validation = await validateEventTopicsProject(process.cwd(), opts.dir);
|
|
53
|
+
printJson({
|
|
54
|
+
valid: true,
|
|
55
|
+
directory: validation.directory,
|
|
56
|
+
topics: validation.topics.map(({ file, definition }) => ({ file, tag: definition.tag })),
|
|
57
|
+
publisher_topics: validation.publisherTopics,
|
|
58
|
+
}, Boolean(opts.json));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
50
61
|
const crud = verb.toLowerCase();
|
|
51
|
-
if (!CRUD_VERBS.includes(crud)) {
|
|
52
|
-
throw new Error(`Unknown verb "${verb}". Use: ${CRUD_VERBS.join(', ')}`);
|
|
62
|
+
if (!CRUD_VERBS.includes(crud) && verb !== 'create-all') {
|
|
63
|
+
throw new Error(`Unknown verb "${verb}". Use: ${[...CRUD_VERBS, 'validate', 'create-all'].join(', ')}`);
|
|
53
64
|
}
|
|
54
65
|
const session = requireSession();
|
|
55
66
|
const productTag = session.project.product_tag;
|
|
67
|
+
const proxy = getSdkProxy(session);
|
|
68
|
+
if (verb === 'create-all') {
|
|
69
|
+
const validation = await validateEventTopicsProject(process.cwd(), opts.dir);
|
|
70
|
+
const created = [];
|
|
71
|
+
// Validation of the complete directory deliberately finishes before the first mutation.
|
|
72
|
+
for (const topic of validation.topics) {
|
|
73
|
+
await proxy.execute('messageBrokers', 'topics.create', [
|
|
74
|
+
productTag,
|
|
75
|
+
topicBodyForTransport(topic.definition),
|
|
76
|
+
]);
|
|
77
|
+
const verified = await proxy.execute('messageBrokers', 'topics.fetch', [productTag, topic.definition.tag]);
|
|
78
|
+
if (!verified) {
|
|
79
|
+
throw new Error(`Topic creation returned without an error, but "${topic.definition.tag}" was not found during verification`);
|
|
80
|
+
}
|
|
81
|
+
created.push({ tag: topic.definition.tag, topic: verified });
|
|
82
|
+
}
|
|
83
|
+
printJson({ created: created.length, topics: created }, Boolean(opts.json));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
56
86
|
const interactive = toInteractiveOpts(opts);
|
|
57
87
|
let tag = opts.tag ?? extraArgs[0];
|
|
58
88
|
if (!tag && ['get', 'update', 'delete', 'list'].includes(crud) && isInteractive(interactive)) {
|
|
@@ -60,7 +90,7 @@ export async function runEventTopicCrud(verb, opts, extraArgs) {
|
|
|
60
90
|
}
|
|
61
91
|
const method = crud === 'get' ? 'topics.fetch' : `topics.${crud}`;
|
|
62
92
|
const needsBody = crud === 'create' || crud === 'update';
|
|
63
|
-
|
|
93
|
+
let body = needsBody
|
|
64
94
|
? await resolveBody({
|
|
65
95
|
...interactive,
|
|
66
96
|
file: opts.file,
|
|
@@ -69,6 +99,13 @@ export async function runEventTopicCrud(verb, opts, extraArgs) {
|
|
|
69
99
|
requireBody: crud === 'create',
|
|
70
100
|
})
|
|
71
101
|
: undefined;
|
|
102
|
+
if (crud === 'create') {
|
|
103
|
+
if (!opts.file) {
|
|
104
|
+
throw new Error('Topic creation requires -f ductape/events/<topic-tag>.topic.json');
|
|
105
|
+
}
|
|
106
|
+
const canonical = await readCanonicalTopicFile(opts.file, process.cwd());
|
|
107
|
+
body = topicBodyForTransport(canonical.definition);
|
|
108
|
+
}
|
|
72
109
|
if (crud === 'create' && (!body || Object.keys(body).length === 0)) {
|
|
73
110
|
throw new Error(`create requires a body. ${bodyRequiredHint('messageBrokers:topics:create')}`);
|
|
74
111
|
}
|
|
@@ -94,7 +131,6 @@ export async function runEventTopicCrud(verb, opts, extraArgs) {
|
|
|
94
131
|
else {
|
|
95
132
|
throw new Error(`Unsupported verb "${crud}" for events topics`);
|
|
96
133
|
}
|
|
97
|
-
const proxy = getSdkProxy(session);
|
|
98
134
|
const result = await proxy.execute('messageBrokers', method, params);
|
|
99
135
|
if (crud === 'create') {
|
|
100
136
|
const fullTag = String(body?.tag ?? '');
|
package/dist/index.js
CHANGED
|
@@ -640,9 +640,10 @@ program
|
|
|
640
640
|
const events = program.command('events').description('Message broker CRUD (sdk-proxy, requires access key login)');
|
|
641
641
|
const eventTopics = events.command('topics').description('Topic CRUD for a message broker');
|
|
642
642
|
eventTopics
|
|
643
|
-
.argument('<verb>', 'list | get | create | update | delete')
|
|
643
|
+
.argument('<verb>', 'list | get | create | update | delete | validate | create-all')
|
|
644
644
|
.option('-t, --tag <tag>', 'Broker tag (list) or topic tag in broker:topic form (get, update, delete)')
|
|
645
645
|
.option('-f, --file <path>', 'JSON body (create, update)')
|
|
646
|
+
.option('--dir <path>', 'Events asset directory (must resolve to ductape/events)', 'ductape/events')
|
|
646
647
|
.option('-i, --interactive', 'Prompt for fields (default in TTY when -f omitted)')
|
|
647
648
|
.option('--no-interactive', 'Require -f JSON for create/update')
|
|
648
649
|
.option('--json', 'JSON output')
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface EventTopicDefinition {
|
|
2
|
+
tag: string;
|
|
3
|
+
name: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
sample?: Record<string, unknown>;
|
|
6
|
+
idempotent?: boolean;
|
|
7
|
+
queueUrls?: Array<{
|
|
8
|
+
env_slug: string;
|
|
9
|
+
url: string;
|
|
10
|
+
}>;
|
|
11
|
+
}
|
|
12
|
+
export interface ValidatedEventTopic {
|
|
13
|
+
file: string;
|
|
14
|
+
definition: EventTopicDefinition;
|
|
15
|
+
}
|
|
16
|
+
export interface EventTopicProjectValidation {
|
|
17
|
+
valid: true;
|
|
18
|
+
directory: string;
|
|
19
|
+
topics: ValidatedEventTopic[];
|
|
20
|
+
publisherTopics: string[];
|
|
21
|
+
}
|
|
22
|
+
export declare function expectedTopicFilename(tag: string): string;
|
|
23
|
+
export declare function validateEventTopicDefinition(value: unknown, file?: string): EventTopicDefinition;
|
|
24
|
+
export declare function canonicalEventsDirectory(projectRoot?: string): string;
|
|
25
|
+
export declare function assertCanonicalTopicFile(file: string, definition: EventTopicDefinition, projectRoot?: string): void;
|
|
26
|
+
export declare function readCanonicalTopicFile(file: string, projectRoot?: string): Promise<ValidatedEventTopic>;
|
|
27
|
+
export declare function findPublishedTopicTags(projectRoot?: string): Promise<string[]>;
|
|
28
|
+
export declare function validateEventTopicsProject(projectRoot?: string, requestedDirectory?: string): Promise<EventTopicProjectValidation>;
|
|
29
|
+
export declare function topicBodyForTransport(definition: EventTopicDefinition): Record<string, unknown>;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { basename, relative, resolve, sep } from 'node:path';
|
|
3
|
+
const TOPIC_KEYS = new Set(['tag', 'name', 'description', 'sample', 'idempotent', 'queueUrls']);
|
|
4
|
+
const IGNORED_DIRECTORY_ENTRIES = new Set(['README.md', '.gitkeep', '.DS_Store']);
|
|
5
|
+
const SKIPPED_SCAN_DIRECTORIES = new Set([
|
|
6
|
+
'.git', 'node_modules', 'dist', 'build', 'coverage', '.next', '.nuxt', 'vendor', 'target', 'bin', 'obj',
|
|
7
|
+
]);
|
|
8
|
+
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.go', '.java', '.cs', '.py']);
|
|
9
|
+
const TAG_SEGMENT = '[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*';
|
|
10
|
+
const FULLY_QUALIFIED_TAG = new RegExp(`^${TAG_SEGMENT}:${TAG_SEGMENT}$`);
|
|
11
|
+
const SQS_QUEUE_URL = /^https:\/\/sqs\.[a-z0-9-]+\.amazonaws\.com\/\d{12}\/[a-zA-Z0-9_-]{1,80}(\.fifo)?$/;
|
|
12
|
+
function assertObject(value, file) {
|
|
13
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
14
|
+
throw new Error(`${file}: a topic file must contain exactly one JSON object (arrays and aggregate catalogues are forbidden)`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function expectedTopicFilename(tag) {
|
|
18
|
+
if (!FULLY_QUALIFIED_TAG.test(tag)) {
|
|
19
|
+
throw new Error(`Topic tag "${tag}" must be fully qualified as broker-tag:topic-tag`);
|
|
20
|
+
}
|
|
21
|
+
return `${tag.slice(tag.indexOf(':') + 1)}.topic.json`;
|
|
22
|
+
}
|
|
23
|
+
export function validateEventTopicDefinition(value, file = 'topic definition') {
|
|
24
|
+
assertObject(value, file);
|
|
25
|
+
const unknown = Object.keys(value).filter((key) => !TOPIC_KEYS.has(key));
|
|
26
|
+
if (unknown.length)
|
|
27
|
+
throw new Error(`${file}: unknown topic field(s): ${unknown.join(', ')}`);
|
|
28
|
+
if (typeof value.tag !== 'string' || !FULLY_QUALIFIED_TAG.test(value.tag)) {
|
|
29
|
+
throw new Error(`${file}: tag is required and must be fully qualified as broker-tag:topic-tag`);
|
|
30
|
+
}
|
|
31
|
+
if (typeof value.name !== 'string' || !value.name.trim()) {
|
|
32
|
+
throw new Error(`${file}: name is required and must be a non-empty string`);
|
|
33
|
+
}
|
|
34
|
+
if (value.description !== undefined && (typeof value.description !== 'string' || !value.description.trim())) {
|
|
35
|
+
throw new Error(`${file}: description must be a non-empty string when supplied`);
|
|
36
|
+
}
|
|
37
|
+
if (value.sample !== undefined && (!value.sample || typeof value.sample !== 'object' || Array.isArray(value.sample))) {
|
|
38
|
+
throw new Error(`${file}: sample must be a JSON object`);
|
|
39
|
+
}
|
|
40
|
+
if (value.idempotent !== undefined && typeof value.idempotent !== 'boolean') {
|
|
41
|
+
throw new Error(`${file}: idempotent must be a boolean`);
|
|
42
|
+
}
|
|
43
|
+
if (value.queueUrls !== undefined) {
|
|
44
|
+
if (!Array.isArray(value.queueUrls) || value.queueUrls.some((entry) => !entry || typeof entry !== 'object' || Array.isArray(entry) ||
|
|
45
|
+
Object.keys(entry).some((key) => key !== 'env_slug' && key !== 'url') ||
|
|
46
|
+
typeof entry.env_slug !== 'string' ||
|
|
47
|
+
!entry.env_slug ||
|
|
48
|
+
typeof entry.url !== 'string' ||
|
|
49
|
+
!SQS_QUEUE_URL.test(entry.url))) {
|
|
50
|
+
throw new Error(`${file}: queueUrls must contain only { env_slug, url } objects with valid AWS SQS queue URLs`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
export function canonicalEventsDirectory(projectRoot = process.cwd()) {
|
|
56
|
+
return resolve(projectRoot, 'ductape', 'events');
|
|
57
|
+
}
|
|
58
|
+
export function assertCanonicalTopicFile(file, definition, projectRoot = process.cwd()) {
|
|
59
|
+
const absoluteFile = resolve(projectRoot, file);
|
|
60
|
+
const eventsDirectory = canonicalEventsDirectory(projectRoot);
|
|
61
|
+
const rel = relative(eventsDirectory, absoluteFile);
|
|
62
|
+
if (!rel || rel.startsWith(`..${sep}`) || rel === '..' || rel.includes(sep)) {
|
|
63
|
+
throw new Error(`${file}: topic assets must be direct files under ${eventsDirectory}`);
|
|
64
|
+
}
|
|
65
|
+
const expected = expectedTopicFilename(definition.tag);
|
|
66
|
+
if (basename(absoluteFile) !== expected) {
|
|
67
|
+
throw new Error(`${file}: filename must be "${expected}" for topic tag "${definition.tag}"`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export async function readCanonicalTopicFile(file, projectRoot = process.cwd()) {
|
|
71
|
+
const absoluteFile = resolve(projectRoot, file);
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(await fs.readFile(absoluteFile, 'utf8'));
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
throw new Error(`${file}: could not read valid JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
78
|
+
}
|
|
79
|
+
const definition = validateEventTopicDefinition(parsed, file);
|
|
80
|
+
assertCanonicalTopicFile(absoluteFile, definition, projectRoot);
|
|
81
|
+
return { file: absoluteFile, definition };
|
|
82
|
+
}
|
|
83
|
+
async function walkSourceFiles(directory, output) {
|
|
84
|
+
let entries;
|
|
85
|
+
try {
|
|
86
|
+
entries = await fs.readdir(directory, { withFileTypes: true });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (entry.isDirectory() && SKIPPED_SCAN_DIRECTORIES.has(entry.name))
|
|
93
|
+
continue;
|
|
94
|
+
const path = resolve(directory, entry.name);
|
|
95
|
+
if (entry.isDirectory())
|
|
96
|
+
await walkSourceFiles(path, output);
|
|
97
|
+
else if (entry.isFile() && SOURCE_EXTENSIONS.has(entry.name.slice(entry.name.lastIndexOf('.'))))
|
|
98
|
+
output.push(path);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export async function findPublishedTopicTags(projectRoot = process.cwd()) {
|
|
102
|
+
const files = [];
|
|
103
|
+
await walkSourceFiles(resolve(projectRoot), files);
|
|
104
|
+
const tags = new Set();
|
|
105
|
+
const patterns = [
|
|
106
|
+
/\.(?:publish|produce|dispatch)\s*\(\s*\{[^)]{0,2000}?\b(?:event|topic)\s*:\s*['"`]([A-Za-z0-9._-]+:[A-Za-z0-9._-]+)['"`]/gs,
|
|
107
|
+
/\.(?:publish|produce|dispatch)\s*\(\s*['"`]([A-Za-z0-9._-]+:[A-Za-z0-9._-]+)['"`]/g,
|
|
108
|
+
];
|
|
109
|
+
for (const file of files) {
|
|
110
|
+
const source = await fs.readFile(file, 'utf8');
|
|
111
|
+
for (const pattern of patterns) {
|
|
112
|
+
pattern.lastIndex = 0;
|
|
113
|
+
for (let match = pattern.exec(source); match; match = pattern.exec(source))
|
|
114
|
+
tags.add(match[1]);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return [...tags].sort();
|
|
118
|
+
}
|
|
119
|
+
export async function validateEventTopicsProject(projectRoot = process.cwd(), requestedDirectory) {
|
|
120
|
+
const directory = canonicalEventsDirectory(projectRoot);
|
|
121
|
+
if (requestedDirectory && resolve(projectRoot, requestedDirectory) !== directory) {
|
|
122
|
+
throw new Error(`Events directory must be ${directory}; custom topic directories are not supported`);
|
|
123
|
+
}
|
|
124
|
+
let entries;
|
|
125
|
+
try {
|
|
126
|
+
entries = await fs.readdir(directory, { withFileTypes: true });
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new Error(`Missing required events directory: ${directory}`);
|
|
130
|
+
}
|
|
131
|
+
const unexpected = entries
|
|
132
|
+
.filter((entry) => !entry.isFile() || (!entry.name.endsWith('.topic.json') && !IGNORED_DIRECTORY_ENTRIES.has(entry.name)))
|
|
133
|
+
.map((entry) => entry.name);
|
|
134
|
+
if (unexpected.length) {
|
|
135
|
+
throw new Error(`${directory}: unsupported entries: ${unexpected.join(', ')}. Only one-topic-per-file *.topic.json assets are allowed`);
|
|
136
|
+
}
|
|
137
|
+
const files = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.topic.json'));
|
|
138
|
+
const topics = await Promise.all(files.map((entry) => readCanonicalTopicFile(resolve(directory, entry.name), projectRoot)));
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
for (const topic of topics) {
|
|
141
|
+
if (seen.has(topic.definition.tag))
|
|
142
|
+
throw new Error(`Duplicate topic tag: ${topic.definition.tag}`);
|
|
143
|
+
seen.add(topic.definition.tag);
|
|
144
|
+
}
|
|
145
|
+
const publisherTopics = await findPublishedTopicTags(projectRoot);
|
|
146
|
+
const undefinedTopics = publisherTopics.filter((tag) => !seen.has(tag));
|
|
147
|
+
if (undefinedTopics.length) {
|
|
148
|
+
throw new Error(`Application publishers reference undefined topic(s): ${undefinedTopics.join(', ')}`);
|
|
149
|
+
}
|
|
150
|
+
return { valid: true, directory, topics, publisherTopics };
|
|
151
|
+
}
|
|
152
|
+
export function topicBodyForTransport(definition) {
|
|
153
|
+
// SDK releases through 0.1.120 require sample even though it is optional in the asset contract.
|
|
154
|
+
return { ...definition, sample: definition.sample ?? {} };
|
|
155
|
+
}
|
package/dist/lib/templates.js
CHANGED
|
@@ -137,6 +137,31 @@ Docs: https://docs.ductape.app/docs/cli/
|
|
|
137
137
|
];
|
|
138
138
|
fs.writeFileSync(eventsPath, JSON.stringify(eventsTemplate, null, 2) + '\n');
|
|
139
139
|
}
|
|
140
|
+
const topicAssetsDir = path.join(ductapeDir, 'events');
|
|
141
|
+
fs.mkdirSync(topicAssetsDir, { recursive: true });
|
|
142
|
+
const topicReadmePath = path.join(topicAssetsDir, 'README.md');
|
|
143
|
+
if (!fs.existsSync(topicReadmePath)) {
|
|
144
|
+
fs.writeFileSync(topicReadmePath, `# Ductape Event topics
|
|
145
|
+
|
|
146
|
+
Store exactly one directly importable topic object per file:
|
|
147
|
+
|
|
148
|
+
\`ductape/events/<topic-tag>.topic.json\`
|
|
149
|
+
|
|
150
|
+
The JSON \`tag\` must be fully qualified as \`broker-tag:topic-tag\`, while the filename uses
|
|
151
|
+
the unqualified topic portion. Aggregate catalogues, manifests, and envelope registries are invalid.
|
|
152
|
+
|
|
153
|
+
Validate locally and in CI:
|
|
154
|
+
|
|
155
|
+
\`\`\`bash
|
|
156
|
+
ductape events topics validate --dir ductape/events --json
|
|
157
|
+
\`\`\`
|
|
158
|
+
`);
|
|
159
|
+
}
|
|
160
|
+
const validationScriptPath = path.join(ductapeDir, 'validate-events.sh');
|
|
161
|
+
if (!fs.existsSync(validationScriptPath)) {
|
|
162
|
+
fs.writeFileSync(validationScriptPath, '#!/usr/bin/env sh\nset -eu\nductape events topics validate --dir ductape/events --json\n');
|
|
163
|
+
fs.chmodSync(validationScriptPath, 0o755);
|
|
164
|
+
}
|
|
140
165
|
copyTemplate(lang, dir);
|
|
141
166
|
const gitignorePath = path.join(dir, '.gitignore');
|
|
142
167
|
if (fs.existsSync(gitignorePath)) {
|
package/package.json
CHANGED