octwin-cli 0.1.21 → 0.5.1

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.
@@ -27,13 +27,20 @@
27
27
  * platform's expander. `octwin validate --remote` covers that case; this
28
28
  * covers everything written directly in the flow.
29
29
  */
30
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
30
+ import { readdirSync, readFileSync } from 'node:fs';
31
31
  import { join } from 'node:path';
32
- /** Load `primitive -> ArgSpec` from the pulled KB, or null when absent. */
32
+ import { lookupKbSubdir } from './kb-path.js';
33
+ /**
34
+ * Load `primitive -> ArgSpec` from the pulled KB.
35
+ *
36
+ * Three-state, like its sibling in `render-check.ts` — the caller must be able to
37
+ * distinguish "checked and clean" from "could not check". See `kb-path.ts`.
38
+ */
33
39
  export function loadPrimitiveArgSpecs(packDir) {
34
- const dir = join(packDir, '.octwin', 'platform-kb', 'primitives');
35
- if (!existsSync(dir))
36
- return null;
40
+ const lookup = lookupKbSubdir(packDir, 'primitives', dir => readdirSync(dir).some(f => f.endsWith('.json')));
41
+ if (lookup.state !== 'ok')
42
+ return { lookup, specs: null };
43
+ const dir = lookup.dir;
37
44
  const out = new Map();
38
45
  try {
39
46
  for (const file of readdirSync(dir)) {
@@ -50,13 +57,17 @@ export function loadPrimitiveArgSpecs(packDir) {
50
57
  keys: Object.keys(schema.properties),
51
58
  required: Array.isArray(schema.required) ? schema.required : [],
52
59
  open: ap !== undefined && ap !== false,
60
+ requiresOneOf: Array.isArray(entry.requiresOneOf) ? entry.requiresOneOf : [],
53
61
  });
54
62
  }
55
63
  }
56
- catch {
57
- return null;
64
+ catch (err) {
65
+ return { lookup: { state: 'malformed', dir, reason: String(err?.message ?? err) }, specs: null };
58
66
  }
59
- return out.size ? out : null;
67
+ if (out.size === 0) {
68
+ return { lookup: { state: 'malformed', dir, reason: 'no primitive entries parsed' }, specs: null };
69
+ }
70
+ return { lookup, specs: out };
60
71
  }
61
72
  /**
62
73
  * Walk parsed YAML for `do:` nodes and report `args:` keys outside the
@@ -84,8 +95,9 @@ export function findArgViolations(doc, file, specs) {
84
95
  : {};
85
96
  const unknown = Object.keys(args).filter(k => !spec.keys.includes(k));
86
97
  const missing = spec.required.filter(k => !(k in args));
87
- if (unknown.length || missing.length) {
88
- findings.push({ file, primitive: obj.do, unknown, missing, declared: [...spec.keys].sort() });
98
+ const unscoped = spec.requiresOneOf.filter(g => !g.some(k => k in args));
99
+ if (unknown.length || missing.length || unscoped.length) {
100
+ findings.push({ file, primitive: obj.do, unknown, missing, unscoped, declared: [...spec.keys].sort() });
89
101
  }
90
102
  }
91
103
  }
@@ -106,5 +118,10 @@ export function describeArgFinding(f) {
106
118
  const plural = f.missing.length === 1 ? 'argument' : 'arguments';
107
119
  parts.push(`missing required ${plural} ${f.missing.map(k => `'${k}'`).join(', ')}`);
108
120
  }
121
+ for (const g of f.unscoped) {
122
+ // Worth its own sentence rather than a key list: the reader's question is
123
+ // "so what?", and for the scope groups the answer is a data leak.
124
+ parts.push(`no ${g.map(k => `'${k}'`).join(' or ')} — the read is project-wide and returns other contacts' records`);
125
+ }
109
126
  return `${f.file}: \`${f.primitive}\` has ${parts.join('; ')}. It takes: ${f.declared.join(', ') || 'no arguments'}`;
110
127
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Locate the pulled platform KB — the one resolver for all three readers.
3
+ *
4
+ * ## Why a walk-up
5
+ *
6
+ * `octwin platform-kb pull` writes `<cwd>/.octwin/platform-kb/`. Authors pull once
7
+ * at a repo root and then work inside `packs/<name>/`, which is how every other
8
+ * `.<tool>` directory behaves (`.git`, `node_modules`, `.env`). The readers used to
9
+ * join `.octwin` onto `packDir` exactly, so a repo-root pull covered no pack at
10
+ * all — and the failure was invisible, because a missing KB made each check
11
+ * silently return "nothing to check".
12
+ *
13
+ * ## Why three states and not `T | null`
14
+ *
15
+ * `catch { return null }` collapses *never pulled*, *corrupt* and *interrupted
16
+ * mid-pull* into one answer, and the advice attached to that answer ("run
17
+ * `octwin platform-kb pull`") is right for the first and only accidentally right
18
+ * for the others. A half-written pull that reads as "you haven't pulled yet" is
19
+ * how an author re-runs the same broken command twice. The reader tells the
20
+ * caller which of the three it found; the caller decides what to print.
21
+ */
22
+ import { existsSync, statSync } from 'node:fs';
23
+ import { dirname, join } from 'node:path';
24
+ /** How far up to look. Deep enough for a monorepo, shallow enough to stay local. */
25
+ const MAX_HOPS = 12;
26
+ /**
27
+ * Walk up from `packDir` looking for `.octwin/platform-kb/`.
28
+ *
29
+ * Stops at the filesystem root — `dirname(x) === x` is the fixed point on both
30
+ * POSIX (`/`) and Windows (`C:\`), and without that guard this loops forever.
31
+ * `MAX_HOPS` is the belt to that braces: a path that is neither absolute nor
32
+ * rooted the way we expect still terminates.
33
+ */
34
+ export function findPlatformKbDir(packDir) {
35
+ let dir = packDir;
36
+ for (let hop = 0; hop < MAX_HOPS; hop++) {
37
+ const candidate = join(dir, '.octwin', 'platform-kb');
38
+ if (existsSync(candidate) && statSync(candidate).isDirectory())
39
+ return candidate;
40
+ const parent = dirname(dir);
41
+ if (parent === dir)
42
+ break;
43
+ dir = parent;
44
+ }
45
+ return null;
46
+ }
47
+ /**
48
+ * Resolve one KB SUBDIRECTORY (`primitives`, `render-intents`) to a three-state
49
+ * answer. `readEntries` is the caller's parse; throwing from it means malformed,
50
+ * returning an empty result means the directory is there but has no usable
51
+ * entries — which is also malformed, not absent.
52
+ */
53
+ export function lookupKbSubdir(packDir, subdir, isUsable) {
54
+ const kb = findPlatformKbDir(packDir);
55
+ if (!kb)
56
+ return { state: 'absent' };
57
+ const dir = join(kb, subdir);
58
+ if (!existsSync(dir)) {
59
+ return { state: 'malformed', dir: kb, reason: `'${subdir}/' is missing from the pulled KB` };
60
+ }
61
+ try {
62
+ if (!isUsable(dir)) {
63
+ return { state: 'malformed', dir: kb, reason: `'${subdir}/' has no readable entries` };
64
+ }
65
+ }
66
+ catch (err) {
67
+ return { state: 'malformed', dir: kb, reason: `'${subdir}/' could not be read — ${err?.message ?? err}` };
68
+ }
69
+ return { state: 'ok', dir };
70
+ }
71
+ /** The line to print when a check could not run. Same wording for all three. */
72
+ export function describeKbLookup(l, what) {
73
+ return l.state === 'absent'
74
+ ? `${what} SKIPPED — no capability reference found. Run \`octwin platform-kb pull\` (a pull at your repo root covers every pack under it).`
75
+ : `${what} SKIPPED — the capability reference at ${l.dir} is unusable: ${l.reason}. Re-run \`octwin platform-kb pull\`.`;
76
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * page.ts — read the platform's ONE page envelope.
3
+ *
4
+ * Every list route returns `Page<T>` (`src/platform/core/utils/paging.ts`):
5
+ * `{ rows, total, offset, limit, has_more, next_offset }`. `rows` is deliberately
6
+ * the collection key for every domain, so a client needs exactly one reader.
7
+ *
8
+ * ## Why this file exists
9
+ *
10
+ * It did not, and the CLI paid for it. Four commands each hand-copied a
11
+ * per-domain collection noun — `json.records`, `json.cases`, `json.orders`,
12
+ * `json.products` — from the days when every route invented its own envelope.
13
+ * When the platform collapsed those into `Page`, the CLI kept reading keys that
14
+ * no longer existed, and `.records ?? []` degraded to an empty array instead of
15
+ * failing. The result shipped: `octwin records doctor` printed
16
+ * "5 record(s)" and then "(none — check your token)", blaming the user's
17
+ * credentials for a key rename.
18
+ *
19
+ * A `?? []` fallback on a response you do not control is a silent-wrong
20
+ * generator. Reading the envelope in ONE place, with a test pinned to the real
21
+ * shape, is the actual fix — a future rename breaks one function loudly rather
22
+ * than four commands quietly.
23
+ *
24
+ * `assets` (media) and `agents` are NOT `Page`-shaped — those routes predate it
25
+ * and return their own envelopes. Do not force them through here; they have no
26
+ * `total` semantics to honour.
27
+ */
28
+ /**
29
+ * Read a `Page` envelope defensively.
30
+ *
31
+ * Returns an empty view for a non-object or a body with no `rows` ARRAY, so a
32
+ * degraded read (`{ has_xrm: false, ...emptyPage() }`) and an error body both
33
+ * land on "no rows" without throwing. `total` stays **null** when absent rather
34
+ * than defaulting to 0 — a caller that prints "0 of 0" when the server said
35
+ * nothing is inventing a fact.
36
+ */
37
+ export function readPage(json) {
38
+ const body = (json ?? {});
39
+ const rows = Array.isArray(body.rows) ? body.rows : [];
40
+ return {
41
+ rows,
42
+ total: typeof body.total === 'number' ? body.total : null,
43
+ hasMore: body.has_more === true,
44
+ nextOffset: typeof body.next_offset === 'number' ? body.next_offset : null,
45
+ };
46
+ }
47
+ /**
48
+ * One line telling the author the list was cut short and how to see the rest —
49
+ * `has_more` existed on every response and no command surfaced it, so a
50
+ * truncated list was indistinguishable from a complete one.
51
+ *
52
+ * Empty string when there is nothing to say, so callers can print it
53
+ * unconditionally.
54
+ */
55
+ export function morePageHint(page, command) {
56
+ if (!page.hasMore || page.nextOffset == null)
57
+ return '';
58
+ const shown = page.rows.length;
59
+ const of = page.total != null ? ` of ${page.total}` : '';
60
+ return ` … showing ${shown}${of} — next page: ${command} --offset ${page.nextOffset}`;
61
+ }
@@ -17,14 +17,23 @@
17
17
  *
18
18
  * Degrades to a no-op when the KB has not been pulled — never invents a rule it
19
19
  * cannot source, and never blocks `validate` for an author who hasn't pulled yet.
20
+ * It SAYS SO when it does: the caller gets a `KbLookup`, not a bare null, so a
21
+ * skipped check can never be reported as a passed one.
20
22
  */
21
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
23
+ import { readdirSync, readFileSync } from 'node:fs';
22
24
  import { join } from 'node:path';
23
- /** Load `render_intent -> allowed_keys` from the pulled KB, or null when absent. */
25
+ import { lookupKbSubdir } from './kb-path.js';
26
+ /**
27
+ * Load `render_intent -> allowed_keys` from the pulled KB.
28
+ *
29
+ * Returns the three-state lookup alongside the map so the caller can tell
30
+ * "checked, nothing wrong" from "could not check". See `kb-path.ts`.
31
+ */
24
32
  export function loadAllowedRenderKeys(packDir) {
25
- const dir = join(packDir, '.octwin', 'platform-kb', 'render-intents');
26
- if (!existsSync(dir))
27
- return null;
33
+ const lookup = lookupKbSubdir(packDir, 'render-intents', dir => readdirSync(dir).some(f => f.endsWith('.json')));
34
+ if (lookup.state !== 'ok')
35
+ return { lookup, keys: null };
36
+ const dir = lookup.dir;
28
37
  const out = new Map();
29
38
  try {
30
39
  for (const file of readdirSync(dir)) {
@@ -36,10 +45,13 @@ export function loadAllowedRenderKeys(packDir) {
36
45
  }
37
46
  }
38
47
  }
39
- catch {
40
- return null;
48
+ catch (err) {
49
+ return { lookup: { state: 'malformed', dir, reason: String(err?.message ?? err) }, keys: null };
50
+ }
51
+ if (out.size === 0) {
52
+ return { lookup: { state: 'malformed', dir, reason: 'no render-intent entries parsed' }, keys: null };
41
53
  }
42
- return out.size ? out : null;
54
+ return { lookup, keys: out };
43
55
  }
44
56
  /**
45
57
  * Walk parsed YAML for objects carrying `render_intent` and report keys outside
@@ -15,7 +15,7 @@
15
15
  * locally but the deploy rejected it".
16
16
  */
17
17
  /** Declarative TEXT extensions a pure-YAML pack may contain. */
18
- const ALLOWED_EXT = new Set(['yaml', 'yml', 'md', 'sql', 'json']);
18
+ const ALLOWED_EXT = new Set(['yaml', 'yml', 'md', 'json']);
19
19
  /** Binary extensions that travel as artifact BLOBS (base64 on the wire, bytea in
20
20
  * storage). No `svg` — it is script-capable and these are served to browsers. */
21
21
  const ALLOWED_BINARY_EXT = new Set(['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf']);
@@ -70,6 +70,15 @@ blobs = {}) {
70
70
  errors.push(`'${p}': pack primitives are not allowed (pure-YAML packs only)`);
71
71
  continue;
72
72
  }
73
+ // `.sql` used to be allowed and was executed VERBATIM by the pack migration
74
+ // runner. Packs no longer own a database, so there is nothing for it to run
75
+ // against — and rejecting it here is finally enforceable, which is what this
76
+ // module's header has always claimed. Named explicitly so the author gets a
77
+ // reason instead of a bare "not an allowed pack file type".
78
+ if (ext(p) === 'sql') {
79
+ errors.push(`'${p}': packs have no database — SQL is not allowed. Model the data in \`xrm.yaml\`, which is first-class platform storage.`);
80
+ continue;
81
+ }
73
82
  const e = ext(p);
74
83
  if (CODE_EXT.has(e)) {
75
84
  errors.push(`'${p}': executable code is not allowed (pure-YAML packs only)`);
@@ -80,7 +89,7 @@ blobs = {}) {
80
89
  continue;
81
90
  }
82
91
  if (!ALLOWED_EXT.has(e)) {
83
- errors.push(`'${p}': not an allowed pack file type (.yaml/.yml/.md/.sql/.json, or an image: ${[...ALLOWED_BINARY_EXT].join('/')})`);
92
+ errors.push(`'${p}': not an allowed pack file type (.yaml/.yml/.md/.json, or an image: ${[...ALLOWED_BINARY_EXT].join('/')})`);
84
93
  continue;
85
94
  }
86
95
  }
package/package.json CHANGED
@@ -1,37 +1,37 @@
1
- {
2
- "name": "octwin-cli",
3
- "version": "0.1.21",
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.5.1",
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,54 +1,55 @@
1
- # manifest.yaml — your pack is declared entirely by this one file. Required:
2
- # id, version (a STRING, e.g. '1.0.0'), description. Everything else is optional
3
- # and grows with your domain. `octwin validate` checks it locally; the platform
4
- # re-validates the full schema on `octwin deploy`.
5
-
6
- id: starter-kit
7
- version: 1.0.0
8
- description: 'A hello bot — edit this to build your pack.'
9
-
10
- # Channels this pack supports + platform capabilities it needs. A pack with
11
- # no database reads only needs `messaging`. Add data-store / embedding /
12
- # vision / storage as your domain grows (see real-estate's manifest).
13
- supported_channels: [whatsapp, web]
14
- required_adapters: [messaging]
15
-
16
- # Default locale for platform-emitted copy + this pack's `$t()` lookups.
17
- default_settings:
18
- locale: ar
19
-
20
- # Agent-callable flow tools. Each id maps to `flows/tools/<id>.flow.yaml`
21
- # (+ its `<id>.locale.<lang>.yaml`). List the HOME hub first — it's the pack's
22
- # front door; add each new tool here as you build it.
23
- flows:
24
- - home # the menu / navigation hub (see references/ux-patterns.md)
25
- - browse # an example tool the hub invokes — replace with your real capability
26
-
27
- # One agent. The platform's universal agent protocol is auto-appended by
28
- # `createPackAgents` `instructions:` carries only pack-supplied parts.
29
- agents:
30
- - id: assistant
31
- display_name: 'Starter Assistant'
32
- # `openrouter/` prefix routes via OpenRouter. Operators override the
33
- # model per-project from the console.
34
- default_model: 'openrouter/google/gemini-3.1-flash-lite-preview'
35
- # Flow tools this agent can call (subset of `flows:` above; hub first).
36
- tools:
37
- - home
38
- - browse
39
- include_platform_protocol: true
40
- # Instruction parts, joined with '\n\n'. `file:` paths are pack-root
41
- # relative; `.md` files may use built-in placeholders like {{pack.id}}.
42
- instructions:
43
- - file: prompts/identity.md
44
- # No working_memory block — deliberate, and you should keep it that way.
45
- # Declaring one makes the platform add Mastra's updateWorkingMemory tool +
46
- # a "call it every turn" instruction: an extra LLM generation on most turns,
47
- # and small models (like the lite default above) parrot the
48
- # <working_memory_data> envelope straight into user-visible replies. Session
49
- # identity (name/lang/phone) already reaches the model via the platform's
50
- # first-turn [ctx: …] header, and tools read it from the request context —
51
- # so you gain nothing here. Leave it off unless you're on a large model and
52
- # have a concrete slot schema to maintain.
53
- default_memory:
54
- lastMessages: 10 # size of the recalled message window
1
+ # manifest.yaml — your pack is declared entirely by this one file. Required:
2
+ # id, version (a STRING, e.g. '1.0.0'), description. Everything else is optional
3
+ # and grows with your domain. `octwin validate` checks it locally; the platform
4
+ # re-validates the full schema on `octwin deploy`.
5
+
6
+ id: starter-kit
7
+ version: 1.0.0
8
+ description: 'A hello bot — edit this to build your pack.'
9
+
10
+ # Channels this pack supports + platform capabilities it needs. A pack with
11
+ # no media or search only needs `messaging`. Add embedding / vision / storage
12
+ # as your domain grows. (Your data model goes in `xrm.yaml` — packs have no
13
+ # database of their own.)
14
+ supported_channels: [whatsapp, web]
15
+ required_adapters: [messaging]
16
+
17
+ # Default locale for platform-emitted copy + this pack's `$t()` lookups.
18
+ default_settings:
19
+ locale: ar
20
+
21
+ # Agent-callable flow tools. Each id maps to `flows/tools/<id>.flow.yaml`
22
+ # (+ its `<id>.locale.<lang>.yaml`). List the HOME hub first it's the pack's
23
+ # front door; add each new tool here as you build it.
24
+ flows:
25
+ - home # the menu / navigation hub (see references/ux-patterns.md)
26
+ - browse # an example tool the hub invokes — replace with your real capability
27
+
28
+ # One agent. The platform's universal agent protocol is auto-appended by
29
+ # `createPackAgents` — `instructions:` carries only pack-supplied parts.
30
+ agents:
31
+ - id: assistant
32
+ display_name: 'Starter Assistant'
33
+ # `openrouter/` prefix routes via OpenRouter. Operators override the
34
+ # model per-project from the console.
35
+ default_model: 'openrouter/google/gemini-3.1-flash-lite-preview'
36
+ # Flow tools this agent can call (subset of `flows:` above; hub first).
37
+ tools:
38
+ - home
39
+ - browse
40
+ include_platform_protocol: true
41
+ # Instruction parts, joined with '\n\n'. `file:` paths are pack-root
42
+ # relative; `.md` files may use built-in placeholders like {{pack.id}}.
43
+ instructions:
44
+ - file: prompts/identity.md
45
+ # No working_memory block deliberate, and you should keep it that way.
46
+ # Declaring one makes the platform add Mastra's updateWorkingMemory tool +
47
+ # a "call it every turn" instruction: an extra LLM generation on most turns,
48
+ # and small models (like the lite default above) parrot the
49
+ # <working_memory_data> envelope straight into user-visible replies. Session
50
+ # identity (name/lang/phone) already reaches the model via the platform's
51
+ # first-turn [ctx: …] header, and tools read it from the request context
52
+ # so you gain nothing here. Leave it off unless you're on a large model and
53
+ # have a concrete slot schema to maintain.
54
+ default_memory:
55
+ lastMessages: 10 # size of the recalled message window