octwin-cli 0.6.0 → 0.7.0

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.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Turn "the platform is unreachable" into a hint the author can act on.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * `fetch('http://localhost:3000')` can be refused while the platform is running
7
+ * and answering. The server binds IPv4-only (`host: '0.0.0.0'` in `src/server.ts`),
8
+ * Node resolves `localhost` to `::1` first, and whether the connection then falls
9
+ * back to `127.0.0.1` is decided by Node's `autoSelectFamily`, NOT by our code.
10
+ *
11
+ * Measured on node v24.12.0, fetching an IPv4-only bind over `localhost`:
12
+ *
13
+ * autoSelectFamily=true (the default here) -> 200
14
+ * autoSelectFamily=false -> fetch failed / ECONNREFUSED
15
+ *
16
+ * So on a runtime where that default is off — or with
17
+ * `--no-network-family-autoselection` / that flag in `NODE_OPTIONS` — every command
18
+ * reports the platform as down while `--url http://127.0.0.1:3000` and `curl` both
19
+ * work. That reading is what costs the time: a pack author's first act is to run the
20
+ * platform locally, and "unreachable" sends them to look at the server.
21
+ *
22
+ * This module only explains; it changes no addresses. Rewriting the host inside
23
+ * `readTarget` would also change the key saved credentials are stored under, and
24
+ * moving the server to `host: '::'` can fail to bind where IPv6 is disabled — so
25
+ * neither belongs in a diagnostic path.
26
+ */
27
+ /** A `localhost` HTTP(S) origin — the only case where the IPv4/IPv6 split applies. */
28
+ function isLocalhostUrl(url) {
29
+ try {
30
+ return new URL(url).hostname.toLowerCase() === 'localhost';
31
+ }
32
+ catch {
33
+ return false; // not a URL we can reason about; no hint to give
34
+ }
35
+ }
36
+ /** Did the connection get refused, as opposed to timing out / DNS / TLS? */
37
+ function isConnectionRefused(err) {
38
+ const seen = new Set();
39
+ for (let e = err; e && !seen.has(e); e = e.cause) {
40
+ seen.add(e);
41
+ if (e.code === 'ECONNREFUSED' || e.code === 'ECONNRESET')
42
+ return true;
43
+ }
44
+ return false;
45
+ }
46
+ /**
47
+ * The extra sentence to append when a failure looks like the IPv6-`localhost`
48
+ * trap, or `null` when it does not — a hint printed on unrelated failures (a
49
+ * genuinely stopped server, a wrong port) is worse than none, because it sends
50
+ * the author to fix an address that was never the problem.
51
+ */
52
+ export function localhostFamilyHint(url, err) {
53
+ if (!isLocalhostUrl(url) || !isConnectionRefused(err))
54
+ return null;
55
+ const ipv4 = url.replace(/(^https?:\/\/)localhost\b/i, '$1127.0.0.1');
56
+ return `if the platform IS running, this is the IPv6 'localhost' trap — Node tried ::1 and the server binds IPv4 only. Retry with --url ${ipv4}`;
57
+ }
@@ -30,9 +30,10 @@ import { lookupKbSubdir, isEntryFile } from './kb-path.js';
30
30
  * "checked, nothing wrong" from "could not check". See `kb-path.ts`.
31
31
  */
32
32
  export function loadAllowedRenderKeys(packDir) {
33
+ const nested = new Map();
33
34
  const lookup = lookupKbSubdir(packDir, 'render-intents', dir => readdirSync(dir).some(isEntryFile));
34
35
  if (lookup.state !== 'ok')
35
- return { lookup, keys: null };
36
+ return { lookup, keys: null, nested };
36
37
  const dir = lookup.dir;
37
38
  const out = new Map();
38
39
  try {
@@ -42,16 +43,20 @@ export function loadAllowedRenderKeys(packDir) {
42
43
  const entry = JSON.parse(readFileSync(join(dir, file), 'utf8'));
43
44
  if (entry.render_intent && Array.isArray(entry.allowed_keys)) {
44
45
  out.set(entry.render_intent, entry.allowed_keys);
46
+ // Optional: a KB pulled before 2026-08-09 has no nested sets, and the nested check simply
47
+ // does not run for that intent. Absent is skipped, never treated as "nothing is allowed".
48
+ if (entry.nested_allowed_keys)
49
+ nested.set(entry.render_intent, entry.nested_allowed_keys);
45
50
  }
46
51
  }
47
52
  }
48
53
  catch (err) {
49
- return { lookup: { state: 'malformed', dir, reason: String(err?.message ?? err) }, keys: null };
54
+ return { lookup: { state: 'malformed', dir, reason: String(err?.message ?? err) }, keys: null, nested };
50
55
  }
51
56
  if (out.size === 0) {
52
- return { lookup: { state: 'malformed', dir, reason: 'no render-intent entries parsed' }, keys: null };
57
+ return { lookup: { state: 'malformed', dir, reason: 'no render-intent entries parsed' }, keys: null, nested };
53
58
  }
54
- return { lookup, keys: out };
59
+ return { lookup, keys: out, nested };
55
60
  }
56
61
  /**
57
62
  * Walk parsed YAML for objects carrying `render_intent` and report keys outside
@@ -59,8 +64,32 @@ export function loadAllowedRenderKeys(packDir) {
59
64
  * nodes: intents nest (an `auto_collection` carries `empty`/`single`/`multi`
60
65
  * sub-intents), and a stray key is just as invisible there.
61
66
  */
62
- export function findRenderKeyViolations(doc, file, allowedByIntent) {
67
+ export function findRenderKeyViolations(doc, file, allowedByIntent, nestedByIntent = new Map()) {
63
68
  const findings = [];
69
+ /**
70
+ * Descend a declared nested path, mirroring the platform's `checkNested`.
71
+ *
72
+ * Descends only where the KB DECLARES a shape, and skips non-objects — so an expression string
73
+ * (`item_template: '$tpl'`) and an undeclared bag (`items:`, pack-supplied records) are both left
74
+ * alone, exactly as at load time.
75
+ */
76
+ const walkNested = (value, intent, at, sets, path) => {
77
+ if (Array.isArray(value)) {
78
+ value.forEach((v, i) => walkNested(v, intent, `${at}[]`, sets, [...path, i]));
79
+ return;
80
+ }
81
+ if (!value || typeof value !== 'object')
82
+ return;
83
+ const obj = value;
84
+ const allowed = sets[at];
85
+ if (allowed) {
86
+ const bad = Object.keys(obj).filter(k => !allowed.includes(k));
87
+ if (bad.length)
88
+ findings.push({ file, path: [...path], intent, keys: bad, allowed, nestedAt: at });
89
+ }
90
+ for (const [k, v] of Object.entries(obj))
91
+ walkNested(v, intent, `${at}.${k}`, sets, [...path, k]);
92
+ };
64
93
  const walk = (node, path) => {
65
94
  if (Array.isArray(node)) {
66
95
  node.forEach((v, i) => walk(v, [...path, i]));
@@ -78,6 +107,14 @@ export function findRenderKeyViolations(doc, file, allowedByIntent) {
78
107
  const bad = Object.keys(obj).filter(k => !allowed.includes(k));
79
108
  if (bad.length)
80
109
  findings.push({ file, path: [...path], intent: obj.render_intent, keys: bad, allowed });
110
+ const sets = nestedByIntent.get(obj.render_intent);
111
+ if (sets) {
112
+ for (const [k, v] of Object.entries(obj)) {
113
+ if (k === 'render_intent' || !allowed.includes(k))
114
+ continue;
115
+ walkNested(v, obj.render_intent, k, sets, [...path, k]);
116
+ }
117
+ }
81
118
  }
82
119
  }
83
120
  for (const [k, v] of Object.entries(obj))
@@ -93,6 +130,7 @@ export function describeRenderFinding(f) {
93
130
  return `${f.file}${at}: unknown render_intent '${f.intent}' — known intents: ${f.allowed.join(', ')}`;
94
131
  }
95
132
  const plural = f.keys.length === 1 ? 'field' : 'fields';
96
- return `${f.file}${at}: render_intent '${f.intent}' has unknown ${plural} ${f.keys.map(k => `'${k}'`).join(', ')} ` +
133
+ const where = f.nestedAt ? `'${f.intent}' at '${f.nestedAt}'` : `'${f.intent}'`;
134
+ return `${f.file}${at}: render_intent ${where} has unknown ${plural} ${f.keys.map(k => `'${k}'`).join(', ')} ` +
97
135
  `— silently dropped at render. Allowed: ${f.allowed.join(', ')}`;
98
136
  }
package/package.json CHANGED
@@ -1,37 +1,37 @@
1
- {
2
- "name": "octwin-cli",
3
- "version": "0.6.0",
4
- "description": "Octwin external-pack developer CLI (by CEQUENS) — scaffold, validate, deploy, and check pure-YAML packs on your tenant.",
5
- "type": "module",
6
- "bin": {
7
- "octwin": "dist/index.js"
8
- },
9
- "files": [
10
- "dist",
11
- "templates",
12
- "README.md",
13
- "CHANGELOG.md",
14
- "LICENSE"
15
- ],
16
- "engines": {
17
- "node": ">=20"
18
- },
19
- "scripts": {
20
- "build": "tsc -p tsconfig.json",
21
- "prepublishOnly": "npm run build"
22
- },
23
- "dependencies": {
24
- "yaml": "^2.6.0"
25
- },
26
- "devDependencies": {
27
- "@types/node": "^22.0.0",
28
- "typescript": "^5.7.0"
29
- },
30
- "publishConfig": {
31
- "access": "public"
32
- },
33
- "keywords": ["octwin", "cequens", "cli", "whatsapp", "chatbot", "pack", "conversational-ai", "yaml"],
34
- "author": "CEQUENS",
35
- "homepage": "https://www.npmjs.com/package/octwin-cli",
36
- "license": "MIT"
37
- }
1
+ {
2
+ "name": "octwin-cli",
3
+ "version": "0.7.0",
4
+ "description": "Octwin external-pack developer CLI (by CEQUENS) — scaffold, validate, deploy, and check pure-YAML packs on your tenant.",
5
+ "type": "module",
6
+ "bin": {
7
+ "octwin": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "templates",
12
+ "README.md",
13
+ "CHANGELOG.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=22"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.json",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "dependencies": {
24
+ "yaml": "^2.6.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^22.0.0",
28
+ "typescript": "^5.7.0"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "keywords": ["octwin", "cequens", "cli", "whatsapp", "chatbot", "pack", "conversational-ai", "yaml"],
34
+ "author": "CEQUENS",
35
+ "homepage": "https://www.npmjs.com/package/octwin-cli",
36
+ "license": "MIT"
37
+ }
@@ -14,6 +14,16 @@ description: 'A hello bot — edit this to build your pack.'
14
14
  supported_channels: [whatsapp, web]
15
15
  required_adapters: [messaging]
16
16
 
17
+ # What to do with a VOICE NOTE. WhatsApp cannot stop customers sending them, and an
18
+ # ABSENT block is not "nothing happens" — it means `passthrough`, i.e. no
19
+ # speech-to-text, so the agent gets "a voice message arrived, no transcript" and has
20
+ # to improvise. `transcribe` is the right default for a conversational pack; use
21
+ # `passthrough` when you handle the audio ref yourself, or `decline` (with a
22
+ # `notice:`) to ask for text instead.
23
+ inbound_preprocessing:
24
+ voicenote:
25
+ mode: transcribe
26
+
17
27
  # Default locale for platform-emitted copy + this pack's `$t()` lookups.
18
28
  default_settings:
19
29
  locale: ar