@commercelayer/agent-cli 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -6
- package/dist/api/client.js +39 -1
- package/dist/api/stub/http.js +2 -2
- package/dist/api/stub/index.js +1 -1
- package/dist/api/stub/management.js +19 -2
- package/dist/api/stub/messages.js +126 -0
- package/dist/api/stub/orders.js +299 -33
- package/dist/api/stub/theme.js +16 -1
- package/dist/api/stub/ucp.js +2 -2
- package/dist/api/transport.js +24 -7
- package/dist/bin.js +28 -6
- package/dist/commands.js +490 -16
- package/dist/cursor.js +31 -0
- package/dist/files.js +5 -3
- package/dist/inbox/cursor.js +11 -0
- package/dist/inbox/fake.js +95 -0
- package/dist/inbox/imap.js +139 -0
- package/dist/inbox/mailbox.js +25 -0
- package/dist/inbox/message.js +88 -0
- package/dist/ops.js +190 -0
- package/dist/orderRequest.js +141 -0
- package/dist/output.js +7 -3
- package/dist/ucp/images.js +62 -0
- package/dist/ucp/shop.js +2 -0
- package/package.json +3 -1
package/dist/cursor.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// A small JSON state file under .commercelayer/ that a command rewrites on every run (the ops
|
|
2
|
+
// cursor, the inbox cursor): read with defaults when missing or broken, kept out of git from
|
|
3
|
+
// its first write, like the credentials.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { ignorePath } from './files.js';
|
|
7
|
+
/** At most this many ids are remembered, newest first. */
|
|
8
|
+
export const MAX_SEEN = 500;
|
|
9
|
+
/** A cursor in `file`: parse turns what was read (or undefined when unreadable) into T. */
|
|
10
|
+
export function jsonCursor(file, parse) {
|
|
11
|
+
return {
|
|
12
|
+
file,
|
|
13
|
+
read() {
|
|
14
|
+
try {
|
|
15
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
16
|
+
return parse(raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : undefined);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return parse(undefined);
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
write(value) {
|
|
23
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
24
|
+
if (!fs.existsSync(file))
|
|
25
|
+
ignorePath(file);
|
|
26
|
+
fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** The strings of a list read back from a cursor file, at most MAX_SEEN. */
|
|
31
|
+
export const strings = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string').slice(0, MAX_SEEN) : []);
|
package/dist/files.js
CHANGED
|
@@ -53,12 +53,14 @@ export function requireCredentials() {
|
|
|
53
53
|
return c;
|
|
54
54
|
}
|
|
55
55
|
/** Make sure .gitignore lists the credentials file. Returns true if it changed. */
|
|
56
|
-
export
|
|
56
|
+
export const ignoreCredentials = () => ignorePath(paths.credentials);
|
|
57
|
+
/** Add a path to .gitignore unless it is there; whether it was added. */
|
|
58
|
+
export function ignorePath(file) {
|
|
57
59
|
const existing = fs.existsSync('.gitignore') ? fs.readFileSync('.gitignore', 'utf8') : '';
|
|
58
|
-
if (existing.split('\n').includes(
|
|
60
|
+
if (existing.split('\n').includes(file))
|
|
59
61
|
return false;
|
|
60
62
|
const sep = existing && !existing.endsWith('\n') ? '\n' : '';
|
|
61
|
-
fs.appendFileSync('.gitignore', `${sep}${
|
|
63
|
+
fs.appendFileSync('.gitignore', `${sep}${file}\n`);
|
|
62
64
|
return true;
|
|
63
65
|
}
|
|
64
66
|
/** The note that sends a future agent in this repo to the skills. No skill names: it never goes stale. */
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// What the inbox commands remember between runs, in .commercelayer/inbox.json (gitignored):
|
|
2
|
+
// when the inbox was last listed, and the messages already pulled, by Message-ID (or by id
|
|
3
|
+
// when a message has none), so inbox list marks only the others as new.
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { jsonCursor, MAX_SEEN, strings } from '../cursor.js';
|
|
6
|
+
import { paths } from '../files.js';
|
|
7
|
+
const cursor = () => jsonCursor(path.join(paths.dir, 'inbox.json'), (c) => ({ last_check: typeof c?.last_check === 'string' ? c.last_check : null, seen: strings(c?.seen) }));
|
|
8
|
+
export const readInboxCursor = () => cursor().read();
|
|
9
|
+
export const writeInboxCursor = (c) => cursor().write({ last_check: c.last_check, seen: c.seen.slice(0, MAX_SEEN) });
|
|
10
|
+
/** The key a message is remembered by. */
|
|
11
|
+
export const seenKey = (m) => m.message_id ?? `id:${m.id}`;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// A mailbox for tests (C11R_IMAP_FAKE=<dir>): the .eml files in the folder are the messages,
|
|
2
|
+
// their id the file name's number (or the position), and flags.json keeps what IMAP would: seen,
|
|
3
|
+
// labels, the folder a message was moved to. "gmail": true in flags.json makes labels Gmail
|
|
4
|
+
// labels; "keywords": false makes a server with neither, which can only move.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fail } from '../output.js';
|
|
8
|
+
import { keywordOk } from './mailbox.js';
|
|
9
|
+
import { parseMessage } from './message.js';
|
|
10
|
+
export function openFake(dir, folder) {
|
|
11
|
+
const stateFile = path.join(dir, 'flags.json');
|
|
12
|
+
const read = () => {
|
|
13
|
+
const s = fs.existsSync(stateFile) ? JSON.parse(fs.readFileSync(stateFile, 'utf8')) : {};
|
|
14
|
+
return { ...s, messages: s.messages ?? {} };
|
|
15
|
+
};
|
|
16
|
+
const save = (s) => fs.writeFileSync(stateFile, JSON.stringify(s, null, 2) + '\n');
|
|
17
|
+
const files = () => fs
|
|
18
|
+
.readdirSync(dir)
|
|
19
|
+
.filter((f) => f.endsWith('.eml'))
|
|
20
|
+
.sort()
|
|
21
|
+
.map((f, i) => ({ id: /^\d+\.eml$/.test(f) ? String(Number(f.slice(0, -4))) : String(i + 1), file: path.join(dir, f) }));
|
|
22
|
+
const here = (s) => files().filter((m) => (s.messages[m.id]?.folder ?? 'INBOX') === folder);
|
|
23
|
+
const one = (id) => {
|
|
24
|
+
const s = read();
|
|
25
|
+
const m = here(s).find((x) => x.id === id);
|
|
26
|
+
return { s, m, st: (s.messages[id] ??= { flags: [], labels: [] }) };
|
|
27
|
+
};
|
|
28
|
+
const labelsOf = (s, id) => (s.gmail ? (s.messages[id]?.labels ?? []) : (s.messages[id]?.flags ?? []).filter((f) => !f.startsWith('\\')));
|
|
29
|
+
return {
|
|
30
|
+
host: 'fake',
|
|
31
|
+
user: 'orders@example.com',
|
|
32
|
+
folder,
|
|
33
|
+
async check() {
|
|
34
|
+
const s = read();
|
|
35
|
+
const all = here(s);
|
|
36
|
+
return { exists: all.length, unseen: all.filter((m) => !s.messages[m.id]?.flags.includes('\\Seen')).length };
|
|
37
|
+
},
|
|
38
|
+
async list({ unseen, since, limit }) {
|
|
39
|
+
const s = read();
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const m of here(s)) {
|
|
42
|
+
const seen = s.messages[m.id]?.flags.includes('\\Seen') ?? false;
|
|
43
|
+
if (unseen && seen)
|
|
44
|
+
continue;
|
|
45
|
+
const p = await parseMessage(fs.readFileSync(m.file));
|
|
46
|
+
if (since && p.date && Date.parse(p.date) < since.getTime())
|
|
47
|
+
continue;
|
|
48
|
+
out.push({ id: m.id, message_id: p.message_id, from: p.from, subject: p.subject, date: p.date, seen, labels: labelsOf(s, m.id), size: fs.statSync(m.file).size });
|
|
49
|
+
}
|
|
50
|
+
return out.sort((a, b) => Number(b.id) - Number(a.id)).slice(0, limit);
|
|
51
|
+
},
|
|
52
|
+
async fetch(id) {
|
|
53
|
+
const { s, m } = one(id);
|
|
54
|
+
return m ? { raw: fs.readFileSync(m.file), labels: labelsOf(s, id) } : null;
|
|
55
|
+
},
|
|
56
|
+
async tag(id, t) {
|
|
57
|
+
const { s, m, st } = one(id);
|
|
58
|
+
if (!m)
|
|
59
|
+
fail('not_found', `There is no message ${id} in ${folder}.`, 'Run `c11r inbox list` for the ids.');
|
|
60
|
+
const r = { id, labels_as: null, labeled: [], unlabeled: [], seen: false };
|
|
61
|
+
if (t.label.length || t.unlabel.length) {
|
|
62
|
+
if (s.gmail) {
|
|
63
|
+
r.labels_as = 'gmail_label';
|
|
64
|
+
st.labels = [...new Set([...st.labels, ...t.label])].filter((l) => !t.unlabel.includes(l));
|
|
65
|
+
}
|
|
66
|
+
else if (s.keywords !== false) {
|
|
67
|
+
const bad = [...t.label, ...t.unlabel].find((l) => !keywordOk(l));
|
|
68
|
+
if (bad)
|
|
69
|
+
fail('invalid_argument', `${bad} cannot be a keyword on fake: no spaces, parentheses, quotes or backslashes.`, 'Use a one-word label (ordered, order-pending), or --move <folder>.');
|
|
70
|
+
r.labels_as = 'keyword';
|
|
71
|
+
st.flags = [...new Set([...st.flags, ...t.label])].filter((l) => !t.unlabel.includes(l));
|
|
72
|
+
}
|
|
73
|
+
else if (!t.move)
|
|
74
|
+
fail('labels_unsupported', `fake keeps no labels or keyword flags in ${folder}.`, 'Move the email to a folder instead: c11r inbox tag <id> --move <folder> [--seen].');
|
|
75
|
+
else
|
|
76
|
+
r.notes = [`fake keeps no labels or keywords: the email was moved to ${t.move} instead${t.unlabel.length ? ', and there was nothing to remove' : ''}.`];
|
|
77
|
+
if (r.labels_as) {
|
|
78
|
+
r.labeled = t.label;
|
|
79
|
+
r.unlabeled = t.unlabel;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (t.seen) {
|
|
83
|
+
st.flags = [...new Set([...st.flags, '\\Seen'])];
|
|
84
|
+
r.seen = true;
|
|
85
|
+
}
|
|
86
|
+
if (t.move) {
|
|
87
|
+
st.folder = t.move;
|
|
88
|
+
r.moved_to = t.move;
|
|
89
|
+
}
|
|
90
|
+
save(s);
|
|
91
|
+
return r;
|
|
92
|
+
},
|
|
93
|
+
async close() { },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// The inbox over IMAP with imapflow, loaded only here: the other commands never pay for it.
|
|
2
|
+
// Gmail keeps labels (X-GM-EXT-1); other servers keep keyword flags when the folder allows any
|
|
3
|
+
// (\*); a server with neither can only move the message to a folder.
|
|
4
|
+
import { fail } from '../output.js';
|
|
5
|
+
import { keywordOk } from './mailbox.js';
|
|
6
|
+
const who = (list) => {
|
|
7
|
+
const a = list?.[0];
|
|
8
|
+
if (!a?.address)
|
|
9
|
+
return null;
|
|
10
|
+
return a.name ? `${a.name} <${a.address}>` : a.address;
|
|
11
|
+
};
|
|
12
|
+
export async function openImap(s) {
|
|
13
|
+
const { ImapFlow } = await import('imapflow');
|
|
14
|
+
// What an error may say: never the password, even if a server echoes it.
|
|
15
|
+
const clean = (msg) => (s.password ? msg.split(s.password).join('***') : msg);
|
|
16
|
+
const unreachable = (e) => {
|
|
17
|
+
const err = e;
|
|
18
|
+
const reason = clean(err.responseText || err.message || String(e));
|
|
19
|
+
const auth = err.authenticationFailed || /auth|credentials|login/i.test(reason);
|
|
20
|
+
fail('inbox_unreachable', `Could not open ${s.folder} on ${s.host}:${s.port} as ${s.user}: ${reason}`, auth
|
|
21
|
+
? 'Check C11R_IMAP_USER and C11R_IMAP_PASSWORD in .commercelayer/credentials with the human: Gmail needs IMAP on and an app password (https://myaccount.google.com/apppasswords), not the account password.'
|
|
22
|
+
: 'Check C11R_IMAP_HOST and C11R_IMAP_PORT (993) in .commercelayer/credentials, and that this machine is online.');
|
|
23
|
+
};
|
|
24
|
+
const client = new ImapFlow({ host: s.host, port: s.port, secure: s.port !== 143, auth: { user: s.user, pass: s.password }, logger: false });
|
|
25
|
+
// An error after connecting (a dropped socket) must not crash the process.
|
|
26
|
+
client.on('error', () => { });
|
|
27
|
+
try {
|
|
28
|
+
await client.connect();
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
unreachable(e);
|
|
32
|
+
}
|
|
33
|
+
let lock;
|
|
34
|
+
try {
|
|
35
|
+
lock = await client.getMailboxLock(s.folder);
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
await client.logout().catch(() => { });
|
|
39
|
+
fail('inbox_unreachable', `There is no folder ${s.folder} on ${s.host} for ${s.user}: ${clean(e.message)}`, 'Set C11R_IMAP_FOLDER in .commercelayer/credentials to an existing folder, or leave it out for INBOX.');
|
|
40
|
+
}
|
|
41
|
+
const gmail = client.capabilities.has('X-GM-EXT-1');
|
|
42
|
+
const mailbox = () => (client.mailbox ? client.mailbox : undefined);
|
|
43
|
+
const envelopeOf = (m) => ({
|
|
44
|
+
id: String(m.uid),
|
|
45
|
+
message_id: m.envelope?.messageId ?? null,
|
|
46
|
+
from: who(m.envelope?.from),
|
|
47
|
+
subject: m.envelope?.subject ?? null,
|
|
48
|
+
date: m.envelope?.date ? new Date(m.envelope.date).toISOString() : null,
|
|
49
|
+
seen: m.flags?.has('\\Seen') ?? false,
|
|
50
|
+
labels: gmail ? [...(m.labels ?? [])] : [...(m.flags ?? [])].filter((f) => !f.startsWith('\\') && !f.startsWith('$')),
|
|
51
|
+
...(m.size ? { size: m.size } : {}),
|
|
52
|
+
});
|
|
53
|
+
const uidOf = (id) => {
|
|
54
|
+
if (!/^\d+$/.test(id))
|
|
55
|
+
fail('invalid_argument', `${id} is not a message id.`, 'Use the id inbox list prints (a number).');
|
|
56
|
+
return id;
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
host: s.host,
|
|
60
|
+
user: s.user,
|
|
61
|
+
folder: s.folder,
|
|
62
|
+
async check() {
|
|
63
|
+
const st = await client.status(s.folder, { messages: true, unseen: true });
|
|
64
|
+
return { exists: st ? (st.messages ?? 0) : 0, unseen: st ? (st.unseen ?? 0) : 0 };
|
|
65
|
+
},
|
|
66
|
+
async list({ unseen, since, limit }) {
|
|
67
|
+
const query = unseen ? { seen: false } : { all: true };
|
|
68
|
+
if (since)
|
|
69
|
+
query.since = since;
|
|
70
|
+
const found = await client.search(query, { uid: true });
|
|
71
|
+
const uids = (Array.isArray(found) ? found : []).sort((a, b) => b - a).slice(0, limit);
|
|
72
|
+
if (!uids.length)
|
|
73
|
+
return [];
|
|
74
|
+
const out = [];
|
|
75
|
+
for await (const m of client.fetch(uids.join(','), { uid: true, envelope: true, flags: true, size: true, ...(gmail ? { labels: true } : {}) }, { uid: true }))
|
|
76
|
+
out.push(envelopeOf(m));
|
|
77
|
+
return out.sort((a, b) => Number(b.id) - Number(a.id));
|
|
78
|
+
},
|
|
79
|
+
async fetch(id) {
|
|
80
|
+
const m = await client.fetchOne(uidOf(id), { uid: true, source: true, flags: true, ...(gmail ? { labels: true } : {}) }, { uid: true });
|
|
81
|
+
if (!m || !m.source)
|
|
82
|
+
return null;
|
|
83
|
+
return { raw: m.source, labels: envelopeOf(m).labels };
|
|
84
|
+
},
|
|
85
|
+
async tag(id, t) {
|
|
86
|
+
const uid = uidOf(id);
|
|
87
|
+
if (!(await client.fetchOne(uid, { uid: true }, { uid: true })))
|
|
88
|
+
fail('not_found', `There is no message ${id} in ${s.folder}.`, 'Run `c11r inbox list` for the ids.');
|
|
89
|
+
const result = { id, labels_as: null, labeled: [], unlabeled: [], seen: false };
|
|
90
|
+
const notes = [];
|
|
91
|
+
if (t.label.length || t.unlabel.length) {
|
|
92
|
+
const keywords = mailbox()?.permanentFlags?.has('\\*') ?? false;
|
|
93
|
+
if (gmail) {
|
|
94
|
+
result.labels_as = 'gmail_label';
|
|
95
|
+
if (t.label.length)
|
|
96
|
+
await client.messageFlagsAdd(uid, t.label, { uid: true, useLabels: true });
|
|
97
|
+
if (t.unlabel.length)
|
|
98
|
+
await client.messageFlagsRemove(uid, t.unlabel, { uid: true, useLabels: true });
|
|
99
|
+
}
|
|
100
|
+
else if (keywords) {
|
|
101
|
+
const bad = [...t.label, ...t.unlabel].find((l) => !keywordOk(l));
|
|
102
|
+
if (bad)
|
|
103
|
+
fail('invalid_argument', `${bad} cannot be a keyword on ${s.host}: no spaces, parentheses, quotes or backslashes.`, 'Use a one-word label (ordered, order-pending), or --move <folder>.');
|
|
104
|
+
result.labels_as = 'keyword';
|
|
105
|
+
if (t.label.length)
|
|
106
|
+
await client.messageFlagsAdd(uid, t.label, { uid: true });
|
|
107
|
+
if (t.unlabel.length)
|
|
108
|
+
await client.messageFlagsRemove(uid, t.unlabel, { uid: true });
|
|
109
|
+
}
|
|
110
|
+
else if (!t.move) {
|
|
111
|
+
fail('labels_unsupported', `${s.host} keeps no labels or keyword flags in ${s.folder}.`, 'Move the email to a folder instead: c11r inbox tag <id> --move <folder> [--seen].');
|
|
112
|
+
}
|
|
113
|
+
else
|
|
114
|
+
notes.push(`${s.host} keeps no labels or keywords: the email was moved to ${t.move} instead${t.unlabel.length ? ', and there was nothing to remove' : ''}.`);
|
|
115
|
+
if (result.labels_as) {
|
|
116
|
+
result.labeled = t.label;
|
|
117
|
+
result.unlabeled = t.unlabel;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (t.seen) {
|
|
121
|
+
await client.messageFlagsAdd(uid, ['\\Seen'], { uid: true });
|
|
122
|
+
result.seen = true;
|
|
123
|
+
}
|
|
124
|
+
// Last: once moved, the message has another id in another folder.
|
|
125
|
+
if (t.move) {
|
|
126
|
+
if (!(await client.messageMove(uid, t.move, { uid: true })))
|
|
127
|
+
fail('inbox_unreachable', `${s.host} did not move message ${id} to ${t.move}.`, 'Check that the folder exists (create it in the mail app), then try again.');
|
|
128
|
+
result.moved_to = t.move;
|
|
129
|
+
}
|
|
130
|
+
if (notes.length)
|
|
131
|
+
result.notes = notes;
|
|
132
|
+
return result;
|
|
133
|
+
},
|
|
134
|
+
async close() {
|
|
135
|
+
lock.release();
|
|
136
|
+
await client.logout().catch(() => { });
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The inbox the merchant receives orders in, over IMAP: what inbox check, list, pull and tag
|
|
2
|
+
// work on. The connection details are in .commercelayer/credentials (gitignored) or the
|
|
3
|
+
// environment; the password is never printed, not even in an error. C11R_IMAP_FAKE=<dir> reads
|
|
4
|
+
// a folder of .eml files instead (tests).
|
|
5
|
+
import { credential } from '../files.js';
|
|
6
|
+
import { fail } from '../output.js';
|
|
7
|
+
const CREDENTIALS_HINT = 'Ask the human to add them to .commercelayer/credentials (gitignored), one per line: C11R_IMAP_HOST=imap.gmail.com, C11R_IMAP_USER=<their address>, C11R_IMAP_PASSWORD=<an app password>, and optionally C11R_IMAP_PORT (993) and C11R_IMAP_FOLDER (INBOX). For Gmail: turn on 2-Step Verification, then create an app password at https://myaccount.google.com/apppasswords; never the account password. They write the password into the file themselves: never ask for it in the chat.';
|
|
8
|
+
export function imapSettings() {
|
|
9
|
+
const missing = ['C11R_IMAP_HOST', 'C11R_IMAP_USER', 'C11R_IMAP_PASSWORD'].filter((k) => !credential(k));
|
|
10
|
+
if (missing.length)
|
|
11
|
+
fail('missing_credentials', `The inbox is not set up: ${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} missing.`, CREDENTIALS_HINT);
|
|
12
|
+
const rawPort = credential('C11R_IMAP_PORT') ?? '993';
|
|
13
|
+
const port = Number(rawPort);
|
|
14
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
15
|
+
fail('invalid_credentials', `C11R_IMAP_PORT is ${rawPort}, not a port.`, 'Use 993 (IMAP over TLS), the usual one, or leave it out.');
|
|
16
|
+
return { host: credential('C11R_IMAP_HOST'), port, user: credential('C11R_IMAP_USER'), password: credential('C11R_IMAP_PASSWORD'), folder: credential('C11R_IMAP_FOLDER') ?? 'INBOX' };
|
|
17
|
+
}
|
|
18
|
+
export async function openMailbox() {
|
|
19
|
+
const fake = process.env.C11R_IMAP_FAKE;
|
|
20
|
+
if (fake)
|
|
21
|
+
return (await import('./fake.js')).openFake(fake, credential('C11R_IMAP_FOLDER') ?? 'INBOX');
|
|
22
|
+
return (await import('./imap.js')).openImap(imapSettings());
|
|
23
|
+
}
|
|
24
|
+
/** An IMAP keyword is one atom: no spaces, parentheses, quotes or backslashes. */
|
|
25
|
+
export const keywordOk = (name) => /^[^\s(){}%*"\\\]]+$/.test(name);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// One email as the agent reads it: parsed with postal-mime (loaded only here, on first use) and
|
|
2
|
+
// written to .commercelayer/inbox/<id>/: message.json (headers, text, the attachments' files),
|
|
3
|
+
// body.txt (the text, or the HTML as text) and attachments/ with each file as it came. The
|
|
4
|
+
// CLI does not read PDFs or sheets: the agent opens them with its own abilities.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { ignorePath, paths } from '../files.js';
|
|
8
|
+
export const MAX_ATTACHMENT = 25 * 1024 * 1024;
|
|
9
|
+
export const inboxDir = () => path.join(paths.dir, 'inbox');
|
|
10
|
+
const who = (a) => (a.group ? a.group.flatMap(who) : a.address ? [a.name ? `${a.name} <${a.address}>` : a.address] : []);
|
|
11
|
+
/** The HTML of a message as plain text, for one with no text part. */
|
|
12
|
+
export function htmlText(html) {
|
|
13
|
+
return html
|
|
14
|
+
.replace(/<(script|style|head)[^>]*>[\s\S]*?<\/\1>/gi, '')
|
|
15
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
16
|
+
.replace(/<\/(p|div|tr|li|h[1-6]|table)>/gi, '\n')
|
|
17
|
+
.replace(/<(td|th)[^>]*>/gi, '\t')
|
|
18
|
+
.replace(/<[^>]+>/g, '')
|
|
19
|
+
.replace(/ /g, ' ')
|
|
20
|
+
.replace(/</g, '<')
|
|
21
|
+
.replace(/>/g, '>')
|
|
22
|
+
.replace(/"/g, '"')
|
|
23
|
+
.replace(/'/g, "'")
|
|
24
|
+
.replace(/&/g, '&')
|
|
25
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
26
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
export async function parseMessage(raw) {
|
|
30
|
+
const { default: PostalMime } = await import('postal-mime');
|
|
31
|
+
const m = await PostalMime.parse(raw, { attachmentEncoding: 'arraybuffer' });
|
|
32
|
+
const text = m.text?.trim() ? m.text : m.html ? htmlText(m.html) : '';
|
|
33
|
+
return {
|
|
34
|
+
message_id: m.messageId ?? null,
|
|
35
|
+
from: m.from ? (who(m.from)[0] ?? null) : null,
|
|
36
|
+
to: (m.to ?? []).flatMap((a) => who(a)),
|
|
37
|
+
subject: m.subject ?? null,
|
|
38
|
+
date: m.date ?? null,
|
|
39
|
+
text,
|
|
40
|
+
...(m.html ? { html: m.html } : {}),
|
|
41
|
+
attachments: m.attachments.map((a) => ({
|
|
42
|
+
name: a.filename,
|
|
43
|
+
content_type: a.mimeType,
|
|
44
|
+
bytes: typeof a.content === 'string' ? new TextEncoder().encode(a.content) : new Uint8Array(a.content),
|
|
45
|
+
inline: a.related === true || (a.disposition === 'inline' && !!a.contentId && a.mimeType.startsWith('image/')),
|
|
46
|
+
})),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const EXT = { 'application/pdf': 'pdf', 'text/plain': 'txt', 'text/csv': 'csv', 'image/png': 'png', 'image/jpeg': 'jpg', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx', 'application/vnd.ms-excel': 'xls', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', 'application/msword': 'doc' };
|
|
50
|
+
/** A file name safe on every system: no folders, no control characters, never empty. */
|
|
51
|
+
export function safeName(name, i, type) {
|
|
52
|
+
// Slashes become underscores: "PO 2026/114.pdf" keeps its whole name and stays in its folder.
|
|
53
|
+
const base = (name ?? '').replace(/[/\\\u0000-\u001f<>:"|?*]+/g, '_').replace(/^[._]+/, '').trim().slice(0, 120);
|
|
54
|
+
return base || `attachment-${i + 1}${EXT[type] ? `.${EXT[type]}` : ''}`;
|
|
55
|
+
}
|
|
56
|
+
/** Write a parsed message under .commercelayer/inbox/<id>/; the folder is gitignored on first use. */
|
|
57
|
+
export function writeMessage(id, m, labels) {
|
|
58
|
+
const root = inboxDir();
|
|
59
|
+
if (!fs.existsSync(root))
|
|
60
|
+
ignorePath(`${root}/`);
|
|
61
|
+
const dir = path.join(root, id.replace(/[^A-Za-z0-9._-]+/g, '_'));
|
|
62
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
63
|
+
const warnings = [];
|
|
64
|
+
const attachments = [];
|
|
65
|
+
const used = new Set();
|
|
66
|
+
m.attachments.forEach((a, i) => {
|
|
67
|
+
if (a.inline)
|
|
68
|
+
return;
|
|
69
|
+
if (a.bytes.length > MAX_ATTACHMENT) {
|
|
70
|
+
warnings.push(`${a.name ?? `attachment ${i + 1}`} is over 25 MB and was not saved: open the email in the mail app.`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
let name = safeName(a.name, i, a.content_type);
|
|
74
|
+
for (let n = 2; used.has(name.toLowerCase()); n++)
|
|
75
|
+
name = name.replace(/(?: \(\d+\))?(\.[^.]*)?$/, ` (${n})$1`);
|
|
76
|
+
used.add(name.toLowerCase());
|
|
77
|
+
fs.mkdirSync(path.join(dir, 'attachments'), { recursive: true });
|
|
78
|
+
const file = path.join(dir, 'attachments', name);
|
|
79
|
+
fs.writeFileSync(file, a.bytes);
|
|
80
|
+
attachments.push({ file, name: a.name, content_type: a.content_type, size: a.bytes.length });
|
|
81
|
+
});
|
|
82
|
+
const body = path.join(dir, 'body.txt');
|
|
83
|
+
fs.writeFileSync(body, m.text.endsWith('\n') ? m.text : `${m.text}\n`);
|
|
84
|
+
const message = path.join(dir, 'message.json');
|
|
85
|
+
const json = { id, message_id: m.message_id, from: m.from, to: m.to, subject: m.subject, date: m.date, labels, text: m.text, ...(m.html ? { html: m.html } : {}), attachments: attachments.map(({ file, name, content_type, size }) => ({ file, name, content_type, size })) };
|
|
86
|
+
fs.writeFileSync(message, JSON.stringify(json, null, 2) + '\n');
|
|
87
|
+
return { id, dir, message, body, subject: m.subject, from: m.from, message_id: m.message_id, attachments, ...(warnings.length ? { warnings } : {}) };
|
|
88
|
+
}
|
package/dist/ops.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// The merchant agent running the store on a schedule: what is new since the last run, what the
|
|
2
|
+
// policy approves and captures, what it holds for the human, what is stuck. The policy is the
|
|
3
|
+
// human's, read here and never sent to the API; the cursor (.commercelayer/ops.json) keeps a
|
|
4
|
+
// run from reporting the same order twice. Shipping and cancelling stay with the human.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import YAML from 'yaml';
|
|
8
|
+
import { jsonCursor, MAX_SEEN, strings } from './cursor.js';
|
|
9
|
+
import { paths } from './files.js';
|
|
10
|
+
import { fail } from './output.js';
|
|
11
|
+
import { minorUnits } from './ucp/platform.js';
|
|
12
|
+
// ---------- the policy
|
|
13
|
+
/**
|
|
14
|
+
* Where the policy is read from: .commercelayer/ops.yml, the whole file (key null). Committed
|
|
15
|
+
* with the store and agreed with the human; never sent to the API.
|
|
16
|
+
*/
|
|
17
|
+
export const POLICY_SOURCE = { file: 'ops.yml', key: null };
|
|
18
|
+
export const DEFAULT_POLICY = { auto_approve: false, auto_capture: false, review_above: null, hold_countries: [], stuck_days: 2, transit_days: 7, mail_orders: { auto_place: false, labels: { pending: 'pending', ordered: 'ordered', declined: 'declined' } } };
|
|
19
|
+
const POLICY_KEYS = Object.keys(DEFAULT_POLICY);
|
|
20
|
+
export const policyPath = () => path.join(paths.dir, POLICY_SOURCE.file);
|
|
21
|
+
/** The policy, the defaults where it says nothing; invalid_policy with the fix when it is wrong. */
|
|
22
|
+
export function loadPolicy() {
|
|
23
|
+
const file = policyPath();
|
|
24
|
+
if (!fs.existsSync(file))
|
|
25
|
+
return { ...DEFAULT_POLICY, source: null };
|
|
26
|
+
let doc;
|
|
27
|
+
try {
|
|
28
|
+
doc = YAML.parse(fs.readFileSync(file, 'utf8'));
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
fail('invalid_policy', `${file} is not valid YAML: ${e.message.split('\n')[0]}`, 'Fix the YAML, then run the command again.');
|
|
32
|
+
}
|
|
33
|
+
const raw = POLICY_SOURCE.key ? doc?.[POLICY_SOURCE.key] : doc;
|
|
34
|
+
const where = POLICY_SOURCE.key ? `${POLICY_SOURCE.key} in ${file}` : file;
|
|
35
|
+
return { ...checkPolicy(raw, where), source: file };
|
|
36
|
+
}
|
|
37
|
+
export function checkPolicy(raw, where) {
|
|
38
|
+
if (raw === undefined || raw === null)
|
|
39
|
+
return { ...DEFAULT_POLICY };
|
|
40
|
+
const bad = (what, hint) => fail('invalid_policy', `${where}: ${what}`, hint);
|
|
41
|
+
if (typeof raw !== 'object' || Array.isArray(raw))
|
|
42
|
+
bad('the policy is not a set of settings.', `Write it as settings, one per line: auto_approve: true, review_above: 500, … (${POLICY_KEYS.join(', ')}).`);
|
|
43
|
+
const p = raw;
|
|
44
|
+
const unknown = Object.keys(p).filter((k) => !POLICY_KEYS.includes(k));
|
|
45
|
+
if (unknown.length)
|
|
46
|
+
bad(`${unknown.join(', ')} ${unknown.length === 1 ? 'is not a setting' : 'are not settings'} of the policy.`, `The settings are ${POLICY_KEYS.join(', ')}.`);
|
|
47
|
+
const out = { ...DEFAULT_POLICY, mail_orders: { auto_place: false, labels: { ...DEFAULT_POLICY.mail_orders.labels } } };
|
|
48
|
+
for (const k of ['auto_approve', 'auto_capture']) {
|
|
49
|
+
if (p[k] === undefined)
|
|
50
|
+
continue;
|
|
51
|
+
if (typeof p[k] !== 'boolean')
|
|
52
|
+
bad(`${k} is ${JSON.stringify(p[k])}, not true or false.`, `Write ${k}: true or ${k}: false.`);
|
|
53
|
+
out[k] = p[k];
|
|
54
|
+
}
|
|
55
|
+
if (p.review_above !== undefined && p.review_above !== null) {
|
|
56
|
+
if (typeof p.review_above !== 'number' || !Number.isFinite(p.review_above) || p.review_above < 0)
|
|
57
|
+
bad(`review_above is ${JSON.stringify(p.review_above)}, not an amount.`, 'Give it in major units of the order\'s currency, for example review_above: 500, or leave it out to hold none for their total.');
|
|
58
|
+
out.review_above = p.review_above;
|
|
59
|
+
}
|
|
60
|
+
if (p.hold_countries !== undefined) {
|
|
61
|
+
const list = p.hold_countries;
|
|
62
|
+
if (!Array.isArray(list) || list.some((c) => typeof c !== 'string' || !/^[A-Za-z]{2}$/.test(c)))
|
|
63
|
+
bad(`hold_countries is ${JSON.stringify(list)}, not a list of country codes.`, 'Write ISO 3166 codes: hold_countries: [US, CA].');
|
|
64
|
+
out.hold_countries = list.map((c) => c.toUpperCase());
|
|
65
|
+
}
|
|
66
|
+
for (const k of ['stuck_days', 'transit_days']) {
|
|
67
|
+
if (p[k] === undefined)
|
|
68
|
+
continue;
|
|
69
|
+
const n = p[k];
|
|
70
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0)
|
|
71
|
+
bad(`${k} is ${JSON.stringify(n)}, not a number of days.`, `Write a number of days above 0, for example ${k}: ${DEFAULT_POLICY[k]}.`);
|
|
72
|
+
out[k] = n;
|
|
73
|
+
}
|
|
74
|
+
if (p.mail_orders !== undefined && p.mail_orders !== null) {
|
|
75
|
+
const m = p.mail_orders;
|
|
76
|
+
const fix = 'Write it as mail_orders: { auto_place: false, labels: { pending: pending, ordered: ordered, declined: declined } }; every setting is optional.';
|
|
77
|
+
if (typeof m !== 'object' || Array.isArray(m))
|
|
78
|
+
bad(`mail_orders is ${JSON.stringify(m)}, not a set of settings.`, fix);
|
|
79
|
+
const extra = Object.keys(m).filter((k) => k !== 'auto_place' && k !== 'labels');
|
|
80
|
+
if (extra.length)
|
|
81
|
+
bad(`mail_orders.${extra[0]} is not a setting: mail_orders has auto_place and labels.`, fix);
|
|
82
|
+
if (m.auto_place !== undefined && typeof m.auto_place !== 'boolean')
|
|
83
|
+
bad(`mail_orders.auto_place is ${JSON.stringify(m.auto_place)}, not true or false.`, fix);
|
|
84
|
+
out.mail_orders.auto_place = m.auto_place === true;
|
|
85
|
+
if (m.labels !== undefined && m.labels !== null) {
|
|
86
|
+
const l = m.labels;
|
|
87
|
+
if (typeof l !== 'object' || Array.isArray(l))
|
|
88
|
+
bad(`mail_orders.labels is ${JSON.stringify(l)}, not a set of names.`, fix);
|
|
89
|
+
const names = Object.keys(DEFAULT_POLICY.mail_orders.labels);
|
|
90
|
+
const other = Object.keys(l).filter((k) => !names.includes(k));
|
|
91
|
+
if (other.length)
|
|
92
|
+
bad(`mail_orders.labels.${other[0]} is not a label: the labels are ${names.join(', ')}.`, fix);
|
|
93
|
+
for (const k of names) {
|
|
94
|
+
const v = l[k];
|
|
95
|
+
if (v === undefined)
|
|
96
|
+
continue;
|
|
97
|
+
if (typeof v !== 'string' || !v.trim() || v.length > 50 || v.startsWith('\\'))
|
|
98
|
+
bad(`mail_orders.labels.${k} is ${JSON.stringify(v)}, not a label name.`, 'A label is a name of 1 to 50 characters, not starting with a backslash (\\Seen and the like are IMAP flags).');
|
|
99
|
+
out.mail_orders.labels[k] = v;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
export const cursorPath = () => path.join(paths.dir, 'ops.json');
|
|
106
|
+
// Machine state, rewritten on every run: kept out of git like the credentials.
|
|
107
|
+
const cursor = () => jsonCursor(cursorPath(), (c) => ({ last_run: typeof c?.last_run === 'string' ? c.last_run : null, seen: strings(c?.seen) }));
|
|
108
|
+
export const readCursor = () => cursor().read();
|
|
109
|
+
export const writeCursor = (c) => cursor().write({ last_run: c.last_run, seen: c.seen.slice(0, MAX_SEEN) });
|
|
110
|
+
// ---------- time
|
|
111
|
+
const UNITS = { h: 3600_000, d: 86_400_000, w: 7 * 86_400_000 };
|
|
112
|
+
/** "24h", "7d", "2w" or an ISO date or time: the instant it means, relative to now. */
|
|
113
|
+
export function parseSince(raw, flag, now = Date.now()) {
|
|
114
|
+
const rel = raw.trim().match(/^(\d+(?:\.\d+)?)\s*([hdw])$/i);
|
|
115
|
+
if (rel)
|
|
116
|
+
return new Date(now - Number(rel[1]) * UNITS[rel[2].toLowerCase()]);
|
|
117
|
+
const t = Date.parse(raw);
|
|
118
|
+
if (!/^\d{4}-\d{2}-\d{2}/.test(raw.trim()) || Number.isNaN(t))
|
|
119
|
+
fail('invalid_argument', `--${flag} ${raw} is not a time.`, `Use a span back from now (24h, 7d, 30d) or an ISO date (2026-11-01, 2026-11-01T09:00:00Z).`);
|
|
120
|
+
return new Date(t);
|
|
121
|
+
}
|
|
122
|
+
const DAY = 86_400_000;
|
|
123
|
+
const days = (ms) => Math.round((ms / DAY) * 10) / 10;
|
|
124
|
+
const brief = (o) => ({ id: o.id, number: o.number, placed_at: o.placed_at, country: o.country, channel: o.channel, email: o.email, items: o.items, total: o.total, payment_status: o.payment_status });
|
|
125
|
+
/** Why the policy holds an order for the human, if it does. */
|
|
126
|
+
export function holdReason(o, p) {
|
|
127
|
+
const reasons = [];
|
|
128
|
+
if (p.review_above !== null && o.total.major > p.review_above)
|
|
129
|
+
reasons.push(`its total ${o.total.display} is above ${o.currency} ${p.review_above.toFixed(2)} (review_above)`);
|
|
130
|
+
if (o.country && p.hold_countries.includes(o.country.toUpperCase()))
|
|
131
|
+
reasons.push(`it ships to ${o.country.toUpperCase()} (hold_countries)`);
|
|
132
|
+
return reasons.length ? reasons.join(' and ') : undefined;
|
|
133
|
+
}
|
|
134
|
+
/** What the policy does with a placed order. */
|
|
135
|
+
export function decidePlaced(o, p) {
|
|
136
|
+
const held = holdReason(o, p);
|
|
137
|
+
if (held)
|
|
138
|
+
return { order: brief(o), action: 'hold', reason: held };
|
|
139
|
+
if (!p.auto_approve)
|
|
140
|
+
return { order: brief(o), action: 'wait', reason: 'auto_approve is off: the human approves orders' };
|
|
141
|
+
if (p.auto_capture && o.payment_status === 'authorized')
|
|
142
|
+
return { order: brief(o), action: 'approve_capture' };
|
|
143
|
+
return { order: brief(o), action: 'approve' };
|
|
144
|
+
}
|
|
145
|
+
/** What the policy does with an approved order whose payment is only authorized. */
|
|
146
|
+
export function decideApproved(o, p) {
|
|
147
|
+
if (!p.auto_capture)
|
|
148
|
+
return { order: brief(o), action: 'wait', reason: 'auto_capture is off: the human says when to take the payment' };
|
|
149
|
+
return { order: brief(o), action: 'capture' };
|
|
150
|
+
}
|
|
151
|
+
/** Picking, packing or ready to ship for too long is stuck; on hold is the merchant's choice, never stuck. */
|
|
152
|
+
const WORKING = ['picking', 'packing', 'ready_to_ship'];
|
|
153
|
+
/** A stuck shipment as ops prints it, from its row and its age in days. */
|
|
154
|
+
export function stuckView(s, age, p) {
|
|
155
|
+
const since = s.status_changed_at ?? s.dates?.[`${s.status}_at`] ?? '';
|
|
156
|
+
const reason = s.status === 'shipped' ? `shipped ${age} days ago and not delivered (transit_days ${p.transit_days})` : `${s.status.replace(/_/g, ' ')} for ${age} days (stuck_days ${p.stuck_days})`;
|
|
157
|
+
return { shipment: { id: s.id, number: s.number, status: s.status, order: s.order, country: s.country, tracking: s.tracking }, days: age, since, reason };
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The same as the API's snapshot, for an API without it: shipments picking, packing or ready to
|
|
161
|
+
* ship longer than stuck_days, or shipped longer than transit_days, from when they entered it.
|
|
162
|
+
*/
|
|
163
|
+
export function stuckShipments(list, p, now = Date.now()) {
|
|
164
|
+
const out = [];
|
|
165
|
+
for (const s of list) {
|
|
166
|
+
const d = (s.dates ?? {});
|
|
167
|
+
const entered = Date.parse(s.status_changed_at ?? d[`${s.status}_at`] ?? '');
|
|
168
|
+
if (Number.isNaN(entered))
|
|
169
|
+
continue;
|
|
170
|
+
const age = days(now - entered);
|
|
171
|
+
if ((WORKING.includes(s.status) && now - entered > p.stuck_days * DAY) || (s.status === 'shipped' && now - entered > p.transit_days * DAY))
|
|
172
|
+
out.push(stuckView(s, age, p));
|
|
173
|
+
}
|
|
174
|
+
return out.sort((a, b) => b.days - a.days);
|
|
175
|
+
}
|
|
176
|
+
// ---------- words
|
|
177
|
+
/** "EUR 20.00 + USD 12.00": money in several currencies, summed per currency. */
|
|
178
|
+
export function totalsByCurrency(list) {
|
|
179
|
+
const by = new Map();
|
|
180
|
+
for (const m of list)
|
|
181
|
+
by.set(m.currency, (by.get(m.currency) ?? 0) + m.amount);
|
|
182
|
+
return [...by].map(([currency, amount]) => {
|
|
183
|
+
const unit = minorUnits(currency);
|
|
184
|
+
const major = amount / unit;
|
|
185
|
+
return { amount, currency, major, display: `${currency} ${major.toFixed(Math.round(Math.log10(unit)))}` };
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
export const moneyText = (list) => (list.length ? list.map((m) => m.display).join(' + ') : '0');
|
|
189
|
+
export const numbers = (list) => list.map((d) => `#${d.order.number}`).join(', ');
|
|
190
|
+
export const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|