octwin-cli 0.1.12 → 0.1.14

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/index.js +383 -107
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,51 @@ Format: [Keep a Changelog](https://keepachangelog.com/) — newest first, bucket
5
5
  **Added · Changed · Deprecated · Removed · Fixed · Security**. The platform-wide view lives in the
6
6
  repo root [`CHANGELOG.md`](../../CHANGELOG.md); this file is the CLI-only cut that ships with the package.
7
7
 
8
+ ## [0.1.14] - 2026-07-23
9
+
10
+ ### Fixed
11
+ - **A failed demo seed no longer reports a clean `✓`.** `deploy` softens non-fatal
12
+ install steps (e.g. a demo-seed row) to warning frames, but the summary still printed
13
+ `✓ Deployed` and exited 0 — so a deploy that seeded ZERO records read as success (the
14
+ false-✓ trap). `deploy` now collects those step errors, prints `⚠ Deployed with N
15
+ warning(s) — data may be incomplete` with each message, and exits non-zero so CI /
16
+ a `deploy && chat` chain catches it.
17
+
18
+ ### Added
19
+ - **`login` echoes what the token reaches.** After saving, `octwin login` calls
20
+ `/api/self/t/whoami` and prints the workspace, project pin, and scopes — so a fresh
21
+ token self-identifies without a second `octwin whoami` (best-effort; a network failure
22
+ never fails the save).
23
+ - **`validate --remote` surfaces warnings + the data-store trap.** The remote validate now
24
+ renders advisory `⚠` warnings (which don't block deploy) and, for a pack declaring
25
+ `required_adapters: [data-store]`, reports the missing-data-store error the deploy would
26
+ have 409'd on — plus a warning that an `xrm.yaml` pack usually shouldn't declare that
27
+ adapter at all. It also now catches (offline) a list-form `entities:` block and a
28
+ `localized: true` field seeded with a bare string — traps that previously surfaced only
29
+ mid-seed at deploy.
30
+
31
+ ## [0.1.13] - 2026-07-22
32
+
33
+ ### Added
34
+ - **A headless media loop — produce + send.** Two paired additions close the coverage hole where any
35
+ media-collect flow (e.g. `activate-app`'s registration / ID uploads) stalled at the upload prompt
36
+ because the CLI could neither *make* nor *send* an image:
37
+ - `octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]` — AI-generates an
38
+ image on the platform (needs a `media:generate`-scoped token), stores it as a public asset, and prints
39
+ its `MEDIA-` handle + serve URL. `--out` downloads the bytes to a file (WhatsApp renders only
40
+ `.png`/`.jpg`); `--json` emits `{ media_id, url, mime, width, height, bytes }`.
41
+ - `octwin chat --media <file|media-id>` — uploads a local file (or a media id from
42
+ `media generate --json`) as an image/document/audio inbound; any accompanying `"message"` rides as
43
+ its caption. The platform's media pipeline folds the upload into a running collect, so media flows
44
+ are now fully drivable headlessly.
45
+
46
+ ### Changed
47
+ - **The KB-drift nudge now names what changed.** With the platform serving per-entry content hashes, the
48
+ post-command nudge appends a `(N changed · M added · K removed)` summary instead of a bare hash pair.
49
+ - **`octwin platform-kb` prints a changelog on pull.** Instead of only a doc/catalog count, the pull now
50
+ diffs the fresh index against your last pull and lists exactly which docs/catalogs were added (`+`),
51
+ changed (`~`), or removed (`-`) — so a replaced schema shape is visible, not silent.
52
+
8
53
  ## [0.1.12] - 2026-07-22
9
54
 
10
55
  ### Fixed
package/dist/index.js CHANGED
@@ -17,7 +17,8 @@
17
17
  * octwin records [entity] [id] # inspect the pack's XRM data (records:read token)
18
18
  * octwin cases [caseId] [--queues] # inspect casework (support tickets) — list / one case + timeline
19
19
  * octwin logs [conversationId] [--as h] [--json] # list conversations / show one's timeline
20
- * octwin chat "msg" [--as h] [--tap <tap-id>] [--json] # drive a turn via the web channel
20
+ * octwin chat "msg" [--as h] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn via the web channel (+ send media)
21
+ * octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (media:generate scope)
21
22
  * octwin platform-kb [pull] [--dir .] # pull the platform capability reference for the authoring skill
22
23
  * octwin test [--dir .] # = validate --remote (the full platform check)
23
24
  *
@@ -35,7 +36,7 @@
35
36
  * (pure-YAML enforcement + manifest/flow Zod) and installs it onto the project.
36
37
  */
37
38
  import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, cpSync } from 'node:fs';
38
- import { join, resolve, dirname } from 'node:path';
39
+ import { join, resolve, dirname, basename } from 'node:path';
39
40
  import { homedir } from 'node:os';
40
41
  import { fileURLToPath } from 'node:url';
41
42
  import { parse as parseYaml } from 'yaml';
@@ -112,6 +113,50 @@ function errDetail(json) {
112
113
  const msg = typeof json === 'string' ? json : (json.error ?? json.message ?? JSON.stringify(json));
113
114
  return msg ? ` — ${msg}` : '';
114
115
  }
116
+ // ── media helpers (produce/send loop) ───────────────────────────────────────
117
+ /** Extension → MIME for local files the `chat --media` sender uploads (mirrors
118
+ * the platform's accepted image/document/audio MIME sets). */
119
+ const MEDIA_MIME_BY_EXT = {
120
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp',
121
+ '.pdf': 'application/pdf', '.txt': 'text/plain',
122
+ '.doc': 'application/msword',
123
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
124
+ '.ogg': 'audio/ogg', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.wav': 'audio/wav', '.webm': 'audio/webm', '.aac': 'audio/aac',
125
+ };
126
+ /** MIME → file extension (for naming a fetched-by-id media part). */
127
+ const EXT_BY_MEDIA_MIME = {
128
+ 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'application/pdf': 'pdf',
129
+ };
130
+ /** The inbound `type` the platform expects for a MIME (image/document/audio). */
131
+ function mediaKindOf(mime) {
132
+ if (mime.startsWith('image/'))
133
+ return 'image';
134
+ if (mime.startsWith('audio/'))
135
+ return 'audio';
136
+ return 'document';
137
+ }
138
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
139
+ /** Resolve a `--media` argument to an uploadable part: a local file path, OR a
140
+ * media UUID (as returned by `octwin media generate --json`) fetched from the
141
+ * public serve route. The cosmetic `MEDIA-…` handle is NOT fetchable (the serve
142
+ * route is UUID-keyed) — use the file written by `--out`, or the `media_id`. */
143
+ async function resolveMediaPart(url, arg) {
144
+ if (existsSync(arg)) {
145
+ const buffer = readFileSync(arg);
146
+ const ext = arg.slice(arg.lastIndexOf('.')).toLowerCase();
147
+ const mime = MEDIA_MIME_BY_EXT[ext] ?? 'application/octet-stream';
148
+ return { blob: new Blob([buffer], { type: mime }), kind: mediaKindOf(mime), filename: basename(arg) };
149
+ }
150
+ if (UUID_RE.test(arg)) {
151
+ const res = await fetchOrDie(`${url}/api/media/${arg}`, undefined, 'fetch media');
152
+ if (!res.ok)
153
+ die(`--media '${arg}' not found (HTTP ${res.status}) — pass a local file path, or a media id from 'octwin media generate --json'`);
154
+ const mime = res.headers.get('content-type') ?? 'application/octet-stream';
155
+ const buffer = Buffer.from(await res.arrayBuffer());
156
+ return { blob: new Blob([buffer], { type: mime }), kind: mediaKindOf(mime), filename: `${arg}.${EXT_BY_MEDIA_MIME[mime] ?? 'bin'}` };
157
+ }
158
+ die(`--media '${arg}' is neither an existing file nor a media id (UUID). The cosmetic MEDIA-… handle isn't fetchable — send the file written by 'octwin media generate --out', or pass the media_id from '--json'.`);
159
+ }
115
160
  // ── bundle collection ───────────────────────────────────────────────────────
116
161
  const SKIP_DIRS = new Set(['.git', 'node_modules', '.pack-bundles', 'dist', '.mastra']);
117
162
  /** Collect every text file under `packDir` into a `{ relPath: content }` map. */
@@ -243,18 +288,34 @@ async function notifyIfOutdated() {
243
288
  }
244
289
  catch { /* a version check must never break the CLI */ }
245
290
  }
246
- // ── platform-KB drift check (observe the pulled reference, TTY-only, fail-silent) ──
247
- /** The `content_hash` of a previously-pulled KB in `<packDir>/.octwin/platform-kb/`,
248
- * or null if nothing has been pulled yet. */
249
- function readLocalKbHash(packDir) {
291
+ /** A previously-pulled KB's identity in `<packDir>/.octwin/platform-kb/index.json`
292
+ * (content hash + per-entry index), or null if nothing has been pulled yet. */
293
+ function readLocalKb(packDir) {
250
294
  try {
251
295
  const idx = JSON.parse(readFileSync(join(packDir, '.octwin', 'platform-kb', 'index.json'), 'utf8'));
252
- return typeof idx.content_hash === 'string' ? idx.content_hash : null;
296
+ return {
297
+ content_hash: typeof idx.content_hash === 'string' ? idx.content_hash : null,
298
+ index: Array.isArray(idx.index) ? idx.index : [],
299
+ };
253
300
  }
254
301
  catch {
255
302
  return null;
256
303
  }
257
304
  }
305
+ /** Diff two KB indexes by key + per-entry `hash` → added / removed / changed keys. */
306
+ function diffKbIndex(prev, next) {
307
+ const prevMap = new Map(prev.map(e => [e.key, e.hash]));
308
+ const nextKeys = new Set(next.map(e => e.key));
309
+ const added = [], changed = [];
310
+ for (const e of next) {
311
+ if (!prevMap.has(e.key))
312
+ added.push(e.key);
313
+ else if (prevMap.get(e.key) !== e.hash)
314
+ changed.push(e.key);
315
+ }
316
+ const removed = prev.filter(e => !nextKeys.has(e.key)).map(e => e.key);
317
+ return { added, removed, changed };
318
+ }
258
319
  /** Nudge (to stderr) when the platform's capability KB has changed since the last
259
320
  * `octwin platform-kb pull`. The sibling of `notifyIfOutdated`, for the KB instead
260
321
  * of the CLI: run only after commands that already hit the platform, so this adds
@@ -266,23 +327,36 @@ async function notifyIfKbStale(flags) {
266
327
  return;
267
328
  try {
268
329
  const packDir = resolve(flags.dir ?? '.');
269
- const localHash = readLocalKbHash(packDir);
270
- if (!localHash)
330
+ const local = readLocalKb(packDir);
331
+ if (!local?.content_hash)
271
332
  return; // never pulled → the skill already says to pull
272
333
  const t = resolveTargetOrNull(flags, packDir);
273
334
  if (!t)
274
335
  return;
275
336
  const ctrl = new AbortController();
276
337
  const timer = setTimeout(() => ctrl.abort(), 2_000);
277
- const res = await fetch(`${t.url}/api/admin/tenants/${t.tenant}/octwin-platform-kb?meta=1`, {
278
- headers: { authorization: `Bearer ${t.token}` }, signal: ctrl.signal,
338
+ const res = await fetch(`${t.url}/api/self/t/octwin-platform-kb?meta=1`, {
339
+ headers: authHeaders(t), signal: ctrl.signal,
279
340
  });
280
341
  clearTimeout(timer);
281
342
  if (!res.ok)
282
343
  return;
283
344
  const meta = await res.json();
284
- if (meta.content_hash && meta.content_hash !== localHash) {
285
- console.error(`\n⬆ the platform capability reference changed since you last pulled it (${localHash} → ${meta.content_hash}).`);
345
+ if (meta.content_hash && meta.content_hash !== local.content_hash) {
346
+ // Per-entry summary (now that the index carries per-entry hashes) the
347
+ // exact list of what changed is one `octwin platform-kb` away.
348
+ let summary = '';
349
+ if (Array.isArray(meta.index) && local.index.length) {
350
+ const { added, removed, changed } = diffKbIndex(local.index, meta.index);
351
+ const parts = [
352
+ changed.length && `${changed.length} changed`,
353
+ added.length && `${added.length} added`,
354
+ removed.length && `${removed.length} removed`,
355
+ ].filter(Boolean);
356
+ if (parts.length)
357
+ summary = ` (${parts.join(' · ')})`;
358
+ }
359
+ console.error(`\n⬆ the platform capability reference changed since you last pulled it${summary}.`);
286
360
  console.error(' Refresh it: octwin platform-kb');
287
361
  }
288
362
  }
@@ -298,6 +372,7 @@ function commandTouchesPlatform(command, flags) {
298
372
  case 'status':
299
373
  case 'test':
300
374
  case 'chat':
375
+ case 'media':
301
376
  case 'records':
302
377
  case 'cases':
303
378
  case 'logs':
@@ -327,10 +402,11 @@ function cmdInit(flags) {
327
402
  displayName: flags['display-name'] ?? undefined,
328
403
  });
329
404
  // Deploy config + repo hygiene + a README.
405
+ // The token carries its own tenant (and optional project pin), so pack.json
406
+ // needs only the platform URL. `tenant`/`project` may be added as optional
407
+ // overrides (they also seed `octwin chat`, which is tenant/project-pathed).
330
408
  writeFileSync(join(dir, 'pack.json'), JSON.stringify({
331
409
  platform_url: 'http://localhost:3000',
332
- tenant: 'your-tenant-slug',
333
- project: 'main',
334
410
  }, null, 2) + '\n', 'utf8');
335
411
  writeFileSync(join(dir, '.gitignore'), 'node_modules/\n.pack-bundles/\n.octwin/\n', 'utf8');
336
412
  if (!existsSync(join(dir, 'README.md'))) {
@@ -342,7 +418,7 @@ function cmdInit(flags) {
342
418
  console.log(' git init && git add -A && git commit -m "init pack"');
343
419
  console.log(' # edit manifest.yaml / flows / prompts, then:');
344
420
  console.log(' octwin validate');
345
- console.log(' # set platform_url + tenant + project in pack.json, then:');
421
+ console.log(' # set platform_url in pack.json (tenant comes from your token), then:');
346
422
  console.log(' octwin login --url <platformUrl> --token <deploy-token>');
347
423
  console.log(' octwin deploy');
348
424
  }
@@ -368,11 +444,12 @@ async function cmdValidate(flags) {
368
444
  }
369
445
  // Remote: the SAME validation the deploy route runs — manifest `.strict()` +
370
446
  // every flow (schema/expression/structure) — returning ALL errors at once.
371
- const { url, tenant, project, token } = resolveTarget(flags, packDir);
372
- console.log(`→ Validating against ${tenant}/${project} @ ${url} …`);
373
- const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/projects/${project}/packs/validate`, {
447
+ const t = resolveTarget(flags, packDir);
448
+ const { url } = t;
449
+ console.log(`→ Validating against ${targetLabel(t)} @ ${url} …`);
450
+ const res = await fetchOrDie(`${url}/api/self/p/packs/validate`, {
374
451
  method: 'POST',
375
- headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
452
+ headers: { 'content-type': 'application/json', ...authHeaders(t) },
376
453
  body: JSON.stringify({ files }),
377
454
  }, 'remote validate');
378
455
  const text = await res.text();
@@ -392,6 +469,12 @@ async function cmdValidate(flags) {
392
469
  console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
393
470
  process.exit(1);
394
471
  }
472
+ const warnings = (json?.warnings ?? []);
473
+ if (warnings.length) {
474
+ console.log(` ⚠ ${warnings.length} warning${warnings.length === 1 ? '' : 's'} (won't block deploy):`);
475
+ for (const w of warnings)
476
+ console.log(` ⚠ [${w.where}] ${w.message}`);
477
+ }
395
478
  if (json?.ok) {
396
479
  console.log(`✓ ${id}@${version} passes the platform's FULL validation — deploy won't reject on schema.`);
397
480
  return;
@@ -411,55 +494,83 @@ async function cmdValidate(flags) {
411
494
  }
412
495
  process.exit(1);
413
496
  }
414
- function cmdLogin(flags) {
415
- const url = flags.url ?? process.env.PACK_PLATFORM_URL ?? die('usage: octwin login --url <platformUrl> --token <t>');
497
+ async function cmdLogin(flags) {
498
+ const rawUrl = flags.url ?? process.env.PACK_PLATFORM_URL ?? die('usage: octwin login --url <platformUrl> --token <t>');
499
+ const url = rawUrl.replace(/\/$/, '');
416
500
  const token = flags.token ?? process.env.PACK_TOKEN ?? die('missing --token');
417
501
  const creds = readCreds();
418
- creds[url.replace(/\/$/, '')] = token;
502
+ creds[url] = token;
419
503
  writeCreds(creds);
420
504
  console.log(`✓ Saved token for ${url}`);
505
+ // Best-effort: echo what the token reaches (workspace + project pin + scopes)
506
+ // so a fresh token self-identifies without a second `octwin whoami`. A network
507
+ // failure never fails the save — the token is stored regardless.
508
+ try {
509
+ const res = await fetch(`${url}/api/self/t/whoami`, { headers: { authorization: `Bearer ${token}` } });
510
+ if (res.ok) {
511
+ const j = await res.json();
512
+ const scopes = Array.isArray(j.scopes) && j.scopes.length ? ` · scopes: ${j.scopes.join(', ')}` : '';
513
+ console.log(` → workspace '${j.tenant_slug}'${j.project_slug ? `, pinned to project '${j.project_slug}'` : ''}${scopes}`);
514
+ }
515
+ else if (res.status === 401 || res.status === 403) {
516
+ console.log(` ⚠ token saved, but the platform rejected it (HTTP ${res.status}) — check it's a current oct_… token`);
517
+ }
518
+ }
519
+ catch { /* platform unreachable — the token is saved regardless */ }
520
+ }
521
+ /** Bearer auth + the optional self-surface overrides, as request headers. */
522
+ function authHeaders(t) {
523
+ const h = { authorization: `Bearer ${t.token}` };
524
+ if (t.tenant)
525
+ h['x-octwin-tenant'] = t.tenant;
526
+ if (t.project)
527
+ h['x-octwin-project'] = t.project;
528
+ return h;
421
529
  }
422
- /** Resolve platform url + tenant + project + token: flags > pack.json > env > saved login. */
530
+ /** Resolve platform url + token (+ optional tenant/project overrides):
531
+ * flags > pack.json > env > saved login. Tenant is derived from the token
532
+ * server-side, so only url + token are required. */
423
533
  function resolveTarget(flags, packDir) {
424
534
  const cfg = readPackConfig(packDir);
425
535
  const url = (flags.url ?? process.env.PACK_PLATFORM_URL ?? cfg.platform_url ?? '').replace(/\/$/, '');
426
- const tenant = flags.tenant ?? process.env.PACK_TENANT ?? cfg.tenant ?? '';
427
- const project = flags.project ?? process.env.PACK_PROJECT ?? cfg.project ?? 'main';
536
+ const tenant = flags.tenant || process.env.PACK_TENANT || cfg.tenant || undefined;
537
+ const project = flags.project || process.env.PACK_PROJECT || cfg.project || undefined;
428
538
  const token = flags.token ?? process.env.PACK_TOKEN ?? readCreds()[url] ?? '';
429
539
  if (!url)
430
540
  die('no platform url — set it in pack.json, --url, or PACK_PLATFORM_URL');
431
- if (!tenant)
432
- die('no tenant — set it in pack.json, --tenant, or PACK_TENANT');
433
541
  if (!token)
434
- die('no token — generate a deploy token in the console (Settings → API tokens), then `octwin login --url <url> --token oct_…` or pass --token');
435
- return { url, tenant, project, token };
542
+ die('no token — generate an API token in the console (Settings → API tokens), then `octwin login --url <url> --token oct_…` or pass --token');
543
+ return { url, token, tenant, project };
436
544
  }
437
- /** Non-fatal `resolveTarget`: returns null (never dies) when any of url/tenant/token
438
- * is missing. Used by the fail-silent KB-staleness observer, which must never
545
+ /** Non-fatal `resolveTarget`: returns null (never dies) when url or token is
546
+ * missing. Used by the fail-silent KB-staleness observer, which must never
439
547
  * interrupt a command over a config gap. */
440
548
  function resolveTargetOrNull(flags, packDir) {
441
549
  const cfg = readPackConfig(packDir);
442
550
  const url = (flags.url ?? process.env.PACK_PLATFORM_URL ?? cfg.platform_url ?? '').replace(/\/$/, '');
443
- const tenant = flags.tenant ?? process.env.PACK_TENANT ?? cfg.tenant ?? '';
444
- const project = flags.project ?? process.env.PACK_PROJECT ?? cfg.project ?? 'main';
551
+ const tenant = flags.tenant || process.env.PACK_TENANT || cfg.tenant || undefined;
552
+ const project = flags.project || process.env.PACK_PROJECT || cfg.project || undefined;
445
553
  const token = flags.token ?? process.env.PACK_TOKEN ?? readCreds()[url] ?? '';
446
- if (!url || !tenant || !token)
554
+ if (!url || !token)
447
555
  return null;
448
- return { url, tenant, project, token };
556
+ return { url, token, tenant, project };
449
557
  }
450
558
  async function cmdWhoami(flags) {
451
559
  const packDir = resolve(flags.dir ?? '.');
452
- const { url, tenant, token } = resolveTarget(flags, packDir);
453
- console.log(`→ Checking the saved token against '${tenant}' @ ${url} …`);
454
- const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/packs`, { headers: { authorization: `Bearer ${token}` } }, 'token check');
560
+ const t = resolveTarget(flags, packDir);
561
+ console.log(`→ Checking the token against ${t.url} …`);
562
+ const res = await fetchOrDie(`${t.url}/api/self/t/whoami`, { headers: authHeaders(t) }, 'token check');
455
563
  if (res.ok) {
456
- console.log(`✓ Token valid for tenant '${tenant}' at ${url} (${token.startsWith('oct_') ? 'deploy token' : 'session token'})`);
564
+ const j = await res.json();
565
+ console.log(`✓ Token valid — workspace '${j.tenant_slug}'${j.project_slug ? `, pinned to project '${j.project_slug}'` : ''} (${j.kind === 'api_token' ? 'API token' : 'session'})`);
566
+ if (Array.isArray(j.scopes))
567
+ console.log(` scopes: ${j.scopes.length ? j.scopes.join(', ') : '(none)'}`);
457
568
  return;
458
569
  }
459
570
  const why = res.status === 401 ? 'invalid / expired / revoked token'
460
- : res.status === 403 ? 'token not authorized for this tenant'
571
+ : res.status === 403 ? 'token not authorized for this workspace'
461
572
  : await res.text();
462
- die(`token check failed for '${tenant}' (HTTP ${res.status}) — ${why}`);
573
+ die(`token check failed (HTTP ${res.status}) — ${why}`);
463
574
  }
464
575
  /**
465
576
  * Read the deploy SSE stream, printing each progress frame's message live, and
@@ -472,6 +583,11 @@ async function readDeployProgress(body) {
472
583
  const decoder = new TextDecoder();
473
584
  let buf = '';
474
585
  let terminal = null;
586
+ // Non-terminal frames with `status:'error'` are step failures the install
587
+ // SOFTENS to non-fatal (e.g. a demo-seed row) — the reconcile keeps going and
588
+ // still emits a `done`. We collect them so the deploy is NOT reported as a
589
+ // clean ✓ when a step actually failed (the false-✓ trap).
590
+ const stepErrors = [];
475
591
  for (;;) {
476
592
  const { done, value } = await reader.read();
477
593
  if (done)
@@ -495,14 +611,18 @@ async function readDeployProgress(body) {
495
611
  terminal = ev;
496
612
  continue;
497
613
  }
498
- if (ev.message)
499
- console.log(` ${ev.status === 'error' ? '⚠' : '·'} ${ev.message}`);
614
+ if (ev.message) {
615
+ const isErr = ev.status === 'error';
616
+ if (isErr)
617
+ stepErrors.push(ev.message);
618
+ console.log(` ${isErr ? '⚠' : '·'} ${ev.message}`);
619
+ }
500
620
  }
501
621
  }
502
- return terminal;
622
+ return { terminal, stepErrors };
503
623
  }
504
- function printDeploySuccess(id, version, tenant, project, r) {
505
- console.log(`✓ Deployed ${id}@${version} and installed onto ${tenant}/${project}`);
624
+ function printDeploySuccess(id, version, t, r) {
625
+ console.log(`✓ Deployed ${id}@${version} and installed onto ${targetLabel(t)}`);
506
626
  if (r?.warning)
507
627
  console.log(` ⚠ ${r.warning}`);
508
628
  const s = r?.summary;
@@ -519,29 +639,39 @@ function printDeploySuccess(id, version, tenant, project, r) {
519
639
  if (parts.length)
520
640
  console.log(` Seeded: ${parts.join(', ')}`);
521
641
  }
522
- console.log(`\nChat with it on your tenant (web widget / console test page for ${tenant}/${project}).`);
642
+ console.log(`\nChat with it: octwin chat "hi" --as tester (or the web widget / console test page).`);
523
643
  }
524
644
  async function cmdDeploy(flags) {
525
645
  const packDir = resolve(flags.dir ?? '.');
526
- const { url, tenant, project, token } = resolveTarget(flags, packDir);
646
+ const t = resolveTarget(flags, packDir);
647
+ const { url } = t;
527
648
  const { id, version, files } = localValidate(packDir);
528
- const endpoint = `${url}/api/admin/tenants/${tenant}/projects/${project}/packs/deploy`;
649
+ const endpoint = `${url}/api/self/p/packs/deploy`;
529
650
  const seed = flags.seed === true;
530
- console.log(`→ Deploying ${id}@${version} (${Object.keys(files).length} files) to ${tenant}/${project}${seed ? ' — with demo seed' : ''} …`);
651
+ console.log(`→ Deploying ${id}@${version} (${Object.keys(files).length} files) to ${targetLabel(t)}${seed ? ' — with demo seed' : ''} …`);
531
652
  const res = await fetchOrDie(endpoint, {
532
653
  method: 'POST',
533
654
  // Ask for a progress stream; the platform falls back to plain JSON if it
534
655
  // (or an error before any progress) can't stream — handled below.
535
- headers: { 'content-type': 'application/json', accept: 'text/event-stream', authorization: `Bearer ${token}` },
656
+ headers: { 'content-type': 'application/json', accept: 'text/event-stream', ...authHeaders(t) },
536
657
  body: JSON.stringify({ files, seed }),
537
658
  }, 'deploy');
538
659
  // Streaming path — live install + seed progress (image generation can take a
539
660
  // while, so `--seed` prints per-record / per-image lines as they happen).
540
661
  if (res.ok && (res.headers.get('content-type') ?? '').includes('text/event-stream') && res.body) {
541
- const final = await readDeployProgress(res.body);
662
+ const { terminal: final, stepErrors } = await readDeployProgress(res.body);
542
663
  if (!final || final.stage === 'error')
543
664
  die(`deploy failed${final?.message ? `: ${final.message}` : ' (stream ended early)'}`);
544
- printDeploySuccess(id, version, tenant, project, final);
665
+ printDeploySuccess(id, version, t, final);
666
+ if (stepErrors.length) {
667
+ // The pack IS installed, but a step (e.g. the demo seed) failed — say so
668
+ // plainly and exit non-zero so CI / a `deploy && chat` chain doesn't treat
669
+ // an incomplete install as a clean success.
670
+ console.error(`\n⚠ Deployed with ${stepErrors.length} warning${stepErrors.length === 1 ? '' : 's'} — data may be incomplete:`);
671
+ for (const e of stepErrors)
672
+ console.error(` • ${e}`);
673
+ process.exit(1);
674
+ }
545
675
  return;
546
676
  }
547
677
  // Non-streaming path — plain JSON (older platform, or an error thrown before
@@ -560,11 +690,12 @@ async function cmdDeploy(flags) {
560
690
  console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
561
691
  process.exit(1);
562
692
  }
563
- printDeploySuccess(id, version, tenant, project, json);
693
+ printDeploySuccess(id, version, t, json);
564
694
  }
565
695
  async function cmdStatus(flags) {
566
696
  const packDir = resolve(flags.dir ?? '.');
567
- const { url, tenant, project, token } = resolveTarget(flags, packDir);
697
+ const t = resolveTarget(flags, packDir);
698
+ const { url } = t;
568
699
  const manifestPath = join(packDir, 'manifest.yaml');
569
700
  if (!existsSync(manifestPath))
570
701
  die('no manifest.yaml in the pack directory (run from your pack dir or pass --dir)');
@@ -573,9 +704,9 @@ async function cmdStatus(flags) {
573
704
  die('manifest.yaml must declare a string `id`');
574
705
  const id = doc.id;
575
706
  const localVersion = typeof doc?.version === 'string' ? doc.version : '?';
576
- console.log(`→ Checking ${id}@${localVersion} on ${tenant}/${project} @ ${url} …`);
577
- const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/projects/${project}/packs/${id}/runtime`, {
578
- headers: { authorization: `Bearer ${token}` },
707
+ console.log(`→ Checking ${id}@${localVersion} on ${targetLabel(t)} @ ${url} …`);
708
+ const res = await fetchOrDie(`${url}/api/self/p/packs/${id}/runtime`, {
709
+ headers: authHeaders(t),
579
710
  }, 'status check');
580
711
  const text = await res.text();
581
712
  let json;
@@ -587,13 +718,13 @@ async function cmdStatus(flags) {
587
718
  }
588
719
  if (!res.ok) {
589
720
  if (res.status === 404)
590
- die(`'${id}' is not installed on ${tenant}/${project} yet — run \`octwin deploy\` first`);
721
+ die(`'${id}' is not installed on ${targetLabel(t)} yet — run \`octwin deploy\` first`);
591
722
  console.error(`✗ status check failed (HTTP ${res.status})`);
592
723
  printAuthHint(res.status, url);
593
724
  console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
594
725
  process.exit(1);
595
726
  }
596
- console.log(`${id} on ${tenant}/${project} @ ${url}`);
727
+ console.log(`${id} on ${targetLabel(t)} @ ${url}`);
597
728
  console.log(` installed version : ${json.installed_version}`);
598
729
  console.log(` live on instance : registered=${json.registered} source=${json.source} loaded=${json.loaded_version ?? '(none)'}`);
599
730
  console.log(` flows : ${(json.flows ?? []).join(', ') || '(none)'}`);
@@ -612,10 +743,11 @@ async function cmdStatus(flags) {
612
743
  }
613
744
  async function cmdPlatformKb(flags) {
614
745
  const packDir = resolve(flags.dir ?? '.');
615
- const { url, tenant, token } = resolveTarget(flags, packDir);
616
- console.log(`→ Pulling the platform capability reference from '${tenant}' @ ${url} …`);
617
- const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/octwin-platform-kb`, {
618
- headers: { authorization: `Bearer ${token}` },
746
+ const t = resolveTarget(flags, packDir);
747
+ const { url } = t;
748
+ console.log(`→ Pulling the platform capability reference from ${url} …`);
749
+ const res = await fetchOrDie(`${url}/api/self/t/octwin-platform-kb`, {
750
+ headers: authHeaders(t),
619
751
  }, 'platform-kb pull');
620
752
  const text = await res.text();
621
753
  if (!res.ok) {
@@ -631,6 +763,9 @@ async function cmdPlatformKb(flags) {
631
763
  process.exit(1);
632
764
  }
633
765
  const bundle = JSON.parse(text);
766
+ // Snapshot the prior pull's index BEFORE overwriting it, so we can show the
767
+ // author EXACTLY what changed (not just a count).
768
+ const prior = readLocalKb(packDir);
634
769
  // Write the reference into <packDir>/.octwin/platform-kb/ — markdown docs (the
635
770
  // skill reads these first) + JSON catalogs (precise field schemas). Gitignored.
636
771
  const outDir = join(packDir, '.octwin', 'platform-kb');
@@ -654,14 +789,31 @@ async function cmdPlatformKb(flags) {
654
789
  writeFileSync(join(outDir, 'index.json'), JSON.stringify({ version: bundle.version, content_hash: bundle.content_hash, generated_at: bundle.generated_at, index: bundle.index }, null, 2) + '\n', 'utf8');
655
790
  console.log(`✓ Pulled the Octwin platform KB → ${outDir}`);
656
791
  console.log(` ${mdCount} markdown docs + ${jsonCount} JSON catalogs (reference version ${bundle.version ?? '?'})`);
792
+ // Changelog since the last pull — per-entry hashes tell us WHICH docs/catalogs
793
+ // moved (a schema shape being replaced shows as a `~ changed`), not just a count.
794
+ if (prior?.content_hash) {
795
+ if (prior.content_hash === bundle.content_hash) {
796
+ console.log(' (no content changes since your last pull)');
797
+ }
798
+ else {
799
+ const { added, removed, changed } = diffKbIndex(prior.index, bundle.index ?? []);
800
+ console.log(` Δ changes since your last pull (${prior.content_hash} → ${bundle.content_hash ?? '?'}):`);
801
+ for (const k of added)
802
+ console.log(` + ${k} (new)`);
803
+ for (const k of changed)
804
+ console.log(` ~ ${k} (changed)`);
805
+ for (const k of removed)
806
+ console.log(` - ${k} (removed)`);
807
+ }
808
+ }
657
809
  console.log(' The octwin-pack authoring skill reads these as the source of truth for what the platform supports.');
658
810
  }
659
811
  // ── records / cases / logs / chat — headless inspect + test with the deploy token ────
660
812
  /** GET an admin endpoint with the deploy token; returns `{ status, json }`.
661
813
  * Dies (with the URL) on a network failure; auth failures return so the
662
814
  * caller can add command-specific context on top of `authFailureHint`. */
663
- async function apiGet(endpoint, token) {
664
- const res = await fetchOrDie(endpoint, { headers: { authorization: `Bearer ${token}` } }, 'request');
815
+ async function apiGet(endpoint, t) {
816
+ const res = await fetchOrDie(endpoint, { headers: authHeaders(t) }, 'request');
665
817
  const text = await res.text();
666
818
  let json;
667
819
  try {
@@ -672,16 +824,22 @@ async function apiGet(endpoint, token) {
672
824
  }
673
825
  return { status: res.status, json };
674
826
  }
827
+ /** A progress-line label for the target workspace. The token names the tenant, so
828
+ * we surface at most the project (when pinned or overridden by `--project`). */
829
+ function targetLabel(t) {
830
+ return t.project ? `project '${t.project}'` : 'your workspace';
831
+ }
675
832
  /** `octwin records [entity] [id]` — inspect the pack's XRM data (needs a `records:read` token). */
676
833
  async function cmdRecords(flags) {
677
834
  const packDir = resolve(flags.dir ?? '.');
678
- const { url, tenant, project, token } = resolveTarget(flags, packDir);
679
- const base = `${url}/api/admin/tenants/${tenant}/projects/${project}`;
835
+ const t = resolveTarget(flags, packDir);
836
+ const { url } = t;
837
+ const base = `${url}/api/self/p`;
680
838
  const entity = flags._[0];
681
839
  const recordId = flags._[1];
682
- console.log(`→ Reading ${recordId ? `${entity} record ${recordId}` : entity ? `${entity} records` : 'the entity catalog'} from ${tenant}/${project} …`);
840
+ console.log(`→ Reading ${recordId ? `${entity} record ${recordId}` : entity ? `${entity} records` : 'the entity catalog'} from ${targetLabel(t)} …`);
683
841
  if (!entity) {
684
- const { status, json } = await apiGet(`${base}/xrm/entities`, token);
842
+ const { status, json } = await apiGet(`${base}/xrm/entities`, t);
685
843
  if (status !== 200)
686
844
  die(`could not read entities (HTTP ${status})`);
687
845
  if (json?.has_xrm === false) {
@@ -693,7 +851,7 @@ async function cmdRecords(flags) {
693
851
  console.log('No entities visible — mint a token with the `records:read` scope to inspect data.');
694
852
  return;
695
853
  }
696
- console.log(`Entities in ${tenant}/${project}:`);
854
+ console.log(`Entities in ${targetLabel(t)}:`);
697
855
  for (const e of ents)
698
856
  console.log(` ${e.entity} (${e.open_count ?? 0} records)`);
699
857
  console.log('\nList records: octwin records <entity>');
@@ -701,7 +859,7 @@ async function cmdRecords(flags) {
701
859
  }
702
860
  if (!recordId) {
703
861
  const limit = flags.limit ?? '50';
704
- const { status, json } = await apiGet(`${base}/xrm/records?entity=${encodeURIComponent(entity)}&limit=${limit}`, token);
862
+ const { status, json } = await apiGet(`${base}/xrm/records?entity=${encodeURIComponent(entity)}&limit=${limit}`, t);
705
863
  if (status === 403)
706
864
  die('forbidden — the paged record list needs the `records` plan feature on this tenant');
707
865
  if (status !== 200) {
@@ -720,7 +878,7 @@ async function cmdRecords(flags) {
720
878
  console.log(` #${r.record_number ?? '?'} ${r.title ?? '(untitled)'}${r.stage ? ` [${r.stage}]` : ''} ${r.id}`);
721
879
  return;
722
880
  }
723
- const { status, json } = await apiGet(`${base}/xrm/records/${encodeURIComponent(recordId)}`, token);
881
+ const { status, json } = await apiGet(`${base}/xrm/records/${encodeURIComponent(recordId)}`, t);
724
882
  if (status === 403)
725
883
  die('forbidden — mint a token with the `records:read` scope');
726
884
  if (status === 404)
@@ -735,15 +893,16 @@ async function cmdRecords(flags) {
735
893
  * or show one's event timeline (full text + the renders each turn produced). */
736
894
  async function cmdLogs(flags) {
737
895
  const packDir = resolve(flags.dir ?? '.');
738
- const { url, tenant, project, token } = resolveTarget(flags, packDir);
739
- const base = `${url}/api/admin/tenants/${tenant}/projects/${project}`;
896
+ const t = resolveTarget(flags, packDir);
897
+ const { url } = t;
898
+ const base = `${url}/api/self/p`;
740
899
  const convId = flags._[0];
741
900
  const asJson = flags.json === true;
742
901
  const asHandle = typeof flags.as === 'string' ? flags.as : undefined;
743
902
  if (!asJson)
744
- console.log(`→ Reading ${convId ? `conversation ${convId}` : 'recent conversations'} from ${tenant}/${project} …`);
903
+ console.log(`→ Reading ${convId ? `conversation ${convId}` : 'recent conversations'} from ${targetLabel(t)} …`);
745
904
  if (!convId) {
746
- const { status, json } = await apiGet(`${base}/conversations?limit=50`, token);
905
+ const { status, json } = await apiGet(`${base}/conversations?limit=50`, t);
747
906
  if (status !== 200)
748
907
  die(`could not read conversations (HTTP ${status})${errDetail(json)} — ${authFailureHint(status, url)}`);
749
908
  let convs = (json?.conversations ?? []);
@@ -757,7 +916,7 @@ async function cmdLogs(flags) {
757
916
  console.log(JSON.stringify(convs, null, 2));
758
917
  return;
759
918
  }
760
- console.log(`Recent conversations in ${tenant}/${project}${asHandle ? ` (handle: ${asHandle})` : ''}:`);
919
+ console.log(`Recent conversations in ${targetLabel(t)}${asHandle ? ` (handle: ${asHandle})` : ''}:`);
761
920
  for (const c of convs) {
762
921
  const handle = c.contact?.channel_contact_handle ?? '?';
763
922
  const name = c.contact?.display_name && c.contact.display_name !== handle ? ` (${c.contact.display_name})` : '';
@@ -767,7 +926,7 @@ async function cmdLogs(flags) {
767
926
  console.log('\nView a timeline: octwin logs <conversationId> (add --json for full payloads)');
768
927
  return;
769
928
  }
770
- const { status, json } = await apiGet(`${base}/conversations/${encodeURIComponent(convId)}`, token);
929
+ const { status, json } = await apiGet(`${base}/conversations/${encodeURIComponent(convId)}`, t);
771
930
  if (status === 404)
772
931
  die(`conversation '${convId}' not found`);
773
932
  if (status !== 200)
@@ -922,18 +1081,40 @@ async function cmdChat(flags) {
922
1081
  const packDir = resolve(flags.dir ?? '.');
923
1082
  const cfg = readPackConfig(packDir);
924
1083
  const url = (flags.url ?? process.env.PACK_PLATFORM_URL ?? cfg.platform_url ?? '').replace(/\/$/, '');
925
- const tenant = flags.tenant ?? process.env.PACK_TENANT ?? cfg.tenant ?? '';
926
- const project = flags.project ?? process.env.PACK_PROJECT ?? cfg.project ?? 'main';
927
1084
  if (!url)
928
1085
  die('no platform url — set it in pack.json, --url, or PACK_PLATFORM_URL');
1086
+ let tenant = flags.tenant || process.env.PACK_TENANT || cfg.tenant || '';
1087
+ let project = flags.project || process.env.PACK_PROJECT || cfg.project || '';
1088
+ // The dev web channel is tenant/project-pathed (it simulates an end-user on a
1089
+ // specific project). When they aren't configured, derive them from the token —
1090
+ // its tenant + optional project pin — via the slug-free `/api/self/t/whoami`.
1091
+ if (!tenant || !project) {
1092
+ const token = flags.token ?? process.env.PACK_TOKEN ?? readCreds()[url] ?? '';
1093
+ if (token) {
1094
+ try {
1095
+ const who = await fetch(`${url}/api/self/t/whoami`, {
1096
+ headers: { authorization: `Bearer ${token}`, ...(tenant ? { 'x-octwin-tenant': tenant } : {}) },
1097
+ });
1098
+ if (who.ok) {
1099
+ const j = await who.json();
1100
+ tenant = tenant || (j.tenant_slug ?? '');
1101
+ project = project || (j.project_slug ?? '');
1102
+ }
1103
+ }
1104
+ catch { /* fall through to the checks below */ }
1105
+ }
1106
+ }
929
1107
  if (!tenant)
930
- die('no tenant — set it in pack.json, --tenant, or PACK_TENANT');
1108
+ die('no tenant — set --tenant / PACK_TENANT / pack.json, or pass a --token to derive it');
1109
+ if (!project)
1110
+ project = 'main';
931
1111
  const from = flags.as ?? 'cli-tester';
932
1112
  const asJson = flags.json === true;
933
1113
  const tapId = typeof flags.tap === 'string' ? flags.tap : undefined;
1114
+ const mediaArg = typeof flags.media === 'string' ? flags.media : undefined;
934
1115
  const message = flags._[0];
935
- if (!message && !tapId)
936
- die('usage: octwin chat "your message" [--as <handle>] [--tap <tap-id>] [--json]');
1116
+ if (!message && !tapId && !mediaArg)
1117
+ die('usage: octwin chat "your message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json]');
937
1118
  // Fresh idempotency key per call (see the command doc above).
938
1119
  const localId = `cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
939
1120
  if (!asJson)
@@ -957,16 +1138,35 @@ async function cmdChat(flags) {
957
1138
  if (f.id != null && f.id > boundary)
958
1139
  boundary = f.id;
959
1140
  }
960
- // Phase 2 — send the inbound (text, or an interactive tap).
961
- console.log(`→ [${from}] ${tapId ? `(tap) ${tapId}` : message}`);
962
- const body = tapId
963
- ? { type: 'interactive', from, tap_id: tapId, ...(message ? { raw_title: message } : {}), local_id: localId }
964
- : { type: 'text', from, text: message, local_id: localId };
965
- const postRes = await fetchOrDie(`${url}/api/web/inbound/${tenant}/${project}`, {
966
- method: 'POST',
967
- headers: { 'content-type': 'application/json' },
968
- body: JSON.stringify(body),
969
- }, 'send message');
1141
+ // Phase 2 — send the inbound (media upload, an interactive tap, or text).
1142
+ const inbound = `${url}/api/web/inbound/${tenant}/${project}`;
1143
+ let postRes;
1144
+ if (mediaArg && !tapId) {
1145
+ // Multipart upload → the platform's media pipeline sets $inbound_media, which
1146
+ // a running collect folds into $state.<field> (docs/19 §8a). Any accompanying
1147
+ // message rides as the media caption.
1148
+ const { blob, kind, filename } = await resolveMediaPart(url, mediaArg);
1149
+ console.log(`→ [${from}] (media:${kind}) ${filename}${message ? ` — "${message}"` : ''}`);
1150
+ const form = new FormData();
1151
+ form.append('type', kind);
1152
+ form.append('from', from);
1153
+ form.append('local_id', localId);
1154
+ if (message)
1155
+ form.append('caption', message);
1156
+ form.append('file', blob, filename);
1157
+ postRes = await fetchOrDie(inbound, { method: 'POST', body: form }, 'send media'); // fetch sets the multipart boundary
1158
+ }
1159
+ else {
1160
+ console.log(`→ [${from}] ${tapId ? `(tap) ${tapId}` : message}`);
1161
+ const body = tapId
1162
+ ? { type: 'interactive', from, tap_id: tapId, ...(message ? { raw_title: message } : {}), local_id: localId }
1163
+ : { type: 'text', from, text: message, local_id: localId };
1164
+ postRes = await fetchOrDie(inbound, {
1165
+ method: 'POST',
1166
+ headers: { 'content-type': 'application/json' },
1167
+ body: JSON.stringify(body),
1168
+ }, 'send message');
1169
+ }
970
1170
  if (!postRes.ok) {
971
1171
  await cancel();
972
1172
  die(`send rejected (HTTP ${postRes.status}): ${await postRes.text()}`);
@@ -1011,23 +1211,86 @@ async function cmdChat(flags) {
1011
1211
  }
1012
1212
  console.log(`\n(same --as '${from}' continues this conversation — timeline: octwin logs --as ${from})`);
1013
1213
  }
1214
+ /** `octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]`
1215
+ * — AI-generate an image on the platform (needs a `media:generate`-scoped token),
1216
+ * store it as a public asset, and return its `MEDIA-` handle + serve URL. `--out`
1217
+ * downloads the bytes to a file (WhatsApp renders only `.png`/`.jpg`); the paired
1218
+ * `octwin chat --media <file|id>` feeds it into a running media-collect flow. */
1219
+ async function cmdMedia(flags) {
1220
+ const sub = flags._[0];
1221
+ if (sub !== 'generate')
1222
+ die('usage: octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]');
1223
+ const packDir = resolve(flags.dir ?? '.');
1224
+ const t = resolveTarget(flags, packDir);
1225
+ const { url } = t;
1226
+ const prompt = flags._[1];
1227
+ if (!prompt)
1228
+ die('usage: octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]');
1229
+ const asJson = flags.json === true;
1230
+ const size = typeof flags.size === 'string' ? flags.size : undefined;
1231
+ const out = typeof flags.out === 'string' ? flags.out : undefined;
1232
+ if (!asJson)
1233
+ console.log(`→ Generating an image on ${targetLabel(t)} @ ${url} …`);
1234
+ const res = await fetchOrDie(`${url}/api/self/p/media/generate`, {
1235
+ method: 'POST',
1236
+ headers: { 'content-type': 'application/json', ...authHeaders(t) },
1237
+ body: JSON.stringify({ prompt, ...(size ? { size } : {}) }),
1238
+ }, 'media generate');
1239
+ const text = await res.text();
1240
+ if (!res.ok) {
1241
+ let j;
1242
+ try {
1243
+ j = JSON.parse(text);
1244
+ }
1245
+ catch {
1246
+ j = text;
1247
+ }
1248
+ console.error(`✗ media generate failed (HTTP ${res.status})${errDetail(j)}`);
1249
+ printAuthHint(res.status, url);
1250
+ process.exit(1);
1251
+ }
1252
+ const r = JSON.parse(text);
1253
+ const absUrl = /^https?:/i.test(r.url) ? r.url : `${url}${r.url}`;
1254
+ // --out → download the bytes so the paired `chat --media <file>` can send them.
1255
+ if (out) {
1256
+ const dl = await fetchOrDie(absUrl, undefined, 'download generated image');
1257
+ if (!dl.ok)
1258
+ die(`could not download the generated image (HTTP ${dl.status})`);
1259
+ writeFileSync(out, Buffer.from(await dl.arrayBuffer()));
1260
+ const ext = out.slice(out.lastIndexOf('.')).toLowerCase();
1261
+ if (ext !== '.png' && ext !== '.jpg' && ext !== '.jpeg') {
1262
+ console.error(` ⚠ '${out}' isn't a .png/.jpg — WhatsApp only renders those extensions.`);
1263
+ }
1264
+ }
1265
+ if (asJson) {
1266
+ console.log(JSON.stringify({ media_id: r.media_id, url: absUrl, mime: r.mime, width: r.width, height: r.height, bytes: r.bytes }));
1267
+ return;
1268
+ }
1269
+ console.log(`✓ Generated ${r.media_ref} (${r.width}×${r.height}, ${r.mime}, ${r.bytes} bytes)`);
1270
+ console.log(` id: ${r.media_id}`);
1271
+ console.log(` url: ${absUrl}`);
1272
+ if (out)
1273
+ console.log(` saved → ${out}`);
1274
+ console.log(` Send it into a chat: octwin chat "here you go" --media ${out ?? r.media_id} --as <handle>`);
1275
+ }
1014
1276
  /** `octwin cases [caseId] [--queues]` — inspect casework (support tickets):
1015
1277
  * the aggregate inbox, one case + its timeline, or the queue list. */
1016
1278
  async function cmdCases(flags) {
1017
1279
  const packDir = resolve(flags.dir ?? '.');
1018
- const { url, tenant, project, token } = resolveTarget(flags, packDir);
1019
- const base = `${url}/api/admin/tenants/${tenant}/projects/${project}`;
1280
+ const t = resolveTarget(flags, packDir);
1281
+ const { url } = t;
1282
+ const base = `${url}/api/self/p`;
1020
1283
  const caseId = flags._[0];
1021
1284
  const asJson = flags.json === true;
1022
1285
  if (!asJson)
1023
- console.log(`→ Reading ${flags.queues === true ? 'case queues' : caseId ? `case ${caseId}` : 'the case inbox'} from ${tenant}/${project} …`);
1286
+ console.log(`→ Reading ${flags.queues === true ? 'case queues' : caseId ? `case ${caseId}` : 'the case inbox'} from ${targetLabel(t)} …`);
1024
1287
  const caseFail = (what, status, json) => {
1025
1288
  if (status === 403)
1026
1289
  die(`forbidden — casework needs the 'cases' plan feature on this tenant, and a role whose grants reach the queue`);
1027
1290
  die(`could not read ${what} (HTTP ${status})${errDetail(json)}${status === 401 ? ` — ${authFailureHint(status, url)}` : ''}`);
1028
1291
  };
1029
1292
  if (flags.queues === true) {
1030
- const { status, json } = await apiGet(`${base}/case-queues`, token);
1293
+ const { status, json } = await apiGet(`${base}/case-queues`, t);
1031
1294
  if (status !== 200)
1032
1295
  caseFail('case queues', status, json);
1033
1296
  if (asJson) {
@@ -1035,7 +1298,7 @@ async function cmdCases(flags) {
1035
1298
  return;
1036
1299
  }
1037
1300
  const queues = (json?.queues ?? []);
1038
- console.log(`Case queues in ${tenant}/${project}:`);
1301
+ console.log(`Case queues in ${targetLabel(t)}:`);
1039
1302
  for (const q of queues)
1040
1303
  console.log(` ${q.key}${q.name ? ` (${q.name})` : ''} ${q.open_count} open`);
1041
1304
  if (json?.unrouted_open_count)
@@ -1044,7 +1307,7 @@ async function cmdCases(flags) {
1044
1307
  }
1045
1308
  if (!caseId) {
1046
1309
  const limit = flags.limit ?? '50';
1047
- const { status, json } = await apiGet(`${base}/cases?limit=${limit}`, token);
1310
+ const { status, json } = await apiGet(`${base}/cases?limit=${limit}`, t);
1048
1311
  if (status !== 200)
1049
1312
  caseFail('cases', status, json);
1050
1313
  if (asJson) {
@@ -1052,7 +1315,7 @@ async function cmdCases(flags) {
1052
1315
  return;
1053
1316
  }
1054
1317
  const rows = (json?.cases ?? []);
1055
- console.log(`Cases in ${tenant}/${project}: ${json?.total ?? rows.length} total`);
1318
+ console.log(`Cases in ${targetLabel(t)}: ${json?.total ?? rows.length} total`);
1056
1319
  if (rows.length === 0)
1057
1320
  console.log(' (none)');
1058
1321
  for (const c of rows) {
@@ -1062,7 +1325,7 @@ async function cmdCases(flags) {
1062
1325
  console.log('\nOne case + timeline: octwin cases <caseId> queues: octwin cases --queues');
1063
1326
  return;
1064
1327
  }
1065
- const { status, json } = await apiGet(`${base}/cases/${encodeURIComponent(caseId)}`, token);
1328
+ const { status, json } = await apiGet(`${base}/cases/${encodeURIComponent(caseId)}`, t);
1066
1329
  if (status === 404)
1067
1330
  die(`case '${caseId}' not found`);
1068
1331
  if (status !== 200)
@@ -1106,7 +1369,8 @@ function help() {
1106
1369
  octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
1107
1370
  octwin cases [caseId] [--queues] [--json] # inspect casework (support tickets) + timelines
1108
1371
  octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
1109
- octwin chat "message" [--as <handle>] [--tap <tap-id>] [--json] # drive a turn + print every render
1372
+ octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
1373
+ octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (needs media:generate scope)
1110
1374
  octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
1111
1375
  octwin test [--dir .] # = validate --remote (the full platform check)
1112
1376
 
@@ -1127,7 +1391,8 @@ const COMMAND_HELP = {
1127
1391
  Offline structural check; --remote additionally runs the platform's FULL
1128
1392
  manifest + flow-DSL validation (all errors at once) — same check as deploy.`,
1129
1393
  login: `octwin login --url <platformUrl> --token oct_…
1130
- Save a deploy token (console → Settings → API tokens) for that platform url.`,
1394
+ Save a deploy token (console → Settings → API tokens) for that platform url,
1395
+ and echo the workspace + project pin + scopes the token reaches.`,
1131
1396
  whoami: `octwin whoami [--url <url>] [--tenant <slug>]
1132
1397
  Verify the resolved token authenticates against the tenant.`,
1133
1398
  deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
@@ -1145,11 +1410,19 @@ const COMMAND_HELP = {
1145
1410
  No id = recent conversations (handle, status, last activity; --as filters).
1146
1411
  With id = the full event timeline including what each turn rendered.
1147
1412
  --json = raw events (verbatim payloads).`,
1148
- chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--json]
1413
+ chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json]
1149
1414
  Drive one turn through the dev web channel and print every render with its
1150
1415
  tap ids. Same --as handle = same conversation (multi-turn works).
1151
1416
  --tap presses a rendered button/list row instead of sending text.
1417
+ --media uploads a local file (or a media id from 'media generate --json') as
1418
+ an image/document/audio inbound — any "message" rides as its caption; feeds a
1419
+ running media-collect flow (e.g. activate-app).
1152
1420
  --json dumps the raw SSE envelopes for the turn.`,
1421
+ media: `octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]
1422
+ AI-generate an image (needs a media:generate-scoped token), store it as a
1423
+ public asset, and print its MEDIA- handle + serve URL. --out downloads the
1424
+ bytes (WhatsApp renders only .png/.jpg); --json emits { media_id, url, mime,
1425
+ width, height, bytes }. Pair with 'octwin chat --media' to drive media flows.`,
1153
1426
  'platform-kb': `octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
1154
1427
  Pull the platform capability reference (markdown + JSON catalogs) into
1155
1428
  .octwin/platform-kb/ for the octwin-pack authoring skill.`,
@@ -1173,7 +1446,7 @@ async function main() {
1173
1446
  await cmdValidate(flags);
1174
1447
  break;
1175
1448
  case 'login':
1176
- cmdLogin(flags);
1449
+ await cmdLogin(flags);
1177
1450
  break;
1178
1451
  case 'whoami':
1179
1452
  await cmdWhoami(flags);
@@ -1196,6 +1469,9 @@ async function main() {
1196
1469
  case 'chat':
1197
1470
  await cmdChat(flags);
1198
1471
  break;
1472
+ case 'media':
1473
+ await cmdMedia(flags);
1474
+ break;
1199
1475
  case 'platform-kb':
1200
1476
  await cmdPlatformKb(flags);
1201
1477
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octwin-cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
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": {