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.
- package/CHANGELOG.md +136 -0
- package/README.md +33 -25
- package/dist/index.js +637 -179
- package/dist/lib/args-check.js +110 -0
- package/dist/lib/pack-source.js +68 -0
- package/dist/lib/rename.js +12 -41
- package/dist/lib/render-check.js +85 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/lib/rename.js
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* rename.ts — rename map + safe substitution over a copied starter tree.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
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
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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