@commercelayer/agent-cli 1.0.0-dev.2 → 1.0.0-dev.22

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.
@@ -1,17 +1,30 @@
1
- // c11r sandbox create [--name] [--seed], get, reset, claim, destroy (contract section 4), and
1
+ // c11r sandbox create [--name] [--seed [type]] [--catalogue file], get, reset, claim, destroy (contract sections 4 and 48), and
2
2
  // c11r status (section 5). They are built in for what happens on this machine: create stores the
3
3
  // sandbox token (mode 600, ~/.config/c11r/credentials.json) and links the folder (.c11r.json);
4
4
  // reset moves both to the new organization; destroy forgets them. The token is printed never,
5
5
  // not even by create: only its hint.
6
6
  import crypto from 'node:crypto';
7
- import { credentialsFile, defaultOrg, markOutWritten, outWritten, storedLogin, forgetOrg, LINK_FILE, readLink, removeLink, renameOrg, storedOrgs, storeOrg, writeLink, hintOf } from '../config.js';
7
+ import { credentialsFile, defaultOrg, forgetOutWritten, storedLogin, forgetOrg, LINK_FILE, readLink, removeLink, renameOrg, parentLinkKept, storedOrgs, storeOrg, writeLink, hintOf } from '../config.js';
8
8
  import { CliError, fail } from '../errors.js';
9
9
  import { apiUrl, envelope, request } from '../http.js';
10
10
  import { parseSchema } from '../schema.js';
11
11
  import { OUT_NOT_TRACKED, removeOrgSecrets } from '../secrets.js';
12
12
  import { VERSION } from '../version.js';
13
+ import { readCatalogue, SEED_TYPES, withShop } from '../storefront.js';
13
14
  /** The longest sandbox name the API takes (contract: 422 invalid_argument past it). */
14
15
  const NAME_MAX = 25;
16
+ const SEED = {
17
+ name: 'seed',
18
+ type: 'enum',
19
+ values: [...SEED_TYPES],
20
+ bare: true,
21
+ description: 'Loads a demo shop of this type: coffee, tshirts or games, six products each, or custom, the person\'s own from --catalogue. Each has two markets, shipping, a manual payment setting (Wire transfer) and a storefront on, enough to place a test order at once. --seed alone is coffee.',
22
+ };
23
+ const CATALOGUE = {
24
+ name: 'catalogue',
25
+ type: 'string',
26
+ description: 'With --seed custom: a YAML file with the shop\'s name, its products (name, description, prices in EUR and USD, code) and optionally its look. Checked here, then sent in the same call.',
27
+ };
15
28
  export const sandbox = {
16
29
  schema: parseSchema({
17
30
  noun: 'sandbox',
@@ -28,9 +41,10 @@ export const sandbox = {
28
41
  live: false,
29
42
  body: [
30
43
  { name: 'name', type: 'string', description: 'The organization name; without it, an invented one. A name starting zztest marks a test organization.' },
31
- { name: 'seed', type: 'boolean', description: 'Loads the demo dataset: two markets, six products, shipping and a test payment method, enough to place a test order at once.' },
44
+ SEED,
45
+ CATALOGUE,
32
46
  ],
33
- effect: 'Creates a sandbox, stores its token on this machine (never printed) and links this folder to it with .c11r.json. With --seed it answers once the seed is applied, in about 20 seconds.',
47
+ effect: 'Creates a sandbox, stores its token on this machine (never printed) and links this folder to it with .c11r.json, in this folder: a link in a folder above is another project\'s and stays as it is. With --seed it answers once the seed and its storefront are in place, in about 20 seconds, with the shop link.',
34
48
  },
35
49
  { verb: 'get', method: 'GET', path: '/v1/sandbox', live: false, effect: 'Returns the sandbox: organization, expiry, whether it is seeded, the token hint and the claim status.' },
36
50
  {
@@ -39,8 +53,8 @@ export const sandbox = {
39
53
  path: '/v1/sandbox/reset',
40
54
  live: false,
41
55
  destructive: true,
42
- body: [{ name: 'seed', type: 'boolean', description: 'Seeds the fresh organization, or not; without it, as before.' }],
43
- effect: 'Replaces the sandbox with a fresh organization behind the same token, seeded as before, and retires the old one. The slug changes; this machine and .c11r.json follow it.',
56
+ body: [{ ...SEED, description: 'Seeds the fresh organization with this type, or not (--seed false); without it, as before. custom needs --catalogue again.' }, CATALOGUE],
57
+ effect: 'Replaces the sandbox with a fresh organization behind the same token, seeded as before (its storefront on again), and retires the old one. The slug changes; this machine and .c11r.json follow it.',
44
58
  },
45
59
  { verb: 'claim', method: 'POST', path: '/v1/sandbox/claim', live: false, effect: 'Returns a one-time claim URL, valid 30 minutes, for the human to open: they sign in with Commerce Layer and pick a plan. The agent never opens it for them.' },
46
60
  {
@@ -76,16 +90,18 @@ async function create(run, values) {
76
90
  if (name !== undefined && (name.trim() === '' || name.length > NAME_MAX)) {
77
91
  fail('invalid_value', `--name ${JSON.stringify(name)} is ${name.trim() === '' ? 'empty' : `${name.length} characters`}: a sandbox name has 1 to ${NAME_MAX}.`, { path: 'name', next_step: { action: 'fix_request', command: null } });
78
92
  }
79
- const seed = Boolean(values.seed);
93
+ const { seed, catalogue } = seedBody(values);
80
94
  const test = process.env.C11R_TEST === '1' || Boolean(name?.startsWith('zztest'));
81
- // The token goes only when this folder, --org or C11R_ORG names a sandbox: the API then
82
- // finishes a failed seed there, or answers 409 conflict naming it, rather than make a second
83
- // one for the same folder. A token picked only because it is the one stored stays home.
84
- const named = run.org.source === 'link' || run.org.source === 'flag' || run.org.source === 'env' || run.org.source === 'token_env';
95
+ // The token goes only when this folder's own link, --org, C11R_ORG or C11R_TOKEN names a
96
+ // sandbox: the API then finishes a failed seed there, or answers 409 conflict naming it, rather
97
+ // than make a second one for the same folder. A link found above is another project's, and a
98
+ // token picked as the default or as the only one stored stays home: the create makes a new one.
99
+ const above = readLink();
100
+ const named = (run.org.source === 'link' && above?.here === true) || run.org.source === 'flag' || run.org.source === 'env' || run.org.source === 'token_env';
85
101
  // A fresh key per create: after a 5xx or a lost connection the same create is repeated with it,
86
102
  // and the API answers the sandbox (and token) it made, rather than leave one with a lost token.
87
103
  const idempotencyKey = `c11r-${crypto.randomBytes(16).toString('base64url')}`;
88
- const r = await request({ method: 'POST', path: '/sandboxes', body: { ...(name ? { name } : {}), seed, test }, dryRun: run.globals.dryRun, timeoutMs: seed ? 120_000 : 60_000, idempotencyKey }, { live: false, token: named ? run.org.token : null });
104
+ const r = await request({ method: 'POST', path: '/sandboxes', body: { ...(name ? { name } : {}), seed, ...(catalogue ? { catalogue } : {}), test }, dryRun: run.globals.dryRun, timeoutMs: seed ? 180_000 : 60_000, idempotencyKey }, { live: false, token: named ? run.org.token : null });
89
105
  if (run.globals.dryRun)
90
106
  return r;
91
107
  const { token, ...data } = r.data ?? {};
@@ -93,15 +109,35 @@ async function create(run, values) {
93
109
  fail('invalid_response', 'The API created a sandbox without returning its org and token.', { next_step: { action: 'none' } });
94
110
  const hint = typeof data.token_hint === 'string' ? data.token_hint : hintOf(token);
95
111
  storeOrg(data.org, { token, token_hint: hint, expires_at: data.expires_at ?? null, created_at: new Date().toISOString(), api_url: apiUrl() });
96
- const link = writeLink(data.org);
112
+ const link = writeLink(data.org, { here: true });
97
113
  // The API says to store the token: the CLI has, and printed none of it.
98
114
  const warnings = r.warnings.filter((w) => w.code !== 'token_shown_once');
115
+ if (above && !above.here)
116
+ warnings.push(parentLinkKept(above));
99
117
  if (link.previous)
100
118
  warnings.push({ code: 'relinked', message: `${link.file} named ${link.previous}; it now names ${data.org}. The token of ${link.previous} stays stored.` });
101
- return { ...r, data: { ...data, token_hint: hint, credentials: credentialsFile(), linked: link.file }, warnings };
119
+ return withShop({ ...r, data: { ...data, token_hint: hint, credentials: credentialsFile(), linked: link.file }, warnings }, data.store);
120
+ }
121
+ /** --seed and --catalogue as the API takes them: custom only with a catalogue, a catalogue only with custom. */
122
+ function seedBody(values) {
123
+ const seed = values.seed ?? false;
124
+ const file = values.catalogue;
125
+ if (seed === 'custom' && file === undefined) {
126
+ fail('missing_value', '--seed custom needs --catalogue: a YAML file with the shop\'s name and its products. Write it from what the human described, show it to them, then run the command with --catalogue <file>.', { path: 'catalogue', next_step: { action: 'fix_request', command: null } });
127
+ }
128
+ if (file !== undefined && seed !== 'custom')
129
+ fail('invalid_value', '--catalogue goes only with --seed custom: the other types bring their own products.', { path: 'catalogue', next_step: { action: 'fix_request', command: null } });
130
+ return { seed, catalogue: file === undefined ? undefined : readCatalogue(file) };
102
131
  }
103
132
  async function reset(run, values) {
104
- const r = await run.send({ method: 'POST', path: '/sandbox/reset', body: values.seed === undefined ? {} : { seed: values.seed } });
133
+ const body = {};
134
+ if (values.seed !== undefined || values.catalogue !== undefined) {
135
+ const { seed, catalogue } = seedBody(values);
136
+ body.seed = seed;
137
+ if (catalogue)
138
+ body.catalogue = catalogue;
139
+ }
140
+ const r = await run.send({ method: 'POST', path: '/sandbox/reset', body });
105
141
  if (run.globals.dryRun)
106
142
  return r;
107
143
  const { org, previous_org: previous } = r.data ?? {};
@@ -112,7 +148,7 @@ async function reset(run, values) {
112
148
  if (link?.org === before)
113
149
  writeLink(org);
114
150
  }
115
- return r;
151
+ return withShop(r, r.data?.store);
116
152
  }
117
153
  async function destroy(run) {
118
154
  let r;
@@ -124,8 +160,7 @@ async function destroy(run) {
124
160
  // here. The sandbox is gone either way, so this machine forgets it too.
125
161
  if (!(e instanceof CliError && e.code === 'unauthorized') || run.globals.dryRun || !run.org.org)
126
162
  throw e;
127
- const out = outWritten(run.org.org);
128
- markOutWritten(run.org.org, false);
163
+ const out = forgetOutWritten(run.org.org);
129
164
  forgetOrg(run.org.org);
130
165
  const link = removeLink(run.org.org);
131
166
  const secrets = removeOrgSecrets(run.org.org);
@@ -136,8 +171,7 @@ async function destroy(run) {
136
171
  const org = r.data?.org ?? run.org.org;
137
172
  if (!org)
138
173
  return r;
139
- const out = outWritten(org);
140
- markOutWritten(org, false);
174
+ const out = forgetOutWritten(org);
141
175
  forgetOrg(org);
142
176
  const link = removeLink(org);
143
177
  const secrets = removeOrgSecrets(org);
@@ -148,12 +182,14 @@ export const status = {
148
182
  noun: 'status',
149
183
  kind: 'task',
150
184
  description: 'c11r status: where this machine and folder stand. The organization picked and from where, its lifecycle state, plan, mode, limits, usage and what is waiting.',
151
- verbs: [{ verb: 'get', method: 'GET', path: '/v1/status', state: 'none', effect: 'Returns the status. c11r status is the same as c11r status get.' }],
185
+ verbs: [{ verb: 'get', method: 'GET', path: '/v1/status', state: 'none', effect: 'Returns the status. c11r status is the same as c11r status get. With --live --yes it sends confirm=true: on an organization connected to c11r, the first live call asks for it once, before the live credential is made.' }],
152
186
  }),
153
187
  defaultVerb: 'get',
154
188
  async run(run) {
155
189
  // Status works at every state: an unresolved organization is shown, not raised.
156
- const r = await run.send({ method: 'GET', path: '/status' });
190
+ // In live, --yes is the human's yes to the live credential of a connected organization
191
+ // (contract 49.4): only this read, confirmed, makes it, never a write's --yes.
192
+ const r = await run.send({ method: 'GET', path: '/status', ...(run.globals.live && run.globals.yes && !run.globals.dryRun ? { confirm: true } : {}) });
157
193
  const link = (() => {
158
194
  try {
159
195
  return readLink();
@@ -168,7 +204,7 @@ export const status = {
168
204
  api_url: apiUrl(),
169
205
  org: run.org.org ? { slug: run.org.org, from: run.org.source } : null,
170
206
  ...(run.org.problem ? { org_problem: { code: run.org.problem.code, message: run.org.problem.message } } : {}),
171
- link: link ? { file: link.file, org: link.org } : { file: null, name: LINK_FILE },
207
+ link: link ? { file: link.file, org: link.org, here: link.here } : { file: null, name: LINK_FILE, here: false },
172
208
  credentials: credentialsFile(),
173
209
  stored,
174
210
  login: (() => {