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.
- package/CHANGELOG.md +540 -498
- package/README.md +1 -1
- package/dist/index.js +455 -265
- package/dist/lib/net-hint.js +57 -0
- package/dist/lib/render-check.js +44 -6
- package/package.json +37 -37
- package/templates/starter/manifest.yaml +10 -0
|
@@ -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
|
+
}
|
package/dist/lib/render-check.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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": ">=
|
|
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
|