@ductape/cli 0.3.9 → 0.3.11

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 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)
@@ -1,3 +1,7 @@
1
+ export declare function validateGraphActionFile(file: string | undefined, body: Record<string, unknown>): {
2
+ graphTag: string;
3
+ actionTag: string;
4
+ };
1
5
  export declare function runGraph(method: string, opts: {
2
6
  file?: string;
3
7
  json?: boolean;
@@ -3,16 +3,80 @@ import { setGraphContext } from '../lib/context-store.js';
3
3
  import { GRAPH_PROXY_METHODS } from '../lib/db-methods.js';
4
4
  import { getGraphProxy, requireSession } from '../lib/proxy/context.js';
5
5
  import { printJson } from '../lib/output.js';
6
+ import path from 'node:path';
7
+ const GRAPH_ACTION_FIELDS = new Set(['graphTag', 'name', 'description', 'operation', 'query', 'parameters']);
8
+ function actionSlug(name) {
9
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
10
+ }
11
+ export function validateGraphActionFile(file, body) {
12
+ if (!file)
13
+ throw new Error('Graph action validation requires -f ductape/graphs/<graph-tag>/actions/<action-tag>.action.json');
14
+ for (const field of ['graphTag', 'name', 'operation', 'query', 'parameters']) {
15
+ if (!(field in body))
16
+ throw new Error(`Graph action is missing required field "${field}"`);
17
+ }
18
+ for (const field of Object.keys(body)) {
19
+ if (!GRAPH_ACTION_FIELDS.has(field))
20
+ throw new Error(`Unknown graph action field "${field}"`);
21
+ }
22
+ if (typeof body.graphTag !== 'string' || !body.graphTag)
23
+ throw new Error('graphTag must be a non-empty string');
24
+ if (typeof body.name !== 'string' || !body.name)
25
+ throw new Error('name must be a non-empty string');
26
+ if (typeof body.operation !== 'string' || !body.operation)
27
+ throw new Error('operation must be a non-empty string');
28
+ if (!body.query || typeof body.query !== 'object' || Array.isArray(body.query))
29
+ throw new Error('query must be an object');
30
+ if (!Array.isArray(body.parameters))
31
+ throw new Error('parameters must be an array');
32
+ for (const [index, parameter] of body.parameters.entries()) {
33
+ if (!parameter || typeof parameter !== 'object' || Array.isArray(parameter))
34
+ throw new Error(`parameters[${index}] must be an object`);
35
+ const value = parameter;
36
+ for (const field of ['name', 'path', 'defaultValue', 'type']) {
37
+ if (!(field in value))
38
+ throw new Error(`parameters[${index}] is missing required field "${field}"`);
39
+ }
40
+ if (!['string', 'number', 'boolean', 'array', 'object'].includes(String(value.type))) {
41
+ throw new Error(`parameters[${index}].type is invalid`);
42
+ }
43
+ }
44
+ const actionTag = actionSlug(body.name);
45
+ const normalized = file.replaceAll('\\', '/');
46
+ const expectedSuffix = `ductape/graphs/${body.graphTag}/actions/${actionTag}.action.json`;
47
+ if (!normalized.endsWith(expectedSuffix))
48
+ throw new Error(`Graph action must be stored at ${expectedSuffix}`);
49
+ if (path.basename(file) !== `${actionTag}.action.json`)
50
+ throw new Error(`Graph action filename must be ${actionTag}.action.json`);
51
+ return { graphTag: body.graphTag, actionTag };
52
+ }
6
53
  export async function runGraph(method, opts) {
7
54
  const session = requireSession();
8
55
  const proxy = getGraphProxy(session);
9
56
  const body = readJsonBody(opts.file);
10
57
  const m = method.includes('.') ? method : method.toLowerCase();
58
+ if (m === 'validateaction') {
59
+ const validated = validateGraphActionFile(opts.file, body);
60
+ printJson({ valid: true, file: opts.file, ...validated }, Boolean(opts.json));
61
+ return;
62
+ }
11
63
  if (!GRAPH_PROXY_METHODS.includes(m) && !m.includes('.')) {
12
64
  console.warn(`Method "${m}" not in known list; sending anyway. Known: ${GRAPH_PROXY_METHODS.join(', ')}`);
13
65
  }
14
66
  const params = [];
15
- if (m === 'connect') {
67
+ if (m === 'createaction') {
68
+ validateGraphActionFile(opts.file, body);
69
+ params.push(body, session.project.product_tag);
70
+ }
71
+ else if (m === 'updateaction') {
72
+ const actionTag = String(body.actionTag ?? '');
73
+ const graphTag = String(body.graphTag ?? '');
74
+ if (!actionTag || !graphTag || !body.updates || typeof body.updates !== 'object') {
75
+ throw new Error('updateAction requires { "actionTag", "graphTag", "updates": { ... } }');
76
+ }
77
+ params.push(actionTag, body.updates, graphTag, session.project.product_tag);
78
+ }
79
+ else if (m === 'connect') {
16
80
  const cfg = {
17
81
  product: session.project.product_tag,
18
82
  env: session.project.env_slug,
@@ -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
- const body = needsBody
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')
@@ -734,7 +735,7 @@ db
734
735
  }));
735
736
  const graph = program.command('graph').description('Graph runtime (graph-proxy)');
736
737
  graph
737
- .argument('<verb>', 'connect | query | …')
738
+ .argument('<verb>', 'connect | query | createAction | validateAction | updateAction | …')
738
739
  .option('-f, --file <path>')
739
740
  .option('--json', 'JSON output')
740
741
  .action(wrap((verb, opts) => runGraph(verb, { file: opts.file, json: opts.json })));
@@ -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
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ductape/cli",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
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",