@commercelayer/agent-cli 1.0.0-dev.8 → 1.0.0-dev.9

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 CHANGED
@@ -74,6 +74,14 @@ Destructive verbs (deletes, `orders cancel`, `payments void` and `refund`, `sand
74
74
  - `.c11r.json` holds only `{ "org": "<slug>" }`: no secret, safe to commit. `sandbox create`, `organizations create` and `auth switch` write it, and `organizations connect` when the folder has no link; `reset` follows the new slug; `destroy` removes it when it names the destroyed organization.
75
75
  - The project folder is the one with `.c11r.json`, else the nearest with `store.yml` or `loops.yml`, else the current one. `loops.yml` goes there.
76
76
 
77
+ ## The store's setup in files
78
+
79
+ `c11r config export` writes the organization's setup into the project folder: `store.yml` (the Configure nouns, refs by key, no ids, no secrets; its first key, `meta`, names the organization and the time) and `price_lists/<code>.csv`. It records what it wrote in `.c11r/export.json`, and never overwrites a file changed since the last export (or one no export wrote) without `--yes`: the answer lists them. A price list file the organization no longer has, unchanged since, goes. `c11r config plan` sends the files and answers what `config apply` would change; `apply` makes the organization match them, and with `--prune` (confirmed) also deletes what the files lack. While imports run, `apply` answers `status: running` with each noun's state and `retry_after`; `--wait` applies again until done or failed.
80
+
81
+ After a Configure write that went through (create, update, delete or a domain verb), in a folder whose `store.yml` names the same organization, the answer warns `config_drift` and `.c11r/drift` notes the write; `config plan` repeats the warning, and `config export` clears it.
82
+
83
+ `c11r exports get <id> --out <file>` downloads a completed export while its link lasts (5 minutes), unzipped unless the name ends in `.gz`; `--wait` reads the job again until it completes.
84
+
77
85
  ## Output, errors and exit codes
78
86
 
79
87
  Every answer is one JSON object on stdout, the API's envelope:
@@ -8,10 +8,14 @@
8
8
  // sales-calls book answers a cancel token once (section 20): stored the same way, by booking id,
9
9
  // and sent by sales-calls cancel <booking> without the agent ever holding it.
10
10
  import crypto from 'node:crypto';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import zlib from 'node:zlib';
11
14
  import { credentialsFile, hintOf, readLink, storeBooking, storedBooking, storeOrg, writeLink } from '../config.js';
12
15
  import { confirm, terminal } from '../confirm.js';
13
16
  import { fail } from '../errors.js';
14
17
  import { apiUrl } from '../http.js';
18
+ import { clearDrift, driftWarning, readDrift, readStoreFiles, storeFileOrg, writeStoreFiles } from '../project.js';
15
19
  import { resourceRequest } from '../resources.js';
16
20
  const NAME_MAX = 25;
17
21
  export const organizations = {
@@ -104,4 +108,137 @@ export const salesCalls = {
104
108
  return undefined;
105
109
  },
106
110
  };
107
- export const extensions = { organizations, 'sales-calls': salesCalls };
111
+ // config (contract section 45.2): the API answers and applies documents; the files are the CLI's.
112
+ // export writes store.yml and price_lists/<code>.csv, never over a file changed since the last
113
+ // export without --yes, and clears the drift note.
114
+ // plan sends the folder's files and says what apply would change, and since when the
115
+ // folder is behind when Configure writes were made here.
116
+ // apply sends them; while imports run (202, running) it shows each noun's state, and with
117
+ // --wait applies again until done or failed. prune is confirmed by the schema.
118
+ const WAIT_MAX_MS = 15 * 60_000;
119
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
120
+ const waitMs = (data) => {
121
+ const s = Number(data?.retry_after);
122
+ const test = process.env.C11R_WAIT_MS;
123
+ if (test !== undefined)
124
+ return Number(test);
125
+ return (Number.isFinite(s) && s > 0 ? Math.min(s, 60) : 5) * 1000;
126
+ };
127
+ export const config = {
128
+ flags: {
129
+ apply: { wait: { type: 'boolean', to: 'local', description: 'While imports run, apply again every retry_after seconds until done or failed (15 minutes at most).' } },
130
+ },
131
+ prepare(verb, parsed) {
132
+ if (verb.name !== 'plan' && verb.name !== 'apply')
133
+ return;
134
+ if (parsed.values.store !== undefined)
135
+ return;
136
+ const files = readStoreFiles();
137
+ parsed.values.store = files.store;
138
+ parsed.values.price_lists = files.price_lists;
139
+ },
140
+ async run(run) {
141
+ const name = run.verb.name;
142
+ if (name === 'export') {
143
+ const r = await run.send(resourceRequest('config', run.verb, undefined, run.parsed.values));
144
+ if (run.globals.dryRun)
145
+ return r;
146
+ const data = r.data ?? {};
147
+ if (!data.store || typeof data.store !== 'object')
148
+ fail('invalid_response', 'config export answered without store.', { next_step: { action: 'none' } });
149
+ const out = writeStoreFiles({ store: data.store, price_lists: data.price_lists ?? {}, meta: { org: data.meta?.org ?? run.org.org ?? undefined, exported_at: data.meta?.exported_at } }, { overwrite: run.globals.yes });
150
+ const counts = Object.fromEntries(Object.entries(data.store).map(([k, v]) => [k, Array.isArray(v) ? v.length : 0]));
151
+ return {
152
+ ...r,
153
+ data: { org: data.meta?.org ?? run.org.org, exported_at: data.meta?.exported_at ?? null, folder: out.dir, files: out.written, removed: out.removed, records: counts, price_lists: Object.keys(data.price_lists ?? {}) },
154
+ next: [{ command: 'c11r config plan', why: 'what the files would change in an organization, after you edit them or for another one (--org)' }],
155
+ };
156
+ }
157
+ if (name === 'plan' || name === 'apply') {
158
+ const req = resourceRequest('config', run.verb, undefined, run.parsed.values);
159
+ let r = await run.send(req);
160
+ const started = Date.now();
161
+ while (name === 'apply' && run.parsed.values.wait && r.data?.status === 'running' && Date.now() - started < WAIT_MAX_MS) {
162
+ const ms = waitMs(r.data);
163
+ process.stderr.write(JSON.stringify({ running: { nouns: states(r.data), next_in_ms: ms } }) + '\n');
164
+ await sleep(ms);
165
+ r = await run.send(req);
166
+ }
167
+ const warnings = [...r.warnings];
168
+ const here = storeFileOrg();
169
+ const drift = readDrift();
170
+ if (name === 'plan' && drift.length && here === run.org.org)
171
+ warnings.push(driftWarning(drift));
172
+ // The organization now matches the files: the folder is no longer behind.
173
+ if (name === 'apply' && !run.globals.dryRun && r.data?.status === 'done' && here === run.org.org)
174
+ clearDrift();
175
+ const data = r.data ?? {};
176
+ const running = name === 'apply' && data.status === 'running';
177
+ return {
178
+ ...r,
179
+ data: running ? { ...data, retry_after: waitMs(data) / 1000, states: states(data) } : data,
180
+ warnings,
181
+ next: running && !run.parsed.values.wait ? [{ command: `${run.line} --wait`, why: 'carry on, and wait until every noun is done or failed' }, ...r.next] : r.next,
182
+ };
183
+ }
184
+ return undefined;
185
+ },
186
+ };
187
+ /** Each noun's state while apply runs: done, running (with its import), waiting, failed or unchanged. */
188
+ function states(data) {
189
+ const out = {};
190
+ for (const n of Array.isArray(data?.nouns) ? data.nouns : [])
191
+ out[n.noun] = n.import?.id && n.status === 'running' ? `running (import ${n.import.id})` : String(n.status);
192
+ return out;
193
+ }
194
+ // exports (45.3): get --out downloads the file while its link is good (5 minutes), unzipped unless
195
+ // the name ends in .gz; --wait polls a job not completed yet.
196
+ export const exports = {
197
+ flags: {
198
+ get: {
199
+ out: { type: 'string', to: 'local', description: 'Download the export to this file once completed (its link lasts 5 minutes). Unzipped, unless the name ends in .gz.' },
200
+ wait: { type: 'boolean', to: 'local', description: 'Until the export is completed, failed or interrupted, read it again every few seconds (15 minutes at most).' },
201
+ },
202
+ },
203
+ async run(run) {
204
+ if (run.verb.name !== 'get' || (!run.parsed.values.out && !run.parsed.values.wait))
205
+ return undefined;
206
+ const req = resourceRequest('exports', run.verb, run.parsed.id, run.parsed.values);
207
+ let r = await run.send(req);
208
+ const started = Date.now();
209
+ const busy = (s) => s === 'pending' || s === 'in_progress';
210
+ while (run.parsed.values.wait && busy(r.data?.status) && Date.now() - started < WAIT_MAX_MS) {
211
+ const ms = waitMs(null);
212
+ process.stderr.write(JSON.stringify({ waiting: { export: run.parsed.id, status: r.data?.status, next_in_ms: ms } }) + '\n');
213
+ await sleep(ms);
214
+ r = await run.send(req);
215
+ }
216
+ const out = run.parsed.values.out;
217
+ if (!out)
218
+ return r;
219
+ const data = r.data ?? {};
220
+ if (data.status !== 'completed' || typeof data.url !== 'string') {
221
+ fail('conflict', `Export ${run.parsed.id} is ${data.status ?? 'not completed'}: there is no file to download yet.`, { path: 'out', details: { status: data.status ?? null }, next_step: { action: 'fix_request', command: `${run.line} --wait` } }, 'rejected');
222
+ }
223
+ const file = path.resolve(out);
224
+ let res;
225
+ try {
226
+ res = await fetch(data.url, { signal: AbortSignal.timeout(120_000) });
227
+ }
228
+ catch (e) {
229
+ fail('api_unreachable', `The export's file could not be downloaded: ${e.message}.`, { path: 'out', next_step: { action: 'fix_request', command: run.line } });
230
+ }
231
+ // An expired link: get the export again for a new one.
232
+ if (!res.ok)
233
+ fail('expired', `The export's link answered ${res.status}: it lasts 5 minutes. Get the export again for a new one.`, { path: 'out', next_step: { action: 'fix_request', command: run.line } }, 'rejected');
234
+ let bytes = Buffer.from(await res.arrayBuffer());
235
+ const zipped = bytes[0] === 0x1f && bytes[1] === 0x8b;
236
+ if (zipped && !file.endsWith('.gz'))
237
+ bytes = zlib.gunzipSync(bytes);
238
+ fs.mkdirSync(path.dirname(file), { recursive: true });
239
+ fs.writeFileSync(file, bytes);
240
+ const { url: _url, ...rest } = data;
241
+ return { ...r, data: { ...rest, file, bytes: bytes.length, unzipped: zipped && !file.endsWith('.gz') } };
242
+ },
243
+ };
244
+ export const extensions = { organizations, 'sales-calls': salesCalls, config, exports };
package/dist/engine.js CHANGED
@@ -12,6 +12,7 @@ import { envelope, request } from './http.js';
12
12
  import { readInputs } from './inputs.js';
13
13
  import { hasLogin, loginToken } from './login.js';
14
14
  import { resourceRequest } from './resources.js';
15
+ import { markDrift } from './project.js';
15
16
  import { describePlan, OUT_NOT_TRACKED, planSecret, removeRecordFiles, writeSecret } from './secrets.js';
16
17
  import { verbOf } from './schema.js';
17
18
  import { apiNouns, apiSchema, refreshSchemas, refusedBySchema } from './schemas.js';
@@ -53,7 +54,10 @@ async function executeOnce(argv, opts) {
53
54
  const schema = builtins[cmd.noun]?.schema ?? (await apiSchema(cmd.noun, Object.keys(builtins)));
54
55
  if (cmd.globals.help && !cmd.verb && !builtins[cmd.noun]?.defaultVerb)
55
56
  return envelope(nounHelp(schema));
56
- const verb = verbOf(schema, cmd.verb ?? builtins[cmd.noun]?.defaultVerb);
57
+ const ext = opts.extensions?.[schema.noun];
58
+ const found = verbOf(schema, cmd.verb ?? builtins[cmd.noun]?.defaultVerb);
59
+ const local = ext?.flags?.[found.name];
60
+ const verb = local ? { ...found, flags: { ...found.flags, ...local } } : found;
57
61
  if (cmd.globals.help)
58
62
  return envelope(verbHelp(schema, verb));
59
63
  // A verb the schema lists as refused: no request, no confirmation for something that cannot happen.
@@ -61,7 +65,6 @@ async function executeOnce(argv, opts) {
61
65
  const by = verb.refused.by === 'commercelayer' ? 'Commerce Layer' : verb.refused.by;
62
66
  fail('not_supported', `c11r ${schema.noun} ${verb.name} is refused by ${by}: ${verb.description || 'it cannot be done through the API.'}`, { details: { refused_by: verb.refused.by }, next_step: { action: 'none' } });
63
67
  }
64
- const ext = opts.extensions?.[schema.noun];
65
68
  const positional = ext?.positional?.[verb.name];
66
69
  const rest = positional && !verb.id && cmd.rest[0] !== undefined && !cmd.rest[0].startsWith('-') ? [`--${positional}`, ...cmd.rest] : cmd.rest;
67
70
  const parsed = parseFields(schema.noun, verb, rest);
@@ -140,7 +143,21 @@ async function executeOnce(argv, opts) {
140
143
  };
141
144
  const own = (await builtins[schema.noun]?.run?.(run)) ?? (await ext?.run?.(run));
142
145
  const r = own ?? (await run.send(resourceRequest(schema.noun, verb, parsed.id, parsed.values)));
143
- return filesGone(secretOf(r, run, secretPlan), run);
146
+ return drifted(filesGone(secretOf(r, run, secretPlan), run), run);
147
+ }
148
+ /**
149
+ * A Configure write that went through, in a folder whose store.yml names this organization: the
150
+ * file is now behind it. Noted in .c11r/drift, with the warning config_drift.
151
+ */
152
+ function drifted(r, run) {
153
+ if (run.globals.dryRun || !run.org.org || run.verb.method === 'GET')
154
+ return r;
155
+ if (run.schema.doc?.group !== 'configure')
156
+ return r;
157
+ const data = (r.data && typeof r.data === 'object' ? r.data : {});
158
+ const id = [run.parsed.id, data.code, data.name, data.id].find((k) => typeof k === 'string' && k !== '');
159
+ const warning = markDrift(run.org.org, { noun: run.schema.noun, verb: run.verb.name, id: id ?? null });
160
+ return warning ? { ...r, warnings: [...r.warnings, warning] } : r;
144
161
  }
145
162
  /**
146
163
  * A delete on a noun that answers secrets: the files c11r wrote for that record in the secrets
@@ -0,0 +1,175 @@
1
+ // The project folder's configuration files (contract section 45.2): store.yml and
2
+ // price_lists/<code>.csv, written by config export and read by config plan and apply, and the
3
+ // CLI's own notes about them in .c11r/:
4
+ //
5
+ // .c11r/export.json what the last export wrote: the organization, when, and each file's hash,
6
+ // so the next export never overwrites a file changed since without --yes.
7
+ // .c11r/drift the Configure writes made since, in a folder whose store.yml names the
8
+ // same organization: config plan mentions them, config export clears them.
9
+ //
10
+ // store.yml carries the export's meta ({ org, exported_at }) as its first key; the CLI takes it
11
+ // off before it sends the file, so the API sees { <noun>: [records] } only.
12
+ import crypto from 'node:crypto';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import YAML from 'yaml';
16
+ import { projectDir } from './config.js';
17
+ import { fail } from './errors.js';
18
+ export const STORE_FILE = 'store.yml';
19
+ export const PRICE_LISTS_DIR = 'price_lists';
20
+ const NOTES_DIR = '.c11r';
21
+ const EXPORT_FILE = 'export.json';
22
+ const DRIFT_FILE = 'drift';
23
+ const hashOf = (text) => `sha256-${crypto.createHash('sha256').update(text).digest('hex')}`;
24
+ const notes = (dir) => path.join(dir, NOTES_DIR);
25
+ /** store.yml and the price list CSVs of the project folder, or store_not_found. */
26
+ export function readStoreFiles(dir = projectDir()) {
27
+ const file = path.join(dir, STORE_FILE);
28
+ let text;
29
+ try {
30
+ text = fs.readFileSync(file, 'utf8');
31
+ }
32
+ catch {
33
+ fail('not_found', `There is no ${STORE_FILE} in ${dir}. c11r config export writes one from the organization.`, { path: 'store', next_step: { action: 'fix_request', command: 'c11r config export' } });
34
+ }
35
+ let doc;
36
+ try {
37
+ doc = YAML.parse(text) ?? {};
38
+ }
39
+ catch (e) {
40
+ fail('invalid_config', `${file} is not YAML: ${e.message}`, { path: 'store' });
41
+ }
42
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc))
43
+ fail('invalid_config', `${file} holds no nouns: it is { <noun>: [records] }.`, { path: 'store' });
44
+ const { meta, ...store } = doc;
45
+ const org = meta && typeof meta === 'object' && typeof meta.org === 'string' ? meta.org : null;
46
+ const price_lists = {};
47
+ const csvDir = path.join(dir, PRICE_LISTS_DIR);
48
+ let names = [];
49
+ try {
50
+ names = fs.readdirSync(csvDir);
51
+ }
52
+ catch {
53
+ // No price lists: fine.
54
+ }
55
+ for (const n of names.sort())
56
+ if (n.endsWith('.csv'))
57
+ price_lists[n.slice(0, -4)] = fs.readFileSync(path.join(csvDir, n), 'utf8');
58
+ return { store, price_lists, org };
59
+ }
60
+ /** The organization the folder's store.yml names, or null without one. */
61
+ export function storeFileOrg(dir = projectDir()) {
62
+ try {
63
+ const doc = YAML.parse(fs.readFileSync(path.join(dir, STORE_FILE), 'utf8'));
64
+ return typeof doc?.meta?.org === 'string' ? doc.meta.org : null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ /**
71
+ * Writes an export: store.yml (meta first) and price_lists/<code>.csv. A file there that differs
72
+ * from what the last export wrote (or that no export wrote) is the user's: without overwrite,
73
+ * nothing is written and the answer lists them. Price list files the last export wrote, unchanged
74
+ * since, and no longer in the organization, go.
75
+ */
76
+ export function writeStoreFiles(data, opts) {
77
+ const dir = opts.dir ?? projectDir();
78
+ const last = readRecord(dir);
79
+ const meta = { org: data.meta?.org ?? null, exported_at: data.meta?.exported_at ?? new Date().toISOString() };
80
+ const yaml = `# The organization's setup, written by c11r config export. Edit it, then c11r config plan and c11r config apply.\n${YAML.stringify({ meta, ...data.store }, { lineWidth: 0 })}`;
81
+ const files = { [STORE_FILE]: yaml };
82
+ for (const [code, csv] of Object.entries(data.price_lists ?? {}))
83
+ files[path.join(PRICE_LISTS_DIR, `${safeName(code)}.csv`)] = csv;
84
+ const changed = (rel) => {
85
+ const abs = path.join(dir, rel);
86
+ if (!fs.existsSync(abs))
87
+ return false;
88
+ const now = hashOf(fs.readFileSync(abs, 'utf8'));
89
+ return last.files[rel] !== now;
90
+ };
91
+ const stale = Object.keys(last.files).filter((rel) => rel.startsWith(`${PRICE_LISTS_DIR}${path.sep}`) && !(rel in files) && fs.existsSync(path.join(dir, rel)));
92
+ const mine = [...Object.keys(files).filter((rel) => files[rel] !== readOr(path.join(dir, rel))), ...stale].filter(changed);
93
+ if (mine.length && !opts.overwrite) {
94
+ fail('conflict', `${mine.join(', ')} changed since the last export${last.exported_at ? ` (${last.exported_at})` : ''}, or no export wrote ${mine.length === 1 ? 'it' : 'them'}. c11r replaces ${mine.length === 1 ? 'it' : 'them'} only with --yes; c11r config plan shows what the files would change in the organization.`, {
95
+ path: 'store',
96
+ details: { files: mine.map((rel) => path.join(dir, rel)) },
97
+ next_step: { action: 'fix_request', command: 'c11r config plan' },
98
+ }, 'rejected');
99
+ }
100
+ const written = [];
101
+ for (const [rel, text] of Object.entries(files)) {
102
+ const abs = path.join(dir, rel);
103
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
104
+ if (readOr(abs) !== text)
105
+ fs.writeFileSync(abs, text);
106
+ written.push(abs);
107
+ }
108
+ const removed = [];
109
+ for (const rel of stale) {
110
+ fs.rmSync(path.join(dir, rel), { force: true });
111
+ removed.push(path.join(dir, rel));
112
+ }
113
+ writeRecord(dir, { org: meta.org, exported_at: meta.exported_at, files: Object.fromEntries(Object.entries(files).map(([rel, text]) => [rel, hashOf(text)])) });
114
+ clearDrift(dir);
115
+ return { written, removed, dir };
116
+ }
117
+ /**
118
+ * After a Configure write: in a folder whose store.yml names this organization, notes it in
119
+ * .c11r/drift and answers the warning. Elsewhere, nothing.
120
+ */
121
+ export function markDrift(org, entry, dir = projectDir()) {
122
+ if (storeFileOrg(dir) !== org)
123
+ return null;
124
+ const list = readDrift(dir);
125
+ list.push({ ...entry, at: new Date().toISOString() });
126
+ fs.mkdirSync(notes(dir), { recursive: true });
127
+ // The first ones say since when; the last 50 say what.
128
+ const kept = list.length > 50 ? [list[0], ...list.slice(-49)] : list;
129
+ fs.writeFileSync(path.join(notes(dir), DRIFT_FILE), JSON.stringify(kept, null, 2) + '\n');
130
+ return driftWarning(kept);
131
+ }
132
+ /** The writes noted since the last export, oldest first. */
133
+ export function readDrift(dir = projectDir()) {
134
+ try {
135
+ const v = JSON.parse(fs.readFileSync(path.join(notes(dir), DRIFT_FILE), 'utf8'));
136
+ return Array.isArray(v) ? v : [];
137
+ }
138
+ catch {
139
+ return [];
140
+ }
141
+ }
142
+ export function clearDrift(dir = projectDir()) {
143
+ fs.rmSync(path.join(notes(dir), DRIFT_FILE), { force: true });
144
+ }
145
+ export function driftWarning(list) {
146
+ const first = list[0];
147
+ const what = (e) => `${e.noun} ${e.verb}${e.id ? ` ${e.id}` : ''}`;
148
+ return {
149
+ code: 'config_drift',
150
+ message: `store.yml is now behind the organization, since ${first.at} (${what(first)}${list.length > 1 ? ` and ${list.length - 1} more` : ''}): c11r config export refreshes it.`,
151
+ };
152
+ }
153
+ function readRecord(dir) {
154
+ try {
155
+ const v = JSON.parse(fs.readFileSync(path.join(notes(dir), EXPORT_FILE), 'utf8'));
156
+ return { org: v.org ?? null, exported_at: v.exported_at ?? null, files: v.files && typeof v.files === 'object' ? v.files : {} };
157
+ }
158
+ catch {
159
+ return { org: null, exported_at: null, files: {} };
160
+ }
161
+ }
162
+ function writeRecord(dir, r) {
163
+ fs.mkdirSync(notes(dir), { recursive: true });
164
+ fs.writeFileSync(path.join(notes(dir), EXPORT_FILE), JSON.stringify(r, null, 2) + '\n');
165
+ }
166
+ function readOr(file) {
167
+ try {
168
+ return fs.readFileSync(file, 'utf8');
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ }
174
+ /** A price list code as a file name: letters, digits, dot, dash and underscore. */
175
+ const safeName = (code) => code.replace(/[^A-Za-z0-9._-]+/g, '-');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "1.0.0-dev.8",
3
+ "version": "1.0.0-dev.9",
4
4
  "description": "c11r: the Commerce Layer CLI for coding agents. One grammar, c11r <noun> <verb>, from a sandbox to a running organization.",
5
5
  "homepage": "https://commercelayer.ai",
6
6
  "keywords": [