@commercelayer/agent-cli 0.8.1 → 1.0.0-dev.1
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 +98 -215
- package/dist/argv.js +227 -0
- package/dist/bin.js +15 -178
- package/dist/builtins/auth.js +169 -0
- package/dist/builtins/extensions.js +107 -0
- package/dist/builtins/index.js +8 -0
- package/dist/builtins/loops.js +215 -0
- package/dist/builtins/sandbox.js +186 -0
- package/dist/config.js +238 -0
- package/dist/confirm.js +56 -0
- package/dist/engine.js +205 -0
- package/dist/errors.js +146 -0
- package/dist/help.js +81 -0
- package/dist/http.js +117 -0
- package/dist/inputs.js +112 -0
- package/dist/login.js +136 -0
- package/dist/resources.js +42 -0
- package/dist/schema.js +182 -0
- package/dist/schemas.js +72 -0
- package/dist/secrets.js +204 -0
- package/dist/version.js +4 -8
- package/package.json +4 -9
- package/dist/api/client.js +0 -190
- package/dist/api/server.js +0 -26
- package/dist/api/stub/db.js +0 -37
- package/dist/api/stub/http.js +0 -18
- package/dist/api/stub/index.js +0 -52
- package/dist/api/stub/locale.js +0 -52
- package/dist/api/stub/management.js +0 -409
- package/dist/api/stub/messages.js +0 -126
- package/dist/api/stub/orders.js +0 -625
- package/dist/api/stub/search.js +0 -78
- package/dist/api/stub/shopping.js +0 -673
- package/dist/api/stub/theme.js +0 -197
- package/dist/api/stub/ucp.js +0 -377
- package/dist/api/transport.js +0 -57
- package/dist/api/types.js +0 -4
- package/dist/assets.js +0 -100
- package/dist/catalog.js +0 -372
- package/dist/check.js +0 -187
- package/dist/commands.js +0 -1241
- package/dist/cursor.js +0 -31
- package/dist/files.js +0 -98
- package/dist/inbox/cursor.js +0 -11
- package/dist/inbox/fake.js +0 -95
- package/dist/inbox/imap.js +0 -139
- package/dist/inbox/mailbox.js +0 -25
- package/dist/inbox/message.js +0 -88
- package/dist/inspect.js +0 -237
- package/dist/languages.js +0 -49
- package/dist/links.js +0 -118
- package/dist/locales.js +0 -43
- package/dist/ops.js +0 -190
- package/dist/orderRequest.js +0 -141
- package/dist/output.js +0 -39
- package/dist/products.js +0 -151
- package/dist/promotions.js +0 -291
- package/dist/store.js +0 -379
- package/dist/storefront.js +0 -285
- package/dist/ucp/buy.js +0 -90
- package/dist/ucp/check.js +0 -362
- package/dist/ucp/client.js +0 -129
- package/dist/ucp/images.js +0 -62
- package/dist/ucp/platform.js +0 -90
- package/dist/ucp/shop.js +0 -357
- package/dist/ucp/signing.js +0 -67
- package/dist/web.js +0 -316
- package/dist/website.js +0 -390
package/dist/cursor.js
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
// The files the CLI keeps in the repo: .commercelayer/store.yml and the product
|
|
2
|
-
// files under .commercelayer/products/ (committed), and .commercelayer/credentials
|
|
3
|
-
// (gitignored: the sandbox token, and the keys of a catalog source).
|
|
4
|
-
import fs from 'node:fs';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import { fail } from './output.js';
|
|
7
|
-
const dir = '.commercelayer';
|
|
8
|
-
export const paths = {
|
|
9
|
-
dir,
|
|
10
|
-
store: path.join(dir, 'store.yml'),
|
|
11
|
-
products: path.join(dir, 'products'),
|
|
12
|
-
credentials: path.join(dir, 'credentials'),
|
|
13
|
-
};
|
|
14
|
-
/** Every KEY=value line of the credentials file; quotes around a value are dropped. */
|
|
15
|
-
function credentialVars() {
|
|
16
|
-
const vars = {};
|
|
17
|
-
if (!fs.existsSync(paths.credentials))
|
|
18
|
-
return vars;
|
|
19
|
-
for (const line of fs.readFileSync(paths.credentials, 'utf8').split('\n')) {
|
|
20
|
-
const m = line.trim().match(/^([A-Z0-9_]+)=(.*)$/);
|
|
21
|
-
if (m)
|
|
22
|
-
vars[m[1]] = m[2].trim().replace(/^(["'])(.*)\1$/, '$2');
|
|
23
|
-
}
|
|
24
|
-
return vars;
|
|
25
|
-
}
|
|
26
|
-
/** A secret the CLI needs besides the store's own token: the environment wins over the credentials file. */
|
|
27
|
-
export function credential(key) {
|
|
28
|
-
return process.env[key] || credentialVars()[key] || undefined;
|
|
29
|
-
}
|
|
30
|
-
export function readCredentials() {
|
|
31
|
-
if (!fs.existsSync(paths.credentials))
|
|
32
|
-
return null;
|
|
33
|
-
const vars = credentialVars();
|
|
34
|
-
if (!vars.C11R_ORG || !vars.C11R_TOKEN)
|
|
35
|
-
return null;
|
|
36
|
-
return { org: vars.C11R_ORG, token: vars.C11R_TOKEN };
|
|
37
|
-
}
|
|
38
|
-
export function writeCredentials(c) {
|
|
39
|
-
fs.mkdirSync(paths.dir, { recursive: true });
|
|
40
|
-
// Keep any other keys already in the file (C11R_SUPABASE_*, ...).
|
|
41
|
-
const others = Object.entries(credentialVars())
|
|
42
|
-
.filter(([k]) => k !== 'C11R_ORG' && k !== 'C11R_TOKEN')
|
|
43
|
-
.map(([k, v]) => `${k}=${v}\n`)
|
|
44
|
-
.join('');
|
|
45
|
-
fs.writeFileSync(paths.credentials, `C11R_ORG=${c.org}\nC11R_TOKEN=${c.token}\n${others}`, { mode: 0o600 });
|
|
46
|
-
}
|
|
47
|
-
/** The store and its credentials, or a failure telling the agent to run init. */
|
|
48
|
-
export function requireCredentials() {
|
|
49
|
-
const c = readCredentials();
|
|
50
|
-
if (!c || !fs.existsSync(paths.store)) {
|
|
51
|
-
fail('no_store', 'There is no store in this repo.', 'Run `c11r init` in the repo root first.');
|
|
52
|
-
}
|
|
53
|
-
return c;
|
|
54
|
-
}
|
|
55
|
-
/** Make sure .gitignore lists the credentials file. Returns true if it changed. */
|
|
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) {
|
|
59
|
-
const existing = fs.existsSync('.gitignore') ? fs.readFileSync('.gitignore', 'utf8') : '';
|
|
60
|
-
if (existing.split('\n').includes(file))
|
|
61
|
-
return false;
|
|
62
|
-
const sep = existing && !existing.endsWith('\n') ? '\n' : '';
|
|
63
|
-
fs.appendFileSync('.gitignore', `${sep}${file}\n`);
|
|
64
|
-
return true;
|
|
65
|
-
}
|
|
66
|
-
/** The note that sends a future agent in this repo to the skills. No skill names: it never goes stale. */
|
|
67
|
-
const AGENT_NOTE_HEADING = '# Store on Commerce Layer';
|
|
68
|
-
const SKILLS = 'Before any task about the store (products, prices, markets, storefront, orders, promotions, agents), fetch `https://commercelayer.ai/skills.md` with `curl -fsSL` and load the skill for the task from `skills/<name>.md`; follow it rather than calling the Commerce Layer API directly. If `.commercelayer/store.yml` is missing, start from `https://commercelayer.ai/start.md`.';
|
|
69
|
-
export const AGENT_NOTE = `${AGENT_NOTE_HEADING}
|
|
70
|
-
This store is managed with \`c11r\`, run as \`npx -y @commercelayer/agent-cli@latest\`, configured in \`.commercelayer/store.yml\`. Always use that form, even if a \`c11r\` binary is installed: it stays on the current version. ${SKILLS}
|
|
71
|
-
`;
|
|
72
|
-
/** The note CLIs before 0.5.1 wrote: replaced by the current one when a file has it word for word. */
|
|
73
|
-
const OLD_AGENT_NOTE = `${AGENT_NOTE_HEADING}
|
|
74
|
-
This store is managed with \`c11r\` (\`npx @commercelayer/agent-cli\`), configured in \`.commercelayer/store.yml\`. ${SKILLS}
|
|
75
|
-
`;
|
|
76
|
-
/**
|
|
77
|
-
* Write the note to AGENTS.md and CLAUDE.md (Claude Code reads that name) in the repo root:
|
|
78
|
-
* created when absent, appended as a section when the file has other content, left alone
|
|
79
|
-
* when the section is already there (replaced when it is an older CLI's, word for word).
|
|
80
|
-
* Returns the files written.
|
|
81
|
-
*/
|
|
82
|
-
export function writeAgentNotes() {
|
|
83
|
-
const written = [];
|
|
84
|
-
for (const file of ['AGENTS.md', 'CLAUDE.md']) {
|
|
85
|
-
const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
86
|
-
// A note an older CLI wrote, untouched since: bring it up to date. An edited one stays as it is.
|
|
87
|
-
if (before.includes(OLD_AGENT_NOTE)) {
|
|
88
|
-
fs.writeFileSync(file, before.replace(OLD_AGENT_NOTE, AGENT_NOTE));
|
|
89
|
-
written.push(file);
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
if (before.split('\n').some((line) => line.trim() === AGENT_NOTE_HEADING))
|
|
93
|
-
continue;
|
|
94
|
-
fs.writeFileSync(file, before.trim() ? `${before.replace(/\s*$/, '')}\n\n${AGENT_NOTE}` : AGENT_NOTE);
|
|
95
|
-
written.push(file);
|
|
96
|
-
}
|
|
97
|
-
return written;
|
|
98
|
-
}
|
package/dist/inbox/cursor.js
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
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}`;
|
package/dist/inbox/fake.js
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
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
|
-
}
|
package/dist/inbox/imap.js
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
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
|
-
}
|
package/dist/inbox/mailbox.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
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);
|
package/dist/inbox/message.js
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
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
|
-
}
|