octwin-cli 0.3.0 → 0.6.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,107 @@
1
+ /**
2
+ * Offline `use:` template check, driven by the pulled platform KB.
3
+ *
4
+ * WHY it matters: `use:` splices a template's NODES into the flow at load time,
5
+ * before any expression evaluates, and its `with:` block is the only thing the
6
+ * spliced subtree can see. So a mistyped param name does not error — it arrives
7
+ * as `undefined` inside the template body, and the card renders with a blank
8
+ * where the value should be, or a confirm step approves with no preview. The
9
+ * platform's own `approve_apply` is the common case: `confirm_title` misspelled
10
+ * silently falls back to the hardcoded Arabic default.
11
+ *
12
+ * PACK TEMPLATES SHADOW PLATFORM ONES. A pack may ship `templates/<name>.yaml`
13
+ * of its own, and a pack template of the same name wins. So the caller passes
14
+ * the pack's own template names and this check stays silent about them entirely
15
+ * — it has no schema for a template it did not publish, and inventing one would
16
+ * flag every pack that uses its own.
17
+ *
18
+ * WHAT IT DOES NOT CHECK: a pack template's params (no published schema, see
19
+ * above), and the VALUES passed to a platform template's params — a `with:`
20
+ * value is almost always an expression resolved at runtime, exactly as in
21
+ * `args-check.ts`.
22
+ */
23
+ import { readdirSync, readFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import { lookupKbSubdir, isEntryFile } from './kb-path.js';
26
+ /** Load platform template param specs from the pulled KB. */
27
+ export function loadTemplateSpecs(packDir) {
28
+ const lookup = lookupKbSubdir(packDir, 'templates', dir => readdirSync(dir).some(isEntryFile));
29
+ if (lookup.state !== 'ok')
30
+ return { lookup, specs: null };
31
+ const dir = lookup.dir;
32
+ const out = new Map();
33
+ try {
34
+ for (const file of readdirSync(dir)) {
35
+ if (!isEntryFile(file))
36
+ continue;
37
+ const entry = JSON.parse(readFileSync(join(dir, file), 'utf8'));
38
+ if (!entry.name)
39
+ continue;
40
+ const params = Array.isArray(entry.params) ? entry.params : [];
41
+ out.set(entry.name, {
42
+ name: entry.name,
43
+ params: params.map(p => p.name).filter((n) => !!n),
44
+ required: params.filter(p => p.required === true).map(p => p.name).filter((n) => !!n),
45
+ });
46
+ }
47
+ }
48
+ catch (err) {
49
+ return { lookup: { state: 'malformed', dir, reason: String(err?.message ?? err) }, specs: null };
50
+ }
51
+ if (!out.size)
52
+ return { lookup: { state: 'malformed', dir, reason: 'no template entries parsed' }, specs: null };
53
+ return { lookup, specs: out };
54
+ }
55
+ /**
56
+ * Walk parsed YAML for `use:` nodes and check them against the platform specs.
57
+ * `packTemplates` names the templates this pack ships itself — those are skipped.
58
+ */
59
+ export function findTemplateViolations(doc, file, specs, packTemplates) {
60
+ const findings = [];
61
+ const walk = (node) => {
62
+ if (Array.isArray(node)) {
63
+ node.forEach(walk);
64
+ return;
65
+ }
66
+ if (!node || typeof node !== 'object')
67
+ return;
68
+ const obj = node;
69
+ const name = obj.use;
70
+ if (typeof name === 'string' && !packTemplates.has(name)) {
71
+ const spec = specs.get(name);
72
+ if (!spec) {
73
+ findings.push({ kind: 'unknown', file, template: name, known: [...specs.keys()].sort() });
74
+ }
75
+ else {
76
+ const withBlock = obj.with;
77
+ const passed = withBlock && typeof withBlock === 'object' && !Array.isArray(withBlock)
78
+ ? Object.keys(withBlock)
79
+ : [];
80
+ const unknown = passed.filter(k => !spec.params.includes(k));
81
+ if (unknown.length)
82
+ findings.push({ kind: 'unknown-param', file, template: name, keys: unknown, params: [...spec.params].sort() });
83
+ const missing = spec.required.filter(k => !passed.includes(k));
84
+ if (missing.length)
85
+ findings.push({ kind: 'missing', file, template: name, keys: missing });
86
+ }
87
+ }
88
+ for (const v of Object.values(obj))
89
+ walk(v);
90
+ };
91
+ walk(doc);
92
+ return findings;
93
+ }
94
+ /** One-line human message per finding. */
95
+ export function describeTemplateFinding(f) {
96
+ switch (f.kind) {
97
+ case 'unknown':
98
+ return `${f.file}: \`use: ${f.template}\` — no such template. Platform templates: ${f.known.join(', ')}`
99
+ + `. (A template of your own must live at templates/${f.template}.yaml in the pack.)`;
100
+ case 'unknown-param':
101
+ return `${f.file}: \`use: ${f.template}\` passes ${f.keys.map(k => `'${k}'`).join(', ')}, which the template declares no param for `
102
+ + `— dropped in silence, and the body sees undefined. Params: ${f.params.join(', ')}`;
103
+ case 'missing':
104
+ return `${f.file}: \`use: ${f.template}\` is missing required param${f.keys.length === 1 ? '' : 's'} `
105
+ + `${f.keys.map(k => `'${k}'`).join(', ')} — the body reads undefined where the value should be`;
106
+ }
107
+ }
@@ -14,6 +14,33 @@
14
14
  * every rule, so a divergence fails the build rather than surfacing as "it passed
15
15
  * locally but the deploy rejected it".
16
16
  */
17
+ /**
18
+ * The pack-name grammar, vendored from `src/platform/core/kernel/pack-id.ts`.
19
+ *
20
+ * Your `manifest.yaml` declares a BARE name (`clinic`). The platform prefixes your
21
+ * workspace slug when it publishes, so the pack becomes `acme.clinic` in the catalog
22
+ * — you never write the owner half, and therefore cannot claim someone else's.
23
+ */
24
+ const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
25
+ /** Validate a raw string as a pack name, or `null`. The only way to mint a `PackName`. */
26
+ export function asPackName(raw) {
27
+ return describePackNameProblem(raw) === null ? raw : null;
28
+ }
29
+ /** Why the manifest's `id` is unusable, or null. Prose, because an author reads it. */
30
+ export function describePackNameProblem(name) {
31
+ if (!name)
32
+ return 'manifest.yaml must declare a non-empty `id`';
33
+ if (name.includes('.')) {
34
+ return `pack id '${name}' must not contain '.' — declare the bare name ` +
35
+ `('${name.slice(name.indexOf('.') + 1)}'); the platform prefixes your workspace ` +
36
+ `when it publishes`;
37
+ }
38
+ if (!SLUG_RE.test(name)) {
39
+ return `pack id '${name}' must be lowercase letters, digits and inner hyphens, ` +
40
+ `1–40 chars (e.g. 'clinic')`;
41
+ }
42
+ return null;
43
+ }
17
44
  /** Declarative TEXT extensions a pure-YAML pack may contain. */
18
45
  const ALLOWED_EXT = new Set(['yaml', 'yml', 'md', 'json']);
19
46
  /** Binary extensions that travel as artifact BLOBS (base64 on the wire, bytea in
@@ -39,13 +66,15 @@ function ext(p) {
39
66
  * no path traversal or absolute paths, declarative extensions only. Returns all
40
67
  * violations at once.
41
68
  */
42
- export function validatePackBundle(packId, files,
69
+ export function validatePackBundle(
70
+ /** The manifest's BARE name — the platform prefixes your workspace at publish. */
71
+ packName, files,
43
72
  /** `{ relPath: base64 }` — the binary half, as the CLI collects it. */
44
73
  blobs = {}) {
45
74
  const errors = [];
46
- if (!/^[a-z][a-z0-9-]*$/.test(packId)) {
47
- errors.push(`pack id '${packId}' must be lowercase ASCII with hyphens (e.g. 'my-pack')`);
48
- }
75
+ const idProblem = describePackNameProblem(packName);
76
+ if (idProblem)
77
+ errors.push(idProblem);
49
78
  const paths = Object.keys(files);
50
79
  if (paths.length === 0 && Object.keys(blobs).length === 0)
51
80
  errors.push('bundle is empty');
@@ -0,0 +1,29 @@
1
+ /**
2
+ * YAML source line for a node path — the CLI-side twin of the platform's
3
+ * `core/utils/yaml-position.ts` (E-08). Standalone because the CLI is
4
+ * platform-free by construction; both are ~40 lines over the same `yaml` API.
5
+ *
6
+ * `yamlLineOf(raw, path)` parses once per call — fine here, because findings
7
+ * are rare (the happy path calls this zero times). When the exact node doesn't
8
+ * resolve, walks up the path so the enclosing node's line is reported.
9
+ */
10
+ import { LineCounter, parseDocument } from 'yaml';
11
+ export function yamlLineOf(raw, path) {
12
+ const lineCounter = new LineCounter();
13
+ let doc;
14
+ try {
15
+ doc = parseDocument(raw, { lineCounter, keepSourceTokens: true });
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ for (let end = path.length; end >= 0; end--) {
21
+ const node = end === 0
22
+ ? doc.contents
23
+ : doc.getIn(path.slice(0, end), true);
24
+ const start = node?.range?.[0];
25
+ if (typeof start === 'number')
26
+ return lineCounter.linePos(start).line;
27
+ }
28
+ return null;
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octwin-cli",
3
- "version": "0.3.0",
3
+ "version": "0.6.0",
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": {