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.
- package/CHANGELOG.md +45 -0
- package/dist/index.js +383 -107
- 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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
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
|
|
270
|
-
if (!
|
|
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/
|
|
278
|
-
headers:
|
|
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 !==
|
|
285
|
-
|
|
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
|
|
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
|
|
372
|
-
|
|
373
|
-
|
|
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',
|
|
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
|
|
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
|
|
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 +
|
|
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
|
|
427
|
-
const project = flags.project
|
|
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
|
|
435
|
-
return { url, tenant, project
|
|
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
|
|
438
|
-
*
|
|
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
|
|
444
|
-
const project = flags.project
|
|
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 || !
|
|
554
|
+
if (!url || !token)
|
|
447
555
|
return null;
|
|
448
|
-
return { url, tenant, project
|
|
556
|
+
return { url, token, tenant, project };
|
|
449
557
|
}
|
|
450
558
|
async function cmdWhoami(flags) {
|
|
451
559
|
const packDir = resolve(flags.dir ?? '.');
|
|
452
|
-
const
|
|
453
|
-
console.log(`→ Checking the
|
|
454
|
-
const res = await fetchOrDie(`${url}/api/
|
|
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
|
-
|
|
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
|
|
571
|
+
: res.status === 403 ? 'token not authorized for this workspace'
|
|
461
572
|
: await res.text();
|
|
462
|
-
die(`token check failed
|
|
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
|
-
|
|
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,
|
|
505
|
-
console.log(`✓ Deployed ${id}@${version} and installed onto ${
|
|
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
|
|
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
|
|
646
|
+
const t = resolveTarget(flags, packDir);
|
|
647
|
+
const { url } = t;
|
|
527
648
|
const { id, version, files } = localValidate(packDir);
|
|
528
|
-
const endpoint = `${url}/api/
|
|
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 ${
|
|
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',
|
|
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,
|
|
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,
|
|
693
|
+
printDeploySuccess(id, version, t, json);
|
|
564
694
|
}
|
|
565
695
|
async function cmdStatus(flags) {
|
|
566
696
|
const packDir = resolve(flags.dir ?? '.');
|
|
567
|
-
const
|
|
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 ${
|
|
577
|
-
const res = await fetchOrDie(`${url}/api/
|
|
578
|
-
headers:
|
|
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 ${
|
|
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 ${
|
|
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
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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,
|
|
664
|
-
const res = await fetchOrDie(endpoint, { headers:
|
|
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
|
|
679
|
-
const
|
|
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 ${
|
|
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`,
|
|
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 ${
|
|
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}`,
|
|
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)}`,
|
|
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
|
|
739
|
-
const
|
|
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 ${
|
|
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`,
|
|
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 ${
|
|
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)}`,
|
|
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
|
|
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 (
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
|
1019
|
-
const
|
|
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 ${
|
|
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`,
|
|
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 ${
|
|
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}`,
|
|
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 ${
|
|
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)}`,
|
|
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