octwin-cli 0.1.16 → 0.1.21

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,110 @@
1
+ /**
2
+ * Offline `do:`/`args:` check, driven by the pulled platform KB.
3
+ *
4
+ * WHY this exists as its own module: `validate.ts` is a vendored, parity-locked
5
+ * mirror of the server's structural validator (`validate-parity.test.ts` fails
6
+ * the build if the two diverge), so it cannot grow rules of its own. This is a
7
+ * different thing — the platform's own primitive-argument contract, replayed
8
+ * locally from `.octwin/platform-kb/primitives/*.json`. Same shape as the
9
+ * render-intent check next door.
10
+ *
11
+ * WHY it matters: an argument a primitive does not declare used to be dropped
12
+ * in silence. `record_list args: { order: … }` (the arg is `sort`) deployed
13
+ * clean, ran clean, and simply did not order; `booking_cancel args: {
14
+ * booking_record_id: … }` meant the REQUIRED `record_id` never arrived and
15
+ * cancelling failed. A scan of the shipped marketplace packs found ten such
16
+ * errors across five packs, so this is not hypothetical. The platform now
17
+ * rejects them at validate; this catches them one step earlier, offline, with
18
+ * no token and no round-trip.
19
+ *
20
+ * KEYS ARE CHECKED, VALUES ARE NOT. An `args:` value is almost always an
21
+ * expression string resolved at runtime, so its YAML type says nothing.
22
+ *
23
+ * TWO LIMITS, both deliberate:
24
+ * • Degrades to a no-op when the KB has not been pulled — never invents a
25
+ * rule it cannot source, never blocks an author who hasn't pulled yet.
26
+ * • Cannot see inside a `use:` template body, because expanding one needs the
27
+ * platform's expander. `octwin validate --remote` covers that case; this
28
+ * covers everything written directly in the flow.
29
+ */
30
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
31
+ import { join } from 'node:path';
32
+ /** Load `primitive -> ArgSpec` from the pulled KB, or null when absent. */
33
+ export function loadPrimitiveArgSpecs(packDir) {
34
+ const dir = join(packDir, '.octwin', 'platform-kb', 'primitives');
35
+ if (!existsSync(dir))
36
+ return null;
37
+ const out = new Map();
38
+ try {
39
+ for (const file of readdirSync(dir)) {
40
+ if (!file.endsWith('.json'))
41
+ continue;
42
+ const entry = JSON.parse(readFileSync(join(dir, file), 'utf8'));
43
+ const schema = entry.inputSchema;
44
+ if (!entry.name || !schema?.properties)
45
+ continue;
46
+ // Same closedness rule the platform uses: `additionalProperties` absent or
47
+ // `false` is CLOSED; anything else (a passthrough/record) is open.
48
+ const ap = schema.additionalProperties;
49
+ out.set(entry.name, {
50
+ keys: Object.keys(schema.properties),
51
+ required: Array.isArray(schema.required) ? schema.required : [],
52
+ open: ap !== undefined && ap !== false,
53
+ });
54
+ }
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ return out.size ? out : null;
60
+ }
61
+ /**
62
+ * Walk parsed YAML for `do:` nodes and report `args:` keys outside the
63
+ * primitive's declared set. Walks the whole document rather than just `steps:`
64
+ * — a `do:` is legal in a `foreach` body, a `collect` hook, an `outputs:` port
65
+ * body and `require:`, and a dropped argument is just as invisible there.
66
+ */
67
+ export function findArgViolations(doc, file, specs) {
68
+ const findings = [];
69
+ const walk = (node) => {
70
+ if (Array.isArray(node)) {
71
+ node.forEach(walk);
72
+ return;
73
+ }
74
+ if (!node || typeof node !== 'object')
75
+ return;
76
+ const obj = node;
77
+ if (typeof obj.do === 'string') {
78
+ const spec = specs.get(obj.do);
79
+ // An unknown primitive NAME is the remote validator's error to report —
80
+ // it knows the full set including whatever the pack itself ships.
81
+ if (spec && !spec.open) {
82
+ const args = (obj.args && typeof obj.args === 'object' && !Array.isArray(obj.args))
83
+ ? obj.args
84
+ : {};
85
+ const unknown = Object.keys(args).filter(k => !spec.keys.includes(k));
86
+ 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() });
89
+ }
90
+ }
91
+ }
92
+ for (const v of Object.values(obj))
93
+ walk(v);
94
+ };
95
+ walk(doc);
96
+ return findings;
97
+ }
98
+ /** One-line human message per finding. */
99
+ export function describeArgFinding(f) {
100
+ const parts = [];
101
+ if (f.unknown.length) {
102
+ const plural = f.unknown.length === 1 ? 'argument' : 'arguments';
103
+ parts.push(`unknown ${plural} ${f.unknown.map(k => `'${k}'`).join(', ')} — silently dropped`);
104
+ }
105
+ if (f.missing.length) {
106
+ const plural = f.missing.length === 1 ? 'argument' : 'arguments';
107
+ parts.push(`missing required ${plural} ${f.missing.map(k => `'${k}'`).join(', ')}`);
108
+ }
109
+ return `${f.file}: \`${f.primitive}\` has ${parts.join('; ')}. It takes: ${f.declared.join(', ') || 'no arguments'}`;
110
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * What counts as pack CONTENT, and which half of an artifact a file belongs to —
3
+ * client side.
4
+ *
5
+ * A VENDORED, platform-free copy of the server's shared path rule
6
+ * (`src/platform/core/pack-meta/pack-source.ts`), for the same forced reason as
7
+ * `validate.ts` next door: `octwin-cli` is published to npm and cannot import the
8
+ * platform.
9
+ *
10
+ * Why it must be shared at all: the operator's GitHub **repo import** and this
11
+ * CLI's **deploy** both turn a pack directory into the two halves of an artifact,
12
+ * and they used to answer "is this file pack content?" differently. The CLI kept
13
+ * `*.ts`, `__snapshots__/` and `*.example`; the import dropped them. Same
14
+ * directory, two verdicts — and since the server's `validatePackBundle` rejects a
15
+ * `.ts` outright, a pack that imported cleanly from GitHub failed to deploy from
16
+ * disk with "executable code is not allowed", blaming a file that was never meant
17
+ * to ship. `pack-source-parity.test.ts` now drives both copies over one table.
18
+ */
19
+ /** Directories that never contain pack content. */
20
+ const SKIP_DIRS = new Set(['node_modules', '__snapshots__', 'dist']);
21
+ /**
22
+ * Binary extensions that travel as artifact BLOBS. Mirrors `ALLOWED_BINARY_EXT` in
23
+ * the validator — which is the authority; this list only decides which half of the
24
+ * artifact a file lands in.
25
+ */
26
+ const BINARY_EXT = new Set(['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf']);
27
+ /**
28
+ * A directory that never contains pack content.
29
+ *
30
+ * Exported because a filesystem walker must PRUNE before it descends, while
31
+ * `classifyPackPath` only ever sees a finished path.
32
+ */
33
+ export function isSkippedDir(name) {
34
+ return SKIP_DIRS.has(name) || name.startsWith('.');
35
+ }
36
+ /**
37
+ * Is this file author-time scaffolding rather than pack content?
38
+ *
39
+ * `.ts` is excluded wholesale, which is what keeps every artifact code-free. A pack
40
+ * that genuinely needs a primitive must ship it compiled to `.js` inside the
41
+ * artifact, because plain `node` cannot import a `.ts` in production.
42
+ */
43
+ function isNotPackContent(name) {
44
+ if (name.startsWith('.'))
45
+ return true; // .env, .gitignore — not content
46
+ if (name.endsWith('.ts') || name.endsWith('.tsx'))
47
+ return true;
48
+ if (name.endsWith('.example'))
49
+ return true; // xrm.yaml.example — an author template
50
+ return false;
51
+ }
52
+ /**
53
+ * Which half of an artifact a pack-relative path belongs to — or neither.
54
+ *
55
+ * Path-based so it classifies a tar entry and a directory listing identically: a
56
+ * `skip` verdict fires when ANY segment is a skipped directory or the basename is
57
+ * scaffolding.
58
+ */
59
+ export function classifyPackPath(relPath) {
60
+ const segments = relPath.split('/');
61
+ const name = segments[segments.length - 1] ?? '';
62
+ if (segments.slice(0, -1).some(isSkippedDir))
63
+ return 'skip';
64
+ if (isNotPackContent(name))
65
+ return 'skip';
66
+ const ext = name.split('.').pop()?.toLowerCase() ?? '';
67
+ return BINARY_EXT.has(ext) ? 'blob' : 'text';
68
+ }
@@ -1,20 +1,21 @@
1
1
  /**
2
2
  * rename.ts — rename map + safe substitution over a copied starter tree.
3
3
  *
4
- * A VENDORED, platform-free copy of the platform scaffolder's rename module
5
- * (`scripts/pack-scaffold/rename.ts`). `octwin init` copies the in-package
6
- * `templates/starter/` tree (packId `starter-kit`, flow `main`, agent
7
- * `assistant`) then this rewrites the copy into a fresh pack:
4
+ * Platform-free by construction the CLI ships with zero platform imports. (It began as a
5
+ * vendored copy of ~~`scripts/pack-scaffold/rename.ts`~~, deleted with the in-repo packs;
6
+ * this is the only copy now.) `octwin init` copies the in-package
7
+ * `templates/starter/` tree (packId `starter-kit`, agent `assistant`, flows
8
+ * `home` + `browse`) then this rewrites the copy into a fresh pack:
8
9
  * • `starter-kit` → <pack-id> (manifest id, comments, README)
9
- * • agent display name + id (when --display-name / --agent given)
10
+ * • agent display name (when --display-name given)
10
11
  * • manifest description (when --description given)
11
- * • the `main` flow → <flow-id> (when --flow given): file renames + scoped edits
12
12
  *
13
- * Flow renaming is scoped to the exact syntactic forms the template uses
14
- * (flow_id, manifest flows/tools entries, `$t("main.…")` / `'main.…'` locale
15
- * namespaces) never a blind `main` substring replace.
13
+ * ~~It also renamed the agent id and a `main` flow~~ behind `--agent` / `--flow` options
14
+ * the CLI never parsed: `octwin init` passes the template's own ids, and the template ships
15
+ * no `main` flow at all, so both branches were unreachable and their patterns would have
16
+ * matched nothing. Renaming a flow is an EDIT after scaffolding, not an init flag.
16
17
  */
17
- import { readdirSync, statSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
18
+ import { readdirSync, statSync, readFileSync, writeFileSync } from 'node:fs';
18
19
  import { join } from 'node:path';
19
20
  const TEXT_EXT = /\.(ya?ml|md|sql|json)$/;
20
21
  function walkFiles(dir) {
@@ -49,30 +50,9 @@ function contentReplacements(opts) {
49
50
  const safe = opts.displayName.replace(/'/g, "''");
50
51
  edits.push([/^(\s*display_name:\s*).*$/m, `$1'${safe}'`]);
51
52
  }
52
- // 4. Agent id — the manifest agents[] list item and the messages.<lang>.yaml key.
53
- if (opts.agentId !== 'assistant') {
54
- edits.push([/^(\s*-\s*id:\s+)assistant\s*$/m, `$1${opts.agentId}`]); // manifest agents[].id
55
- edits.push([/^(\s+)assistant:(\s*)$/m, `$1${opts.agentId}:$2`]); // messages.<lang>.yaml agents.<id>
56
- }
57
- // 5. Flow id — scoped forms only (never a blind `main` substring replace).
58
- if (opts.flowId !== 'main') {
59
- const f = opts.flowId;
60
- edits.push([/^flow_id:(\s*)main\s*$/m, `flow_id:$1${f}`]); // flow + locale headers
61
- edits.push([/^(\s*-\s*)main\s*$/gm, `$1${f}`]); // manifest flows: + tools: entries
62
- edits.push([new RegExp(`(\\$t\\(["'])main\\.`, 'g'), `$1${f}.`]); // $t("main.…")
63
- }
64
53
  return { global, manifestOnly };
65
54
  }
66
- /** File-basename prefix renames (e.g. `main.flow.yaml` `<flow>.flow.yaml`). */
67
- function fileRenames(opts) {
68
- if (opts.flowId === 'main')
69
- return [];
70
- return [['main.', `${opts.flowId}.`]];
71
- }
72
- /**
73
- * Apply all renames in-place to a freshly-copied pack tree at `destDir`.
74
- * Content edits run first, then file renames (so edits see original names).
75
- */
55
+ /** Apply all renames in-place to a freshly-copied pack tree at `destDir`. */
76
56
  export function applyRenames(destDir, opts) {
77
57
  const { global, manifestOnly } = contentReplacements(opts);
78
58
  for (const file of walkFiles(destDir)) {
@@ -91,15 +71,6 @@ export function applyRenames(destDir, opts) {
91
71
  if (changed)
92
72
  writeFileSync(file, text, 'utf8');
93
73
  }
94
- for (const [fromPrefix, toPrefix] of fileRenames(opts)) {
95
- for (const file of walkFiles(destDir)) {
96
- const dir = file.slice(0, file.length - basename(file).length);
97
- const base = basename(file);
98
- if (base.startsWith(fromPrefix)) {
99
- renameSync(file, join(dir, toPrefix + base.slice(fromPrefix.length)));
100
- }
101
- }
102
- }
103
74
  }
104
75
  function basename(p) {
105
76
  const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Offline render-intent field check, driven by the pulled platform KB.
3
+ *
4
+ * WHY this exists as its own module: `validate.ts` is a vendored, parity-locked
5
+ * mirror of the server's structural validator (`validate-parity.test.ts` fails the
6
+ * build if the two diverge), so it cannot grow rules of its own. This check is a
7
+ * different thing — not a structural rule, but the platform's own render-intent
8
+ * contract replayed locally from `.octwin/platform-kb/render-intents/*.json`.
9
+ *
10
+ * WHY it matters: an unknown key on a render intent used to be swallowed at load
11
+ * and dropped at render — no error anywhere, just a card missing the field the
12
+ * author wrote. The platform now rejects it at parse; this catches it one step
13
+ * earlier, offline, with no token and no round-trip. A scan of the 21 shipped
14
+ * marketplace packs found exactly this class of bug twice (a `text_card` carrying
15
+ * `buttons`, which text_card has never rendered; a `list_picker` carrying a
16
+ * `group_by` that does not exist), so it is not a hypothetical.
17
+ *
18
+ * Degrades to a no-op when the KB has not been pulled — never invents a rule it
19
+ * cannot source, and never blocks `validate` for an author who hasn't pulled yet.
20
+ */
21
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ /** Load `render_intent -> allowed_keys` from the pulled KB, or null when absent. */
24
+ export function loadAllowedRenderKeys(packDir) {
25
+ const dir = join(packDir, '.octwin', 'platform-kb', 'render-intents');
26
+ if (!existsSync(dir))
27
+ return null;
28
+ const out = new Map();
29
+ try {
30
+ for (const file of readdirSync(dir)) {
31
+ if (!file.endsWith('.json'))
32
+ continue;
33
+ const entry = JSON.parse(readFileSync(join(dir, file), 'utf8'));
34
+ if (entry.render_intent && Array.isArray(entry.allowed_keys)) {
35
+ out.set(entry.render_intent, entry.allowed_keys);
36
+ }
37
+ }
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ return out.size ? out : null;
43
+ }
44
+ /**
45
+ * Walk parsed YAML for objects carrying `render_intent` and report keys outside
46
+ * that intent's allowed set. Walks the whole document rather than just `render:`
47
+ * nodes: intents nest (an `auto_collection` carries `empty`/`single`/`multi`
48
+ * sub-intents), and a stray key is just as invisible there.
49
+ */
50
+ export function findRenderKeyViolations(doc, file, allowedByIntent) {
51
+ const findings = [];
52
+ const walk = (node) => {
53
+ if (Array.isArray(node)) {
54
+ node.forEach(walk);
55
+ return;
56
+ }
57
+ if (!node || typeof node !== 'object')
58
+ return;
59
+ const obj = node;
60
+ if (typeof obj.render_intent === 'string') {
61
+ const allowed = allowedByIntent.get(obj.render_intent);
62
+ if (!allowed) {
63
+ findings.push({ file, intent: obj.render_intent, keys: [], allowed: [...allowedByIntent.keys()] });
64
+ }
65
+ else {
66
+ const bad = Object.keys(obj).filter(k => !allowed.includes(k));
67
+ if (bad.length)
68
+ findings.push({ file, intent: obj.render_intent, keys: bad, allowed });
69
+ }
70
+ }
71
+ for (const v of Object.values(obj))
72
+ walk(v);
73
+ };
74
+ walk(doc);
75
+ return findings;
76
+ }
77
+ /** One-line human message per finding. */
78
+ export function describeRenderFinding(f) {
79
+ if (f.keys.length === 0) {
80
+ return `${f.file}: unknown render_intent '${f.intent}' — known intents: ${f.allowed.join(', ')}`;
81
+ }
82
+ const plural = f.keys.length === 1 ? 'field' : 'fields';
83
+ return `${f.file}: render_intent '${f.intent}' has unknown ${plural} ${f.keys.map(k => `'${k}'`).join(', ')} ` +
84
+ `— silently dropped at render. Allowed: ${f.allowed.join(', ')}`;
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octwin-cli",
3
- "version": "0.1.16",
3
+ "version": "0.1.21",
4
4
  "description": "Octwin external-pack developer CLI (by CEQUENS) — scaffold, validate, deploy, and check pure-YAML packs on your tenant.",
5
5
  "type": "module",
6
6
  "bin": {