@ductape/cli 0.3.8 → 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 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)
@@ -20,7 +21,7 @@
20
21
  - `ductape workspaces current`, `workspaces switch`, `workspaces refresh`
21
22
  - `ductape login --workspace`, `--skip-workspace-select`
22
23
  - `ductape link` defaults to active workspace; `whoami` shows `active_workspace`
23
- - Switching workspaces updates linked `.ductape/config.json` when present
24
+ - Switching workspaces updates linked `ductape/config.json` when present
24
25
 
25
26
  ## 0.2.0
26
27
 
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)
@@ -57,5 +57,5 @@ export async function runLink(opts) {
57
57
  };
58
58
  saveProjectConfig(targetDir, config);
59
59
  setActiveWorkspace(workspaceIdValue, workspaceLabel(workspaceSummary), workspaceTagValue);
60
- success(`Linked project in ${path.join(targetDir, '.ductape/config.json')}`);
60
+ success(`Linked project in ${path.join(targetDir, 'ductape/config.json')}`);
61
61
  }
@@ -1,8 +1,20 @@
1
1
  import { type CommandInteractiveFlags } from '../lib/interactive-opts.js';
2
+ import type { SDKModule } from '../lib/proxy/sdk-proxy.js';
3
+ export declare function resolveResourceProductTag(override: string | undefined, linkedProductTag: string): string;
4
+ type ResourceProxy = {
5
+ execute<T>(module: SDKModule, method: string, params?: unknown[]): Promise<T>;
6
+ };
7
+ /**
8
+ * Session inventories on older platform versions can fail when the component does not exist.
9
+ * Confirm the state through the product catalogue before treating that failure as an empty list,
10
+ * so transport, authentication, and genuine server failures are never silently swallowed.
11
+ */
12
+ export declare function listSessionsWithProductFallback(proxy: ResourceProxy, productTag: string): Promise<unknown[]>;
2
13
  export declare function runResourcesList(json: boolean): void;
3
14
  export declare function runEventTopicCrud(verb: string, opts: {
4
15
  tag?: string;
5
16
  file?: string;
17
+ dir?: string;
6
18
  json?: boolean;
7
19
  } & CommandInteractiveFlags, extraArgs: string[]): Promise<void>;
8
20
  export declare function runNotificationMessageCrud(verb: string, opts: {
@@ -13,6 +25,8 @@ export declare function runNotificationMessageCrud(verb: string, opts: {
13
25
  }): Promise<void>;
14
26
  export declare function runResourceCrud(typeName: string, verb: string, opts: {
15
27
  tag?: string;
28
+ product?: string;
16
29
  file?: string;
17
30
  json?: boolean;
18
31
  } & CommandInteractiveFlags, extraArgs: string[]): Promise<void>;
32
+ export {};
@@ -5,17 +5,84 @@ 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'];
10
+ export function resolveResourceProductTag(override, linkedProductTag) {
11
+ return override?.trim() || linkedProductTag;
12
+ }
13
+ function unwrapArray(value) {
14
+ if (Array.isArray(value))
15
+ return value;
16
+ if (value && typeof value === 'object' && Array.isArray(value.data)) {
17
+ return value.data;
18
+ }
19
+ return undefined;
20
+ }
21
+ /**
22
+ * Session inventories on older platform versions can fail when the component does not exist.
23
+ * Confirm the state through the product catalogue before treating that failure as an empty list,
24
+ * so transport, authentication, and genuine server failures are never silently swallowed.
25
+ */
26
+ export async function listSessionsWithProductFallback(proxy, productTag) {
27
+ try {
28
+ const result = await proxy.execute('sessions', 'list', [productTag]);
29
+ return unwrapArray(result) ?? [];
30
+ }
31
+ catch (listError) {
32
+ try {
33
+ const fetched = await proxy.execute('product', 'fetch', [productTag]);
34
+ const product = fetched && typeof fetched === 'object' && 'data' in fetched
35
+ ? fetched.data
36
+ : fetched;
37
+ if (!product || typeof product !== 'object')
38
+ throw listError;
39
+ const sessions = unwrapArray(product.sessions) ?? [];
40
+ return sessions.filter((session) => !session || typeof session !== 'object' || session.deleted !== true);
41
+ }
42
+ catch {
43
+ throw listError;
44
+ }
45
+ }
46
+ }
9
47
  export function runResourcesList(json) {
10
48
  printJson({ resource_types: listResourceTypes() }, json);
11
49
  }
12
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
+ }
13
61
  const crud = verb.toLowerCase();
14
- if (!CRUD_VERBS.includes(crud)) {
15
- 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(', ')}`);
16
64
  }
17
65
  const session = requireSession();
18
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
+ }
19
86
  const interactive = toInteractiveOpts(opts);
20
87
  let tag = opts.tag ?? extraArgs[0];
21
88
  if (!tag && ['get', 'update', 'delete', 'list'].includes(crud) && isInteractive(interactive)) {
@@ -23,7 +90,7 @@ export async function runEventTopicCrud(verb, opts, extraArgs) {
23
90
  }
24
91
  const method = crud === 'get' ? 'topics.fetch' : `topics.${crud}`;
25
92
  const needsBody = crud === 'create' || crud === 'update';
26
- const body = needsBody
93
+ let body = needsBody
27
94
  ? await resolveBody({
28
95
  ...interactive,
29
96
  file: opts.file,
@@ -32,6 +99,13 @@ export async function runEventTopicCrud(verb, opts, extraArgs) {
32
99
  requireBody: crud === 'create',
33
100
  })
34
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
+ }
35
109
  if (crud === 'create' && (!body || Object.keys(body).length === 0)) {
36
110
  throw new Error(`create requires a body. ${bodyRequiredHint('messageBrokers:topics:create')}`);
37
111
  }
@@ -57,7 +131,6 @@ export async function runEventTopicCrud(verb, opts, extraArgs) {
57
131
  else {
58
132
  throw new Error(`Unsupported verb "${crud}" for events topics`);
59
133
  }
60
- const proxy = getSdkProxy(session);
61
134
  const result = await proxy.execute('messageBrokers', method, params);
62
135
  if (crud === 'create') {
63
136
  const fullTag = String(body?.tag ?? '');
@@ -168,11 +241,28 @@ export async function runResourceCrud(typeName, verb, opts, extraArgs) {
168
241
  tag,
169
242
  };
170
243
  }
171
- const params = buildCrudParams(module, method, session.project.product_tag, {
244
+ const productTag = resolveResourceProductTag(opts.product, session.project.product_tag);
245
+ const params = buildCrudParams(module, method, productTag, {
172
246
  tag,
173
247
  body: payload,
174
248
  });
175
249
  const proxy = getSdkProxy(session);
250
+ if (crud === 'list' && module === 'sessions') {
251
+ printJson(await listSessionsWithProductFallback(proxy, productTag), Boolean(opts.json));
252
+ return;
253
+ }
176
254
  const result = await proxy.execute(module, method, params);
255
+ if (crud === 'create' && module === 'sessions') {
256
+ const createdTag = String(body?.tag ?? '').trim();
257
+ if (!createdTag) {
258
+ throw new Error('Session creation could not be verified because body.tag is missing');
259
+ }
260
+ const verified = await proxy.execute(module, 'fetch', buildCrudParams(module, 'fetch', productTag, { tag: createdTag }));
261
+ if (!verified) {
262
+ throw new Error(`Session creation returned without an error, but "${createdTag}" was not found during verification`);
263
+ }
264
+ printJson({ created: true, session: verified }, Boolean(opts.json));
265
+ return;
266
+ }
177
267
  printJson(result, Boolean(opts.json));
178
268
  }
package/dist/index.js CHANGED
@@ -112,7 +112,7 @@ workspaces
112
112
  .action(wrap((opts) => runWorkspacesCurrent(Boolean(opts.json))));
113
113
  workspaces
114
114
  .command('use [selector]')
115
- .description('Switch active workspace (interactive picker). Updates linked .ductape/config.json if present')
115
+ .description('Switch active workspace (interactive picker). Updates linked ductape/config.json if present')
116
116
  .option('--profile <name>')
117
117
  .option('-C, --dir <path>', 'Project directory to sync', process.cwd())
118
118
  .option('--json', 'JSON output')
@@ -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')
@@ -661,6 +662,7 @@ resources
661
662
  .argument('<type>', 'Resource type (e.g. storage, database, cache …)')
662
663
  .argument('<verb>', 'list | get | create | update | delete | connect')
663
664
  .option('-t, --tag <tag>')
665
+ .option('--product <tag>', 'Product tag (defaults to linked project)')
664
666
  .option('-f, --file <path>', 'JSON body (or use interactive prompts)')
665
667
  .option('-i, --interactive', 'Prompt for fields (default in TTY when -f omitted)')
666
668
  .option('--no-interactive', 'Require -f JSON for create/update/connect')
@@ -9,6 +9,7 @@ export const GLOBAL_CONFIG_PATH = path.join(GLOBAL_DIR, 'config.json');
9
9
  export const HUB_PLATFORM_DIR = path.join(GLOBAL_DIR, 'platform');
10
10
  export const PROJECT_CONFIG_DIR = 'ductape';
11
11
  export const PROJECT_CONFIG_PATH = path.join(PROJECT_CONFIG_DIR, 'config.json');
12
+ const LEGACY_PROJECT_CONFIG_PATH = path.join('.ductape', 'config.json');
12
13
  const DEFAULT_GLOBAL = {
13
14
  default_profile: 'cloud',
14
15
  profiles: {
@@ -114,10 +115,13 @@ export function findProjectConfig(startDir = process.cwd()) {
114
115
  let dir = startDir;
115
116
  const root = path.parse(dir).root;
116
117
  while (true) {
117
- const candidate = path.join(dir, PROJECT_CONFIG_PATH);
118
- const cfg = readJson(candidate);
119
- if (cfg?.product_tag && (cfg.workspace_tag || cfg.workspace_id)) {
120
- return { dir, config: cfg };
118
+ // The CLI writes ductape/config.json. Read the former hidden path only as
119
+ // an upgrade fallback; canonical config always wins when both exist.
120
+ for (const relativePath of [PROJECT_CONFIG_PATH, LEGACY_PROJECT_CONFIG_PATH]) {
121
+ const cfg = readJson(path.join(dir, relativePath));
122
+ if (cfg?.product_tag && (cfg.workspace_tag || cfg.workspace_id)) {
123
+ return { dir, config: cfg };
124
+ }
121
125
  }
122
126
  if (dir === root)
123
127
  break;
@@ -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.8",
3
+ "version": "0.3.10",
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",
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Ductape SDK example — product/env defaults are set on the constructor only.
3
- * CLI: `ductape login` + `ductape link` (see .ductape/config.json).
3
+ * CLI: `ductape login` + `ductape link` (see ductape/config.json).
4
4
  */
5
5
  import Ductape from '@ductape/sdk';
6
6