octwin-cli 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CEQUENS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # octwin-cli
2
+
3
+ The **Octwin** external-pack developer CLI, by **CEQUENS**. Scaffold a pure-YAML
4
+ pack in your own repo, validate it offline, and deploy it to a running Octwin
5
+ platform to test on your own tenant — no platform checkout, no build step.
6
+
7
+ The package is `octwin-cli`; the command it installs is `octwin`.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ # zero-install (recommended)
13
+ npx octwin-cli <command>
14
+
15
+ # or install the command globally
16
+ npm i -g octwin-cli
17
+ octwin <command>
18
+ ```
19
+
20
+ ## The loop
21
+
22
+ ```bash
23
+ # 1. Scaffold a standalone pure-YAML pack (yours)
24
+ octwin init ./my-pack --id my-pack --description "My business bot"
25
+ cd ./my-pack && git init && git add -A && git commit -m "init pack"
26
+
27
+ # 2. Author: edit manifest.yaml, flows/tools/main.flow.yaml (+ locale),
28
+ # prompts/identity.md. Everything is pure YAML/SQL — no TypeScript.
29
+
30
+ # 3. Validate locally (same structural gate the server runs)
31
+ octwin validate
32
+
33
+ # 4. Log in with a DEPLOY TOKEN from the console (Workspace → API tokens →
34
+ # Generate), then deploy. Point pack.json at your platform + tenant first.
35
+ octwin login --url https://platform.example.com --token cqp_…
36
+ octwin whoami --tenant my-tenant
37
+ octwin deploy --tenant my-tenant --project main # add --seed to seed demo data
38
+
39
+ # 5. Confirm it landed — which version is live?
40
+ octwin status --tenant my-tenant --project main
41
+
42
+ # 6. Chat on your tenant (web widget / console test page). Edit → deploy again:
43
+ # a redeploy hot-loads with no restart; re-run `octwin status` to confirm.
44
+ ```
45
+
46
+ ## Auth — the deploy token
47
+
48
+ You authenticate with a tenant-scoped **deploy token** (`cqp_…`), not a password
49
+ and not an operator token. It is least-privilege (scope `pack:deploy`) and
50
+ revocable in the console. Add the **`media:generate`** scope to let a `--seed`
51
+ deploy AI-generate seed images (a demo field `photo: 'generate:<prompt>'`).
52
+
53
+ ## Config resolution (deploy/status)
54
+
55
+ `flags` > `pack.json` (in the pack dir) > env (`PACK_PLATFORM_URL`, `PACK_TENANT`,
56
+ `PACK_PROJECT`, `PACK_TOKEN`) > saved login (`~/.octwin/credentials.json`).
57
+
58
+ ## What a pack may contain
59
+
60
+ Pure declarative data only — YAML + SQL (`.yaml`/`.yml`/`.md`/`.sql`/`.json`).
61
+ No `.ts`/`.js`, no `routes/`, no `db/client.*`, no `*.primitives.*`. This is what
62
+ makes an external pack safe to run on the shared platform; the server enforces it
63
+ on deploy. Persist domain records with the platform's first-class **XRM** /
64
+ **catalog** / **casework** modules (declared in `xrm.yaml` / `cases.yaml`) — no
65
+ pack database required.
package/dist/index.js ADDED
@@ -0,0 +1,342 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * octwin — the Octwin external-pack developer CLI (by CEQUENS).
4
+ *
5
+ * A developer scaffolds a pure-YAML pack in their own repo, validates it
6
+ * locally, and deploys it to a running Octwin platform to test on their own
7
+ * tenant. Standalone: no platform checkout, no build step for the developer —
8
+ * `npx octwin-cli <cmd>` (published as `octwin-cli`, command `octwin`).
9
+ *
10
+ * octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
11
+ * octwin validate [--dir .]
12
+ * octwin login --url <platformUrl> --token cqp_…
13
+ * octwin whoami [--url <url>] [--tenant <slug>]
14
+ * octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
15
+ * octwin status [--dir .] # did my deploy land? which version is live?
16
+ * octwin test [--dir .] # local validate + how to chat on your tenant
17
+ *
18
+ * Config resolution (deploy): flags > pack.json (in the pack dir) > env
19
+ * (PACK_PLATFORM_URL / PACK_TENANT / PACK_PROJECT / PACK_TOKEN) > saved login.
20
+ *
21
+ * The bundle uploaded is every file under the pack dir EXCEPT pack.json, dot
22
+ * files/dirs, node_modules, and build output. The platform re-validates it
23
+ * (pure-YAML enforcement + manifest/flow Zod) and installs it onto the project.
24
+ */
25
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, cpSync } from 'node:fs';
26
+ import { join, resolve, dirname } from 'node:path';
27
+ import { homedir } from 'node:os';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { parse as parseYaml } from 'yaml';
30
+ import { applyRenames } from './lib/rename.js';
31
+ import { validatePackBundle } from './lib/validate.js';
32
+ // The in-package starter template ships alongside `dist/` and `src/` (both one
33
+ // level under the package root), so `../templates/starter` resolves for the
34
+ // built CLI and `tsx` dev alike.
35
+ const TEMPLATE_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'templates', 'starter');
36
+ function parseFlags(argv) {
37
+ const f = { _: [] };
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const a = argv[i];
40
+ if (a.startsWith('--')) {
41
+ const key = a.slice(2);
42
+ const next = argv[i + 1];
43
+ if (next === undefined || next.startsWith('--'))
44
+ f[key] = true;
45
+ else {
46
+ f[key] = next;
47
+ i++;
48
+ }
49
+ }
50
+ else
51
+ f._.push(a);
52
+ }
53
+ return f;
54
+ }
55
+ function die(msg) {
56
+ console.error(`✗ ${msg}`);
57
+ process.exit(1);
58
+ }
59
+ // ── bundle collection ───────────────────────────────────────────────────────
60
+ const SKIP_DIRS = new Set(['.git', 'node_modules', '.pack-bundles', 'dist', '.mastra']);
61
+ /** Collect every text file under `packDir` into a `{ relPath: content }` map. */
62
+ function collectBundleFiles(packDir) {
63
+ const files = {};
64
+ const walk = (dir, prefix) => {
65
+ for (const name of readdirSync(dir)) {
66
+ const full = join(dir, name);
67
+ const rel = prefix ? `${prefix}/${name}` : name;
68
+ if (statSync(full).isDirectory()) {
69
+ if (SKIP_DIRS.has(name) || name.startsWith('.'))
70
+ continue;
71
+ walk(full, rel);
72
+ continue;
73
+ }
74
+ if (name === 'pack.json')
75
+ continue; // deploy config, not part of the pack
76
+ if (name.startsWith('.'))
77
+ continue; // .gitignore etc. — not pack content
78
+ files[rel] = readFileSync(full, 'utf8');
79
+ }
80
+ };
81
+ walk(packDir, '');
82
+ return files;
83
+ }
84
+ function readManifestIdVersion(files) {
85
+ const raw = files['manifest.yaml'];
86
+ if (!raw)
87
+ die('no manifest.yaml found in the pack directory');
88
+ const doc = parseYaml(raw);
89
+ if (typeof doc?.id !== 'string' || typeof doc?.version !== 'string') {
90
+ die('manifest.yaml must declare string `id` and `version`');
91
+ }
92
+ return { id: doc.id, version: doc.version };
93
+ }
94
+ function readPackConfig(packDir) {
95
+ const p = join(packDir, 'pack.json');
96
+ if (!existsSync(p))
97
+ return {};
98
+ try {
99
+ return JSON.parse(readFileSync(p, 'utf8'));
100
+ }
101
+ catch {
102
+ return {};
103
+ }
104
+ }
105
+ function credsPath() { return join(homedir(), '.octwin', 'credentials.json'); }
106
+ function readCreds() {
107
+ try {
108
+ return JSON.parse(readFileSync(credsPath(), 'utf8'));
109
+ }
110
+ catch {
111
+ return {};
112
+ }
113
+ }
114
+ function writeCreds(map) {
115
+ mkdirSync(join(homedir(), '.octwin'), { recursive: true });
116
+ writeFileSync(credsPath(), JSON.stringify(map, null, 2), 'utf8');
117
+ }
118
+ // ── commands ────────────────────────────────────────────────────────────────
119
+ function cmdInit(flags) {
120
+ const target = flags._[0] ?? die('usage: octwin init <dir> [--id my-pack]');
121
+ const dir = resolve(target);
122
+ const id = flags.id ?? target.replace(/[/\\]/g, '').replace(/[^a-z0-9-]/gi, '-').toLowerCase();
123
+ if (!/^[a-z][a-z0-9-]*$/.test(id))
124
+ die(`pack id '${id}' must be lowercase ASCII with hyphens — pass --id`);
125
+ if (existsSync(dir) && readdirSync(dir).length > 0)
126
+ die(`target '${dir}' is not empty`);
127
+ if (!existsSync(TEMPLATE_DIR))
128
+ die(`starter template missing at ${TEMPLATE_DIR} (broken install?)`);
129
+ // Copy the pure-YAML starter template, then rename it into a fresh pack.
130
+ mkdirSync(dir, { recursive: true });
131
+ cpSync(TEMPLATE_DIR, dir, { recursive: true });
132
+ applyRenames(dir, {
133
+ packId: id,
134
+ flowId: 'main',
135
+ agentId: 'assistant',
136
+ description: flags.description ?? undefined,
137
+ displayName: flags['display-name'] ?? undefined,
138
+ });
139
+ // Deploy config + repo hygiene + a README.
140
+ writeFileSync(join(dir, 'pack.json'), JSON.stringify({
141
+ platform_url: 'http://localhost:3000',
142
+ tenant: 'your-tenant-slug',
143
+ project: 'main',
144
+ }, null, 2) + '\n', 'utf8');
145
+ writeFileSync(join(dir, '.gitignore'), 'node_modules/\n.pack-bundles/\n', 'utf8');
146
+ if (!existsSync(join(dir, 'README.md'))) {
147
+ writeFileSync(join(dir, 'README.md'), `# ${id}\n\nA pure-YAML pack for the Octwin platform.\n\n- Edit \`manifest.yaml\`, \`flows/tools/main.flow.yaml\`, \`prompts/identity.md\`\n- \`octwin validate\` — check it locally\n- \`octwin deploy\` — deploy + install onto your tenant\n`, 'utf8');
148
+ }
149
+ console.log(`✓ Scaffolded pure-YAML pack '${id}' at ${dir}`);
150
+ console.log('\nNext:');
151
+ console.log(` cd ${target}`);
152
+ console.log(' git init && git add -A && git commit -m "init pack"');
153
+ console.log(' # edit manifest.yaml / flows / prompts, then:');
154
+ console.log(' octwin validate');
155
+ console.log(' # set platform_url + tenant + project in pack.json, then:');
156
+ console.log(' octwin login --url <platformUrl> --token <deploy-token>');
157
+ console.log(' octwin deploy');
158
+ }
159
+ function localValidate(packDir) {
160
+ const files = collectBundleFiles(packDir);
161
+ const { id, version } = readManifestIdVersion(files);
162
+ const r = validatePackBundle(id, files);
163
+ if (!r.ok) {
164
+ for (const e of r.errors)
165
+ console.error(` ✗ ${e}`);
166
+ die(`bundle validation failed (${r.errors.length} error${r.errors.length === 1 ? '' : 's'})`);
167
+ }
168
+ return { id, version, files };
169
+ }
170
+ function cmdValidate(flags) {
171
+ const packDir = resolve(flags.dir ?? '.');
172
+ const { id, version, files } = localValidate(packDir);
173
+ console.log(`✓ ${id}@${version} is a valid pure-YAML pack (${Object.keys(files).length} files)`);
174
+ console.log(' (the platform re-validates manifest + flows against the full schema on deploy)');
175
+ }
176
+ function cmdLogin(flags) {
177
+ const url = flags.url ?? process.env.PACK_PLATFORM_URL ?? die('usage: octwin login --url <platformUrl> --token <t>');
178
+ const token = flags.token ?? process.env.PACK_TOKEN ?? die('missing --token');
179
+ const creds = readCreds();
180
+ creds[url.replace(/\/$/, '')] = token;
181
+ writeCreds(creds);
182
+ console.log(`✓ Saved token for ${url}`);
183
+ }
184
+ /** Resolve platform url + tenant + project + token: flags > pack.json > env > saved login. */
185
+ function resolveTarget(flags, packDir) {
186
+ const cfg = readPackConfig(packDir);
187
+ const url = (flags.url ?? process.env.PACK_PLATFORM_URL ?? cfg.platform_url ?? '').replace(/\/$/, '');
188
+ const tenant = flags.tenant ?? process.env.PACK_TENANT ?? cfg.tenant ?? '';
189
+ const project = flags.project ?? process.env.PACK_PROJECT ?? cfg.project ?? 'main';
190
+ const token = flags.token ?? process.env.PACK_TOKEN ?? readCreds()[url] ?? '';
191
+ if (!url)
192
+ die('no platform url — set it in pack.json, --url, or PACK_PLATFORM_URL');
193
+ if (!tenant)
194
+ die('no tenant — set it in pack.json, --tenant, or PACK_TENANT');
195
+ if (!token)
196
+ die('no token — generate a deploy token in the console (Settings → API tokens), then `octwin login --url <url> --token cqp_…` or pass --token');
197
+ return { url, tenant, project, token };
198
+ }
199
+ async function cmdWhoami(flags) {
200
+ const packDir = resolve(flags.dir ?? '.');
201
+ const { url, tenant, token } = resolveTarget(flags, packDir);
202
+ const res = await fetch(`${url}/api/admin/tenants/${tenant}/packs`, { headers: { authorization: `Bearer ${token}` } });
203
+ if (res.ok) {
204
+ console.log(`✓ Token valid for tenant '${tenant}' at ${url} (${token.startsWith('cqp_') ? 'deploy token' : 'session token'})`);
205
+ return;
206
+ }
207
+ const why = res.status === 401 ? 'invalid / expired / revoked token'
208
+ : res.status === 403 ? 'token not authorized for this tenant'
209
+ : await res.text();
210
+ die(`token check failed for '${tenant}' (HTTP ${res.status}) — ${why}`);
211
+ }
212
+ async function cmdDeploy(flags) {
213
+ const packDir = resolve(flags.dir ?? '.');
214
+ const { url, tenant, project, token } = resolveTarget(flags, packDir);
215
+ const { id, version, files } = localValidate(packDir);
216
+ const endpoint = `${url}/api/admin/tenants/${tenant}/projects/${project}/packs/deploy`;
217
+ console.log(`→ Deploying ${id}@${version} (${Object.keys(files).length} files) to ${tenant}/${project} …`);
218
+ const res = await fetch(endpoint, {
219
+ method: 'POST',
220
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
221
+ body: JSON.stringify({ files, seed: flags.seed === true }),
222
+ });
223
+ const text = await res.text();
224
+ let json;
225
+ try {
226
+ json = JSON.parse(text);
227
+ }
228
+ catch {
229
+ json = text;
230
+ }
231
+ if (!res.ok) {
232
+ console.error(`✗ deploy failed (HTTP ${res.status})`);
233
+ console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
234
+ process.exit(1);
235
+ }
236
+ console.log(`✓ Deployed ${id}@${version} and installed onto ${tenant}/${project}`);
237
+ if (json?.warning)
238
+ console.log(` ⚠ ${json.warning}`);
239
+ console.log(`\nChat with it on your tenant (web widget / console test page for ${tenant}/${project}).`);
240
+ }
241
+ async function cmdStatus(flags) {
242
+ const packDir = resolve(flags.dir ?? '.');
243
+ const { url, tenant, project, token } = resolveTarget(flags, packDir);
244
+ const manifestPath = join(packDir, 'manifest.yaml');
245
+ if (!existsSync(manifestPath))
246
+ die('no manifest.yaml in the pack directory (run from your pack dir or pass --dir)');
247
+ const doc = parseYaml(readFileSync(manifestPath, 'utf8'));
248
+ if (typeof doc?.id !== 'string')
249
+ die('manifest.yaml must declare a string `id`');
250
+ const id = doc.id;
251
+ const localVersion = typeof doc?.version === 'string' ? doc.version : '?';
252
+ const res = await fetch(`${url}/api/admin/tenants/${tenant}/projects/${project}/packs/${id}/runtime`, {
253
+ headers: { authorization: `Bearer ${token}` },
254
+ });
255
+ const text = await res.text();
256
+ let json;
257
+ try {
258
+ json = JSON.parse(text);
259
+ }
260
+ catch {
261
+ json = text;
262
+ }
263
+ if (!res.ok) {
264
+ if (res.status === 404)
265
+ die(`'${id}' is not installed on ${tenant}/${project} yet — run \`octwin deploy\` first`);
266
+ console.error(`✗ status check failed (HTTP ${res.status})`);
267
+ console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
268
+ process.exit(1);
269
+ }
270
+ console.log(`${id} on ${tenant}/${project} @ ${url}`);
271
+ console.log(` installed version : ${json.installed_version}`);
272
+ console.log(` live on instance : registered=${json.registered} source=${json.source} loaded=${json.loaded_version ?? '(none)'}`);
273
+ console.log(` flows : ${(json.flows ?? []).join(', ') || '(none)'}`);
274
+ if (!json.registered) {
275
+ console.log('\n… not warm on the instance you hit yet — it loads on the next inbound (chat once, then re-check).');
276
+ }
277
+ else if (json.loaded_version && json.installed_version && json.loaded_version !== json.installed_version) {
278
+ console.log(`\n⚠ instance has ${json.loaded_version} but the project is bound to ${json.installed_version} — a redeploy lands on the next turn.`);
279
+ }
280
+ else {
281
+ console.log('\n✓ live and current.');
282
+ }
283
+ if (localVersion !== '?' && localVersion !== json.installed_version) {
284
+ console.log(` (local manifest is ${localVersion}; deployed is ${json.installed_version} — \`octwin deploy\` to push local edits.)`);
285
+ }
286
+ }
287
+ function cmdTest(flags) {
288
+ const packDir = resolve(flags.dir ?? '.');
289
+ const { id, version } = localValidate(packDir);
290
+ console.log(`✓ ${id}@${version} validates locally.`);
291
+ console.log(' Deploy it (`octwin deploy`), then chat on your tenant via the web widget /');
292
+ console.log(' console test page, and `octwin status` to confirm the live version.');
293
+ }
294
+ function help() {
295
+ console.log(`octwin — Octwin external-pack developer CLI (by CEQUENS)
296
+
297
+ octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
298
+ octwin validate [--dir .]
299
+ octwin login --url <platformUrl> --token cqp_… # a deploy token from the console
300
+ octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
301
+ octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
302
+ octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
303
+ octwin test [--dir .]
304
+
305
+ Get a deploy token: console → your workspace → Settings → API tokens → Generate.
306
+ Config (deploy): flags > pack.json > env (PACK_PLATFORM_URL/PACK_TENANT/PACK_PROJECT/PACK_TOKEN) > saved login.`);
307
+ }
308
+ async function main() {
309
+ const [command, ...rest] = process.argv.slice(2);
310
+ const flags = parseFlags(rest);
311
+ switch (command) {
312
+ case 'init':
313
+ cmdInit(flags);
314
+ break;
315
+ case 'validate':
316
+ cmdValidate(flags);
317
+ break;
318
+ case 'login':
319
+ cmdLogin(flags);
320
+ break;
321
+ case 'whoami':
322
+ await cmdWhoami(flags);
323
+ break;
324
+ case 'deploy':
325
+ await cmdDeploy(flags);
326
+ break;
327
+ case 'status':
328
+ await cmdStatus(flags);
329
+ break;
330
+ case 'test':
331
+ cmdTest(flags);
332
+ break;
333
+ case undefined:
334
+ case 'help':
335
+ case '--help':
336
+ case '-h':
337
+ help();
338
+ break;
339
+ default: die(`unknown command '${command}' — run \`octwin help\``);
340
+ }
341
+ }
342
+ main().catch((err) => die(err?.message ?? String(err)));
@@ -0,0 +1,107 @@
1
+ /**
2
+ * rename.ts — rename map + safe substitution over a copied starter tree.
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:
8
+ * • `starter-kit` → <pack-id> (manifest id, comments, README)
9
+ * • agent display name + id (when --display-name / --agent given)
10
+ * • manifest description (when --description given)
11
+ * • the `main` flow → <flow-id> (when --flow given): file renames + scoped edits
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.
16
+ */
17
+ import { readdirSync, statSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ const TEXT_EXT = /\.(ya?ml|md|sql|json)$/;
20
+ function walkFiles(dir) {
21
+ const out = [];
22
+ for (const name of readdirSync(dir)) {
23
+ const full = join(dir, name);
24
+ if (statSync(full).isDirectory())
25
+ out.push(...walkFiles(full));
26
+ else
27
+ out.push(full);
28
+ }
29
+ return out;
30
+ }
31
+ function escapeRegExp(s) {
32
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
33
+ }
34
+ /** Build the ordered list of [pattern, replacement] content edits. */
35
+ function contentReplacements(opts) {
36
+ const global = []; // applied to every text file
37
+ const manifestOnly = []; // applied to manifest.yaml only
38
+ const edits = global; // alias — most edits are global
39
+ // 1. Pack id — every literal `starter-kit` becomes the new id.
40
+ edits.push([new RegExp(escapeRegExp('starter-kit'), 'g'), opts.packId]);
41
+ // 2. Manifest description value — manifest.yaml ONLY (a flow's own
42
+ // `description:` line must not be overwritten with the pack description).
43
+ if (opts.description) {
44
+ const safe = opts.description.replace(/'/g, "''");
45
+ manifestOnly.push([/^description:.*$/m, `description: '${safe}'`]);
46
+ }
47
+ // 3. Agent display name (single-quoted scalar on `display_name:`).
48
+ if (opts.displayName) {
49
+ const safe = opts.displayName.replace(/'/g, "''");
50
+ edits.push([/^(\s*display_name:\s*).*$/m, `$1'${safe}'`]);
51
+ }
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
+ return { global, manifestOnly };
65
+ }
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
+ */
76
+ export function applyRenames(destDir, opts) {
77
+ const { global, manifestOnly } = contentReplacements(opts);
78
+ for (const file of walkFiles(destDir)) {
79
+ if (!TEXT_EXT.test(file))
80
+ continue;
81
+ const edits = basename(file) === 'manifest.yaml' ? [...global, ...manifestOnly] : global;
82
+ let text = readFileSync(file, 'utf8');
83
+ let changed = false;
84
+ for (const [pattern, replacement] of edits) {
85
+ const next = text.replace(pattern, replacement);
86
+ if (next !== text) {
87
+ text = next;
88
+ changed = true;
89
+ }
90
+ }
91
+ if (changed)
92
+ writeFileSync(file, text, 'utf8');
93
+ }
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
+ }
104
+ function basename(p) {
105
+ const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
106
+ return i < 0 ? p : p.slice(i + 1);
107
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Structural bundle validation — the pure-YAML/SQL gate, client side.
3
+ *
4
+ * A VENDORED, platform-free copy of the server's structural validator
5
+ * (`src/platform/provisioning/bundle-validate.ts`). It runs `octwin validate`
6
+ * offline so a developer catches the obvious rejections (executable code,
7
+ * routes, primitives, path traversal, non-declarative file types) before
8
+ * uploading. The **server re-validates authoritatively** on deploy — this local
9
+ * copy is a fast pre-check, not the source of truth — and it omits the server's
10
+ * in-repo shadow check (there is no `src/packs` in a developer's own repo).
11
+ */
12
+ /** Declarative file extensions a pure-YAML pack may contain. */
13
+ const ALLOWED_EXT = new Set(['yaml', 'yml', 'md', 'sql', 'json']);
14
+ /** Extensions/paths that mean executable code or a non-YAML capability — rejected. */
15
+ const CODE_EXT = new Set(['ts', 'js', 'mjs', 'cjs', 'jsx', 'tsx', 'node', 'wasm', 'sh', 'bash', 'exe', 'py', 'rb']);
16
+ /** Normalize to forward slashes + strip a leading `./`. */
17
+ function norm(p) {
18
+ return p.replace(/\\/g, '/').replace(/^\.\//, '');
19
+ }
20
+ function ext(p) {
21
+ const base = p.slice(p.lastIndexOf('/') + 1);
22
+ const dot = base.lastIndexOf('.');
23
+ return dot >= 0 ? base.slice(dot + 1).toLowerCase() : '';
24
+ }
25
+ /**
26
+ * Validate an uploaded bundle file-map (`{ "<relPath>": "<body>" }`).
27
+ * Enforces: a `manifest.yaml` at root, no executable code / capability files,
28
+ * no path traversal or absolute paths, declarative extensions only. Returns all
29
+ * violations at once.
30
+ */
31
+ export function validatePackBundle(packId, files) {
32
+ const errors = [];
33
+ if (!/^[a-z][a-z0-9-]*$/.test(packId)) {
34
+ errors.push(`pack id '${packId}' must be lowercase ASCII with hyphens (e.g. 'my-pack')`);
35
+ }
36
+ const paths = Object.keys(files);
37
+ if (paths.length === 0)
38
+ errors.push('bundle is empty');
39
+ if (!paths.some(p => norm(p) === 'manifest.yaml')) {
40
+ errors.push('bundle is missing manifest.yaml at its root');
41
+ }
42
+ for (const raw of paths) {
43
+ const p = norm(raw);
44
+ if (p.startsWith('/') || /^[a-zA-Z]:/.test(p) || p.split('/').includes('..')) {
45
+ errors.push(`unsafe path '${raw}' (absolute or traversal)`);
46
+ continue;
47
+ }
48
+ if (p.startsWith('routes/')) {
49
+ errors.push(`'${p}': pack HTTP routes are not allowed (pure-YAML packs only)`);
50
+ continue;
51
+ }
52
+ if (/^db\/client\./.test(p)) {
53
+ errors.push(`'${p}': a pack DB client is not allowed (pure-YAML packs only)`);
54
+ continue;
55
+ }
56
+ if (p.includes('.primitives.')) {
57
+ errors.push(`'${p}': pack primitives are not allowed (pure-YAML packs only)`);
58
+ continue;
59
+ }
60
+ const e = ext(p);
61
+ if (CODE_EXT.has(e)) {
62
+ errors.push(`'${p}': executable code is not allowed (pure-YAML packs only)`);
63
+ continue;
64
+ }
65
+ if (!ALLOWED_EXT.has(e)) {
66
+ errors.push(`'${p}': not an allowed pack file type (.yaml/.yml/.md/.sql only)`);
67
+ continue;
68
+ }
69
+ }
70
+ return { ok: errors.length === 0, errors };
71
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "octwin-cli",
3
+ "version": "0.1.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
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "prepublishOnly": "npm run build"
21
+ },
22
+ "dependencies": {
23
+ "yaml": "^2.6.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^22.0.0",
27
+ "typescript": "^5.7.0"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "keywords": ["octwin", "cequens", "cli", "whatsapp", "chatbot", "pack"],
33
+ "license": "MIT"
34
+ }
@@ -0,0 +1,14 @@
1
+ # Pack-level slash-command declarations (OPTIONAL but recommended).
2
+ #
3
+ # The platform's default `systemCommand` matches inbound text against these
4
+ # BEFORE normal agent dispatch. Without a `commands.yaml`, a pack has NO slash
5
+ # commands — e.g. `/clear` silently does nothing (it just goes to the agent as
6
+ # text). Ship this file if you want `/clear` (and friends) to work like the
7
+ # other packs.
8
+ #
9
+ # action: `close_conversation` (reset the conversation) | `noop` (reply only).
10
+
11
+ - match: '/clear'
12
+ action: close_conversation
13
+ reply_ar: 'تم مسح المحادثة ✅ تقدر تبدأ من جديد.'
14
+ source: '/clear'
@@ -0,0 +1,39 @@
1
+ # main — the starter greeting flow.
2
+ #
3
+ # The canonical pack-author shape, in miniature — PURE YAML, no TypeScript:
4
+ # • input: the tool's schema (drives the LLM tool catalogue + validation)
5
+ # • entry: routing rules (here: always go to `greet`)
6
+ # • steps: compute the card body with an `assign` expr → render → end
7
+ # • all user-visible copy lives in main.locale.ar.yaml (never inline here)
8
+ #
9
+ # Conventions worth copying:
10
+ # • Build data with expr builtins (`$trim`/`$coalesce`/`$t`) — reach for a
11
+ # pack TS primitive only for logic the expr grammar genuinely can't express.
12
+ # • `{$body}` interpolates the assigned value into the card.
13
+ # • `memory_note` leaves the agent a breadcrumb of what the user saw.
14
+
15
+ flow_id: main
16
+ version: 1
17
+ description: Greet the user. Call for any message while building out this pack.
18
+
19
+ input:
20
+ message:
21
+ type: string
22
+ optional: true
23
+ describe: 'Optional note from the user to echo back in the greeting.'
24
+
25
+ entry:
26
+ - else: true
27
+ goto: greet
28
+
29
+ steps:
30
+ greet:
31
+ # Echo the user's note when present, else the default greeting — a ternary
32
+ # over `$trim` + `$t(key, { … })`. (Was a `build_greeting` primitive.)
33
+ - assign:
34
+ body: '$trim($coalesce($input.message, "")) != "" ? $t("main.body_with_note", { note: $trim($input.message) }) : $t("main.body_default")'
35
+ - render:
36
+ render_intent: text_card
37
+ body: '{$body}'
38
+ memory_note: '{$t("main.memory_note")}'
39
+ - end: applied
@@ -0,0 +1,16 @@
1
+ # main — Arabic copy.
2
+ #
3
+ # Every user-facing string for the `main` flow lives here, keyed by a stable
4
+ # name. The flow YAML references each via `$t("main.<key>", { … })`.
5
+ # Placeholder syntax: `{var}` — substituted from the second arg of `$t()`.
6
+ # Adding a new locale = drop a sibling `main.locale.<lang>.yaml` with the same
7
+ # keys translated.
8
+
9
+ flow_id: main
10
+
11
+ strings:
12
+ body_default: 'أهلاً بك 👋 أنا بوت البداية. اكتب أي رسالة لنبدأ.'
13
+ body_with_note: 'أهلاً بك 👋 قلت: "{note}". أنا بوت البداية — جاهز نبدأ.'
14
+
15
+ # Agent breadcrumb (kept in English like other packs' memory notes).
16
+ memory_note: 'Showed starter greeting card'
@@ -0,0 +1,13 @@
1
+ # starter-kit — pack-wide Arabic strings.
2
+ #
3
+ # Entries here are shared across ALL flows in this pack (cross-flow labels,
4
+ # common error messages, status verbs). Flow-specific strings live in
5
+ # `flows/tools/<flow>.locale.ar.yaml`. Reference these via `$t("starter-kit.<key>")`
6
+ # — every key is namespaced (pack-level by `pack_id`; flow-level by `flow_id`).
7
+ # A bare `$t("<key>")` does NOT resolve (returns the raw key); the flow linter's
8
+ # `locale-key-not-namespaced` rule fails the build on a bare key.
9
+
10
+ pack_id: starter-kit
11
+ strings:
12
+ # Example pack-level string — reference as `$t("starter-kit.greeting")`.
13
+ greeting: 'أهلاً! إزاي أقدر أساعدك؟'
@@ -0,0 +1,40 @@
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
21
+ # `flows/tools/<id>.flow.yaml` + `<id>.primitives.ts`.
22
+ flows:
23
+ - main
24
+
25
+ # One agent. The platform's universal agent protocol is auto-appended by
26
+ # `createPackAgents` — `instructions:` carries only pack-supplied parts.
27
+ agents:
28
+ - id: assistant
29
+ display_name: 'Starter Assistant'
30
+ # `openrouter/` prefix routes via OpenRouter. Operators override the
31
+ # model per-project from the console.
32
+ default_model: 'openrouter/google/gemini-3.1-flash-lite-preview'
33
+ # Flow tools this agent can call (subset of `flows:` above).
34
+ tools:
35
+ - main
36
+ include_platform_protocol: true
37
+ # Instruction parts, joined with '\n\n'. `file:` paths are pack-root
38
+ # relative; `.md` files may use built-in placeholders like {{pack.id}}.
39
+ instructions:
40
+ - file: prompts/identity.md
@@ -0,0 +1,26 @@
1
+ # starter-kit — platform-emitted envelope copy (Arabic).
2
+ #
3
+ # Two blocks, both auto-loaded by definePack (lang from default_settings.locale):
4
+ # • errors: copy the PLATFORM emits on failure paths (tool throw, workflow
5
+ # plumbing). Optional, but worth having so users never sit in
6
+ # silence. Schema: src/platform/manifest/messages.ts.
7
+ # • agents: per-agent loop copy, keyed by the manifest agent id. OPTIONAL —
8
+ # the platform ships default copy; resolveMessages merges this block
9
+ # (whole or partial) over it at turn start. Shown here as an example.
10
+
11
+ errors:
12
+ failed:
13
+ failure: { body: 'عذراً، حدث خطأ غير متوقع. حاول مرة أخرى.' }
14
+ unhandled_tool_error:
15
+ failure: { body: 'عذراً، حدث خطأ غير متوقع. حاول مرة أخرى بعد لحظة. 🙏' }
16
+
17
+ # Per-agent envelope copy — keyed by manifest agent id (`assistant`).
18
+ agents:
19
+ assistant:
20
+ agentDisabled: 'الخدمة الذكية غير متاحة مؤقتاً. حاول مرة أخرى لاحقاً. 🙏'
21
+ transientError: 'عذراً، حدث خطأ مؤقت. حاول مرة أخرى بعد لحظة. 🙏'
22
+ workflowExpired: 'عذراً، انتهت الجلسة. ابدأ من جديد.'
23
+ workflowError: 'عذراً، حدث خطأ أثناء استكمال العملية. حاول مرة أخرى.'
24
+ workflowDisabled: 'هذه الميزة غير مفعّلة في الوقت الحالي.'
25
+ toolDisabled: 'هذه الميزة غير مفعّلة في الوقت الحالي.'
26
+ directTapError: 'عذراً، حدث خطأ. حاول مرة أخرى.'
@@ -0,0 +1,5 @@
1
+ You are the Starter Assistant — a tiny example bot that ships with the platform as a reference pack.
2
+
3
+ For any user message, call the `main` tool to greet them. Pass the user's message as the `message` argument when they say something specific you can echo back; otherwise call `main` with no arguments.
4
+
5
+ Keep replies short and friendly. This pack exists to be copied and customized — once you scaffold your own pack, replace this prompt and the `main` flow with your real domain.