@commercelayer/agent-cli 0.8.1 → 1.0.0-dev.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.
Files changed (67) hide show
  1. package/README.md +95 -215
  2. package/dist/argv.js +214 -0
  3. package/dist/bin.js +15 -178
  4. package/dist/builtins/auth.js +169 -0
  5. package/dist/builtins/extensions.js +123 -0
  6. package/dist/builtins/index.js +8 -0
  7. package/dist/builtins/loops.js +215 -0
  8. package/dist/builtins/sandbox.js +186 -0
  9. package/dist/config.js +211 -0
  10. package/dist/confirm.js +56 -0
  11. package/dist/engine.js +173 -0
  12. package/dist/errors.js +146 -0
  13. package/dist/help.js +78 -0
  14. package/dist/http.js +117 -0
  15. package/dist/login.js +133 -0
  16. package/dist/resources.js +41 -0
  17. package/dist/schema.js +152 -0
  18. package/dist/schemas.js +72 -0
  19. package/dist/secrets.js +189 -0
  20. package/dist/version.js +4 -8
  21. package/package.json +4 -9
  22. package/dist/api/client.js +0 -190
  23. package/dist/api/server.js +0 -26
  24. package/dist/api/stub/db.js +0 -37
  25. package/dist/api/stub/http.js +0 -18
  26. package/dist/api/stub/index.js +0 -52
  27. package/dist/api/stub/locale.js +0 -52
  28. package/dist/api/stub/management.js +0 -409
  29. package/dist/api/stub/messages.js +0 -126
  30. package/dist/api/stub/orders.js +0 -625
  31. package/dist/api/stub/search.js +0 -78
  32. package/dist/api/stub/shopping.js +0 -673
  33. package/dist/api/stub/theme.js +0 -197
  34. package/dist/api/stub/ucp.js +0 -377
  35. package/dist/api/transport.js +0 -57
  36. package/dist/api/types.js +0 -4
  37. package/dist/assets.js +0 -100
  38. package/dist/catalog.js +0 -372
  39. package/dist/check.js +0 -187
  40. package/dist/commands.js +0 -1241
  41. package/dist/cursor.js +0 -31
  42. package/dist/files.js +0 -98
  43. package/dist/inbox/cursor.js +0 -11
  44. package/dist/inbox/fake.js +0 -95
  45. package/dist/inbox/imap.js +0 -139
  46. package/dist/inbox/mailbox.js +0 -25
  47. package/dist/inbox/message.js +0 -88
  48. package/dist/inspect.js +0 -237
  49. package/dist/languages.js +0 -49
  50. package/dist/links.js +0 -118
  51. package/dist/locales.js +0 -43
  52. package/dist/ops.js +0 -190
  53. package/dist/orderRequest.js +0 -141
  54. package/dist/output.js +0 -39
  55. package/dist/products.js +0 -151
  56. package/dist/promotions.js +0 -291
  57. package/dist/store.js +0 -379
  58. package/dist/storefront.js +0 -285
  59. package/dist/ucp/buy.js +0 -90
  60. package/dist/ucp/check.js +0 -362
  61. package/dist/ucp/client.js +0 -129
  62. package/dist/ucp/images.js +0 -62
  63. package/dist/ucp/platform.js +0 -90
  64. package/dist/ucp/shop.js +0 -357
  65. package/dist/ucp/signing.js +0 -67
  66. package/dist/web.js +0 -316
  67. package/dist/website.js +0 -390
package/dist/argv.js ADDED
@@ -0,0 +1,214 @@
1
+ // c11r <noun> <verb> [id] [--field value ...], in two passes. The first takes the global flags
2
+ // out, wherever they are, and finds the noun and the verb. The second reads the rest against the
3
+ // verb's flags, which say whether a flag takes a value, what type it is and where it goes.
4
+ import fs from 'node:fs';
5
+ import { closest, fail, fixWith } from './errors.js';
6
+ const BOOLEAN_GLOBALS = {
7
+ '--json': 'json',
8
+ '--dry-run': 'dryRun',
9
+ '--yes': 'yes',
10
+ '-y': 'yes',
11
+ '--live': 'live',
12
+ '--help': 'help',
13
+ '-h': 'help',
14
+ '--version': 'version',
15
+ };
16
+ export function parseCommand(argv) {
17
+ const globals = { json: true, dryRun: false, yes: false, live: false, help: false, version: false };
18
+ const bare = [];
19
+ const rest = [];
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const a = argv[i];
22
+ const [flag, inline] = splitInline(a);
23
+ if (BOOLEAN_GLOBALS[flag] && inline === undefined) {
24
+ globals[BOOLEAN_GLOBALS[flag]] = true;
25
+ }
26
+ else if (flag === '--org') {
27
+ const value = inline ?? argv[++i];
28
+ if (value === undefined || value.startsWith('--'))
29
+ fail('missing_value', '--org needs an organization slug.', { path: 'org' });
30
+ globals.org = value;
31
+ }
32
+ else if (bare.length < 2 && !a.startsWith('-') && rest.length === 0) {
33
+ bare.push(a);
34
+ }
35
+ else {
36
+ rest.push(a);
37
+ }
38
+ }
39
+ return { globals, noun: bare[0], verb: bare[1], rest };
40
+ }
41
+ function splitInline(a) {
42
+ if (!a.startsWith('--'))
43
+ return [a, undefined];
44
+ const eq = a.indexOf('=');
45
+ return eq === -1 ? [a, undefined] : [a.slice(0, eq), a.slice(eq + 1)];
46
+ }
47
+ /** --price-list and --price_list are the same flag: the schema's names are snake_case. */
48
+ const toKey = (flag) => flag.replace(/^--/, '').replace(/-/g, '_');
49
+ export const toFlag = (key) => `--${key.replace(/_/g, '-')}`;
50
+ /** The rest of the command, read against the verb: its id, then its flags. */
51
+ export function parseFields(noun, verb, rest) {
52
+ const values = {};
53
+ let id;
54
+ const keys = Object.keys(verb.flags);
55
+ const cmd = `c11r ${noun} ${verb.name}`;
56
+ for (let i = 0; i < rest.length; i++) {
57
+ const a = rest[i];
58
+ if (!a.startsWith('--')) {
59
+ if (verb.id && id === undefined) {
60
+ id = a;
61
+ continue;
62
+ }
63
+ fail('unexpected_argument', `${cmd} does not take ${a}${verb.id ? `: it already has the id ${id}` : ''}. A value goes after its flag.`, { next_step: fixWith(`${cmd} --help`) });
64
+ }
65
+ const [flag, inline] = splitInline(a);
66
+ const key = toKey(flag);
67
+ if (key === 'data') {
68
+ Object.assign(values, readData(inline ?? rest[++i]));
69
+ continue;
70
+ }
71
+ // A key of its own (a filter operator: created_at.gte), else part of an object (checkout.url).
72
+ const [top, ...sub] = verb.flags[key] ? [key] : key.split('.');
73
+ const spec = verb.flags[top];
74
+ if (!spec) {
75
+ fail('unknown_field', `${cmd} has no flag ${toFlag(key)}.`, {
76
+ path: key,
77
+ did_you_mean: closest(top, keys).map(toFlag),
78
+ details: { flags: keys.map(toFlag) },
79
+ next_step: fixWith(`${cmd} --help`),
80
+ });
81
+ }
82
+ let raw = inline;
83
+ if (raw === undefined) {
84
+ const next = rest[i + 1];
85
+ if (spec.type === 'boolean' && !sub.length) {
86
+ raw = next === 'true' || next === 'false' ? rest[++i] : 'true';
87
+ }
88
+ else {
89
+ if (next === undefined || /^--[a-z]/i.test(next))
90
+ fail('missing_value', `${toFlag(key)} needs a value.`, { path: key });
91
+ raw = rest[++i];
92
+ }
93
+ }
94
+ if (sub.length) {
95
+ if (spec.type !== 'object')
96
+ fail('invalid_value', `${toFlag(top)} is ${spec.type}: it has no part ${sub.join('.')}.`, { path: key });
97
+ let target = (values[top] ??= {});
98
+ for (const k of sub.slice(0, -1))
99
+ target = (target[k] ??= {});
100
+ const part = sub.join('.');
101
+ target[sub[sub.length - 1]] = part in (spec.properties ?? {}) ? coerce(key, { type: spec.properties[part] }, raw) : raw;
102
+ continue;
103
+ }
104
+ const value = coerce(key, spec, raw);
105
+ values[top] = spec.type === 'array' ? [...(values[top] ?? []), ...value] : value;
106
+ }
107
+ return { id, values };
108
+ }
109
+ /** A value as its flag's type says, or invalid_value with the reason. */
110
+ export function coerce(key, spec, raw) {
111
+ const invalid = (why) => fail('invalid_value', `${toFlag(key)} ${JSON.stringify(raw)} ${why}.`, { path: key, ...(spec.values ? { details: { values: spec.values } } : {}) });
112
+ switch (spec.type) {
113
+ case 'string':
114
+ case 'ref':
115
+ return raw;
116
+ case 'integer':
117
+ if (!/^-?\d+$/.test(raw.trim()))
118
+ invalid('is not a whole number');
119
+ return Number(raw);
120
+ case 'number':
121
+ case 'money':
122
+ // Money is in major units: 12.50.
123
+ if (raw.trim() === '' || !Number.isFinite(Number(raw)))
124
+ invalid(spec.type === 'money' ? 'is not an amount, as in 12.50' : 'is not a number');
125
+ return Number(raw);
126
+ case 'boolean':
127
+ if (raw !== 'true' && raw !== 'false')
128
+ invalid('is not true or false');
129
+ return raw === 'true';
130
+ case 'date':
131
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(raw) || Number.isNaN(Date.parse(raw)))
132
+ invalid('is not a date, as in 2026-11-30');
133
+ return raw;
134
+ case 'datetime':
135
+ if (Number.isNaN(Date.parse(raw)))
136
+ invalid('is not a date and time, as in 2026-11-30T09:00:00Z');
137
+ return raw;
138
+ case 'enum':
139
+ if (!spec.values?.includes(raw))
140
+ invalid(`is not one of ${spec.values?.join(', ')}`);
141
+ return raw;
142
+ case 'object': {
143
+ const v = raw.trim().startsWith('{') ? json(raw, invalid) : pairs(key, raw, spec.properties ?? {}, invalid);
144
+ if (!v || typeof v !== 'object' || Array.isArray(v))
145
+ invalid('is not a JSON object');
146
+ return v;
147
+ }
148
+ case 'array': {
149
+ const item = spec.items ?? 'string';
150
+ if (raw.trim().startsWith('[')) {
151
+ const list = json(raw, invalid);
152
+ if (!Array.isArray(list))
153
+ invalid('is not a list');
154
+ return list.map((x) => (typeof x === 'string' && item !== 'string' ? coerce(key, { type: item, values: spec.values }, x) : x));
155
+ }
156
+ // One object per flag (--items sku=A,quantity=2, repeated), or scalars separated by commas.
157
+ if (item === 'object')
158
+ return [coerce(key, { type: 'object', properties: spec.properties }, raw)];
159
+ return raw
160
+ .split(',')
161
+ .map((s) => s.trim())
162
+ .filter(Boolean)
163
+ .map((x) => coerce(key, { type: item, values: spec.values }, x));
164
+ }
165
+ }
166
+ }
167
+ function json(raw, invalid) {
168
+ try {
169
+ return JSON.parse(raw);
170
+ }
171
+ catch {
172
+ return invalid('is not valid JSON');
173
+ }
174
+ }
175
+ /**
176
+ * sku=A,quantity=2 as { sku: 'A', quantity: 2 }, each part typed by the schema's properties.
177
+ * A part the schema does not type stays text: a postal code or a phone is never made a number.
178
+ */
179
+ function pairs(key, raw, props, invalid) {
180
+ const out = {};
181
+ for (const part of raw.split(',')) {
182
+ const eq = part.indexOf('=');
183
+ if (eq < 1)
184
+ invalid('is not JSON nor key=value pairs separated by commas');
185
+ const name = part.slice(0, eq).trim();
186
+ const value = part.slice(eq + 1).trim();
187
+ out[name] = props[name] ? coerce(`${key}.${name}`, { type: props[name] }, value) : value;
188
+ }
189
+ return out;
190
+ }
191
+ /** --data '{"name": ...}' or --data @file.json: the values as one JSON object. */
192
+ function readData(raw) {
193
+ if (raw === undefined)
194
+ fail('missing_value', '--data needs a JSON object, or @ and a file name.', { path: 'data' });
195
+ let text = raw;
196
+ if (raw.startsWith('@')) {
197
+ try {
198
+ text = fs.readFileSync(raw.slice(1), 'utf8');
199
+ }
200
+ catch (e) {
201
+ fail('invalid_value', `--data ${raw}: ${e.message}.`, { path: 'data' });
202
+ }
203
+ }
204
+ let v;
205
+ try {
206
+ v = JSON.parse(text);
207
+ }
208
+ catch {
209
+ fail('invalid_value', '--data is not valid JSON.', { path: 'data' });
210
+ }
211
+ if (!v || typeof v !== 'object' || Array.isArray(v))
212
+ fail('invalid_value', '--data is not a JSON object.', { path: 'data' });
213
+ return v;
214
+ }
package/dist/bin.js CHANGED
@@ -1,182 +1,19 @@
1
1
  #!/usr/bin/env node
2
- // c11r: the Commerce Layer CLI for coding agents (package @commercelayer/agent-cli).
3
- // Arguments are a command, optional subcommand, --flags and --option values.
4
- // Output is always JSON; exit code 1 on an error or a failed check.
5
- import * as cmd from './commands.js';
6
- import { CliError, print } from './output.js';
7
- import { LATEST_HINT, NPX } from './version.js';
8
- const VALUE_OPTIONS = new Set(['--name', '--sku', '--quantity', '--country', '--code', '--file', '--store', '--item', '--email', '--first-name', '--last-name', '--phone', '--street', '--city', '--postal-code', '--region', '--shipping', '--category', '--min-price', '--max-price', '--status', '--payment', '--fulfillment', '--limit', '--archived', '--amount', '--carrier', '--tracking', '--tracking-url', '--since', '--until', '--by', '--company', '--line-2', '--query', '--reference', '--notes', '--language', '--source', '--channel', '--label', '--unlabel', '--move', '--currency']);
9
- const argv = process.argv.slice(2);
10
- const flags = new Set();
11
- const values = {};
12
- /** Every value of an option given more than once (--item, --code, --category), in order; values keeps the last. */
13
- const lists = {};
14
- const set = (k, v) => {
15
- values[k] = v;
16
- (lists[k] ??= []).push(v);
17
- };
18
- const args = [];
19
- for (let i = 0; i < argv.length; i++) {
20
- const a = argv[i];
21
- if (a === '-f' && i + 1 < argv.length) {
22
- values.file = argv[++i];
23
- continue;
2
+ // c11r: one JSON object on stdout per command, { data, state, warnings, next } or
3
+ // { error, state }, and a stable exit code (src/errors.ts, EXIT). Retry notices go to stderr.
4
+ import { builtins, extensions } from './builtins/index.js';
5
+ import { execute } from './engine.js';
6
+ import { asCliError, EXIT } from './errors.js';
7
+ async function main() {
8
+ try {
9
+ const out = await execute(process.argv.slice(2), { builtins, extensions });
10
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n');
11
+ process.exitCode = EXIT.ok;
24
12
  }
25
- if (!a.startsWith('--')) {
26
- args.push(a);
27
- continue;
13
+ catch (e) {
14
+ const err = asCliError(e);
15
+ process.stdout.write(JSON.stringify({ error: err.body, state: err.state ?? null }, null, 2) + '\n');
16
+ process.exitCode = err.exitCode;
28
17
  }
29
- const eq = a.indexOf('=');
30
- if (eq > 0)
31
- set(a.slice(2, eq), a.slice(eq + 1));
32
- else if (VALUE_OPTIONS.has(a) && i + 1 < argv.length && !argv[i + 1].startsWith('--'))
33
- set(a.slice(2), argv[++i]);
34
- else
35
- flags.add(a);
36
18
  }
37
- const [name, sub] = args;
38
- async function run() {
39
- if (flags.has('--version') || name === 'version')
40
- return { version: cmd.VERSION, latest_hint: LATEST_HINT };
41
- switch (name) {
42
- case undefined:
43
- case 'help':
44
- return cmd.help(args.slice(1).join(' ') || undefined);
45
- case 'init':
46
- return cmd.init(values);
47
- case 'plan':
48
- return cmd.plan();
49
- case 'apply':
50
- return cmd.apply(flags);
51
- case 'status':
52
- return cmd.status();
53
- case 'claim':
54
- return cmd.claim();
55
- case 'catalog':
56
- if (sub === 'pull')
57
- return cmd.catalogPull(values);
58
- throw new CliError('unknown_command', `There is no command "catalog ${sub ?? ''}".`, 'Use `c11r catalog pull`.');
59
- case 'storefront':
60
- if (sub === 'init')
61
- return cmd.storefrontInit(flags);
62
- if (sub === 'publish')
63
- return cmd.storefrontPublish();
64
- if (sub === 'check')
65
- return cmd.storefrontCheckCmd();
66
- if (sub === 'inspect')
67
- return cmd.storefrontInspect(args[2]);
68
- throw new CliError('unknown_command', `There is no command "storefront ${sub ?? ''}".`, 'Use `c11r storefront init`, `storefront publish`, `storefront check` or `storefront inspect <url>`.');
69
- case 'promotions':
70
- if (sub === 'list')
71
- return cmd.promotionsList();
72
- if (sub === 'show')
73
- return cmd.promotionsShow(args[2]);
74
- if (sub === 'create')
75
- return cmd.promotionsCreate(values);
76
- if (sub === 'update')
77
- return cmd.promotionsUpdate(args[2], values);
78
- if (sub === 'enable' || sub === 'disable')
79
- return cmd.promotionsEnable(args[2], sub === 'enable');
80
- if (sub === 'delete')
81
- return cmd.promotionsDelete(args[2]);
82
- throw new CliError('unknown_command', `There is no command "promotions ${sub ?? ''}".`, 'Use `c11r promotions list`, `show <id>`, `create`, `update <id>`, `enable <id>`, `disable <id>` or `delete <id>`.');
83
- case 'links':
84
- if (sub === 'list')
85
- return cmd.linksList();
86
- if (sub === 'show')
87
- return cmd.linksShow(args[2]);
88
- if (sub === 'create')
89
- return cmd.linksCreate(values);
90
- if (sub === 'enable' || sub === 'disable')
91
- return cmd.linksEnable(args[2], sub === 'enable');
92
- if (sub === 'delete')
93
- return cmd.linksDelete(args[2]);
94
- throw new CliError('unknown_command', `There is no command "links ${sub ?? ''}".`, 'Use `c11r links list`, `show <id>`, `create`, `enable <id>`, `disable <id>` or `delete <id>`.');
95
- case 'orders':
96
- if (sub === 'test')
97
- return cmd.ordersTest(flags, values);
98
- if (sub === 'create')
99
- return cmd.ordersCreate(values, lists, flags);
100
- if (sub === 'list')
101
- return cmd.ordersList(values, flags);
102
- if (sub === 'show')
103
- return cmd.ordersShow(args[2]);
104
- if (sub === 'place' || sub === 'approve' || sub === 'capture' || sub === 'cancel' || sub === 'refund' || sub === 'archive' || sub === 'unarchive')
105
- return cmd.ordersAct(sub, args[2], values, flags);
106
- throw new CliError('unknown_command', `There is no command "orders ${sub ?? ''}".`, 'Use `c11r orders list`, `show <number>`, `create`, `place <number>`, `approve <number> [--capture]`, `capture`, `cancel`, `refund <number> [--amount <n>]`, `archive`, `unarchive`, or `orders test`.');
107
- case 'fulfillment':
108
- if (sub === 'list')
109
- return cmd.fulfillmentList(values);
110
- if (sub === 'show')
111
- return cmd.fulfillmentShow(args[2]);
112
- if (sub === 'hold' || sub === 'pick' || sub === 'pack' || sub === 'ready' || sub === 'ship' || sub === 'deliver')
113
- return cmd.fulfillmentAct(sub, args[2], values);
114
- throw new CliError('unknown_command', `There is no command "fulfillment ${sub ?? ''}".`, 'Use `c11r fulfillment list`, `show <shipment>`, `hold`, `pick`, `pack`, `ready`, `ship <shipment> [--carrier --tracking --tracking-url]` or `deliver`.');
115
- case 'ops':
116
- if (sub === 'check')
117
- return cmd.opsCheck(values);
118
- if (sub === 'run')
119
- return cmd.opsRun(flags);
120
- if (sub === 'digest')
121
- return cmd.opsDigest(values);
122
- throw new CliError('unknown_command', `There is no command "ops ${sub ?? ''}".`, 'Use `c11r ops check`, `ops run [--dry-run]` or `ops digest [--since 24h]`.');
123
- case 'insights':
124
- return cmd.insights(values);
125
- case 'inbox':
126
- if (sub === 'check')
127
- return cmd.inboxCheck();
128
- if (sub === 'list')
129
- return cmd.inboxList(values, flags);
130
- if (sub === 'pull')
131
- return cmd.inboxPull(args[2], values, flags);
132
- if (sub === 'tag')
133
- return cmd.inboxTag(args[2], lists, values, flags);
134
- throw new CliError('unknown_command', `There is no command "inbox ${sub ?? ''}".`, sub === 'reply' ? 'The CLI does not send email: replies stay with the human or their mail app.' : 'Use `c11r inbox check`, `list [--unseen] [--since 24h] [--limit 20]`, `pull <id> | --unseen` or `tag <id> --label <name> | --unlabel <name> | --move <folder> | --seen`.');
135
- case 'ucp':
136
- if (sub === 'profile')
137
- return cmd.ucpProfile(flags);
138
- if (sub === 'check')
139
- return cmd.ucpCheck();
140
- if (sub === 'search')
141
- return cmd.ucpSearch(args.slice(2).join(' '), values, lists, flags);
142
- if (sub === 'product')
143
- return cmd.ucpProduct(args[2], values, flags);
144
- if (sub === 'order')
145
- return cmd.ucpOrder(args[2], values);
146
- if (sub === 'ask')
147
- return cmd.ucpAsk(args.slice(2).join(' '), values);
148
- if (sub === 'checkout') {
149
- const [, , action, id] = args;
150
- if (action === 'create')
151
- return cmd.ucpCheckoutCreate(values, lists);
152
- if (action === 'update')
153
- return cmd.ucpCheckoutUpdate(id, values, lists);
154
- if (action === 'show')
155
- return cmd.ucpCheckoutShow(id, values);
156
- if (action === 'cancel')
157
- return cmd.ucpCheckoutCancel(id, values);
158
- if (action === 'complete')
159
- return cmd.ucpCheckoutComplete(id, values, flags);
160
- throw new CliError('unknown_command', `There is no command "ucp checkout ${action ?? ''}".`, 'Use `c11r ucp checkout create`, `update <id>`, `show <id>`, `cancel <id>` or `complete <id>`.');
161
- }
162
- throw new CliError('unknown_command', `There is no command "ucp ${sub ?? ''}".`, 'Use `c11r ucp search`, `product`, `checkout`, `order`, `ask`, `profile` or `check`.');
163
- default:
164
- throw new CliError('unknown_command', `There is no command "${name}".`, `Run \`c11r help\`. Commands: ${Object.keys(cmd.HELP).join(', ')}.`);
165
- }
166
- }
167
- run()
168
- .then((result) => {
169
- print(result);
170
- if (result?.status === 'fail')
171
- process.exit(1);
172
- })
173
- .catch((e) => {
174
- if (e instanceof CliError) {
175
- print({ error: { code: e.code, message: e.message, hint: e.hint, ...(e.path ? { path: e.path } : {}) } });
176
- }
177
- else {
178
- const message = e instanceof Error ? e.message : String(e);
179
- print({ error: { code: 'internal', message, hint: `This is a bug in the CLI. Tell the human, with this output, and try again with ${NPX} in case it is already fixed.` } });
180
- }
181
- process.exit(1);
182
- });
19
+ void main();
@@ -0,0 +1,169 @@
1
+ // c11r auth login, logout, switch (contract section 18): the human's own Commerce Layer
2
+ // login, for the nouns a claimed organization needs (organizations, members, roles, credentials).
3
+ //
4
+ // login reads the client id and secret of the human's Provisioning API application: from a
5
+ // terminal, the secret typed without echo; with no terminal, from a file the human saved
6
+ // (--secret-file, or ~/.config/c11r/provisioning.secret by default), only if no other user can
7
+ // read it. Never from a flag or the environment. It mints a token at Commerce Layer, checks it
8
+ // with GET /v1/auth, keeps both values in credentials.json (mode 600) and deletes the file.
9
+ // With no terminal and no file it stops at once, makes the empty file (mode 600) for the human
10
+ // to complete, and answers secret_required with next_step human_step. The stored login shows in
11
+ // c11r status, never its secret.
12
+ import fs from 'node:fs';
13
+ import { credentialsFile, readLink, setDefaultOrg, storedLogin, storedOrgs, storeLogin, writeLink } from '../config.js';
14
+ import { terminal } from '../confirm.js';
15
+ import { closest, errorBody, CliError, fail } from '../errors.js';
16
+ import { envelope, request } from '../http.js';
17
+ import { loginToken, mint, PROVISIONING_URL, readSecretFile, secretFileDefault, writeSecretTemplate } from '../login.js';
18
+ import { parseSchema } from '../schema.js';
19
+ export const auth = {
20
+ schema: parseSchema({
21
+ noun: 'auth',
22
+ group: 'setup',
23
+ skill: 'setup-auth',
24
+ kind: 'task',
25
+ description: 'The human\'s own Commerce Layer login, from their Provisioning API application: for organizations, members, roles and the credentials of a claimed organization. The secret stays on this machine and is never printed.',
26
+ verbs: [
27
+ {
28
+ verb: 'login',
29
+ method: 'POST',
30
+ path: '/v1/auth/login',
31
+ state: 'none',
32
+ credential: 'none',
33
+ body: [
34
+ { name: 'client_id', type: 'string', description: 'The client id of the Provisioning API application. Not a secret. Also read from the secret file.' },
35
+ { name: 'secret_file', type: 'string', description: `A file with the client secret, alone or as client_secret=..., readable only by its owner (mode 600). By default ${'~/.config/c11r/provisioning.secret'} when there is no terminal.` },
36
+ { name: 'keep_file', type: 'boolean', description: 'Leaves the secret file where it is; by default it is deleted once read.' },
37
+ ],
38
+ effect: 'Logs in with the human\'s Provisioning API application: mints a token at Commerce Layer, checks it with the API, keeps the client id and secret in credentials.json (mode 600). The API receives only the token.',
39
+ },
40
+ { verb: 'logout', method: 'POST', path: '/v1/auth/logout', state: 'none', credential: 'none', effect: 'Forgets the stored login on this machine. Its last token ends by itself within two hours. Organization tokens stay.' },
41
+ {
42
+ verb: 'switch',
43
+ method: 'POST',
44
+ path: '/v1/auth/switch/{slug}',
45
+ state: 'none',
46
+ credential: 'none',
47
+ body: [{ name: 'global', type: 'boolean', description: 'Sets the default for this machine, used when no --org, C11R_ORG or .c11r.json names one; without it, links this project folder.' }],
48
+ effect: 'Picks the organization commands act on: links this project folder to it (.c11r.json), or with --global sets the default for this machine.',
49
+ },
50
+ ],
51
+ }),
52
+ async run(run) {
53
+ switch (run.verb.name) {
54
+ case 'login':
55
+ return login(run);
56
+ case 'logout':
57
+ return logout();
58
+ case 'switch':
59
+ return switchOrg(run);
60
+ }
61
+ return undefined;
62
+ },
63
+ };
64
+ async function login(run) {
65
+ const v = run.parsed.values;
66
+ const prompter = run.prompter ?? terminal;
67
+ let clientId = v.client_id;
68
+ let secret;
69
+ let file = v.secret_file;
70
+ const fallback = secretFileDefault();
71
+ if (!file && !prompter.interactive && fs.existsSync(fallback))
72
+ file = fallback;
73
+ if (file) {
74
+ const read = readSecretFile(file);
75
+ clientId = clientId ?? read.client_id;
76
+ secret = read.client_secret;
77
+ if (!secret || !clientId) {
78
+ fail('secret_required', `${file} is not complete: ${!secret && !clientId ? 'client_id and client_secret have' : !secret ? 'client_secret has' : 'client_id has'} no value. Ask the human to complete the two lines client_id= and client_secret= in it with the values of their Provisioning API application, from the dashboard (next_step.url). Then c11r auth login reads it. Do not open or print that file.`, {
79
+ path: !secret ? 'client_secret' : 'client_id',
80
+ next_step: { action: 'human_step', command: 'c11r auth login', url: PROVISIONING_URL },
81
+ details: { secret_file: file },
82
+ });
83
+ }
84
+ }
85
+ else if (prompter.interactive && prompter.askSecret) {
86
+ clientId = clientId ?? (await prompter.ask('Client id of your Provisioning API application: ')).trim();
87
+ secret = (await prompter.askSecret('Client secret (not shown): ')).trim();
88
+ if (!clientId || !secret)
89
+ fail('secret_required', 'Nothing was typed: no login was stored.', { path: !clientId ? 'client_id' : 'client_secret' });
90
+ }
91
+ else {
92
+ // No terminal and no file: the human saves the values in a file the CLI made for it.
93
+ const created = writeSecretTemplate(fallback);
94
+ throw new CliError(errorBody('secret_required', `There is no terminal to type the secret in. Ask the human to open the Provisioning API page of the dashboard (next_step.url), create an application if there is none, and complete the two lines client_id= and client_secret= in ${fallback}, which ${created ? 'c11r has just made' : 'is there already'}, readable only by them. Then c11r auth login reads it. Do not open or print that file.`, { path: 'secret_file', next_step: { action: 'human_step', command: 'c11r auth login', url: PROVISIONING_URL }, details: { secret_file: fallback, created } }));
95
+ }
96
+ const minted = await mint(clientId, secret);
97
+ const who = await request({ method: 'GET', path: '/auth', query: { limit: '100' } }, { live: false, token: minted.token });
98
+ const orgs = who.data?.organizations;
99
+ const items = Array.isArray(orgs) ? orgs : Array.isArray(orgs?.items) ? orgs.items : [];
100
+ const previous = storedLogin();
101
+ if (!run.globals.dryRun) {
102
+ storeLogin({
103
+ client_id: clientId,
104
+ client_secret: secret,
105
+ token: minted.token,
106
+ expires_at: minted.expires_at,
107
+ user: who.data?.user ?? null,
108
+ organizations: items.map((o) => ({ slug: o.slug, name: o.name, plan: o.plan ?? null, ...(o.claimed_with_c11r !== undefined ? { claimed_with_c11r: o.claimed_with_c11r } : {}) })),
109
+ created_at: new Date().toISOString(),
110
+ });
111
+ }
112
+ let removed = false;
113
+ if (file && !v.keep_file && !run.globals.dryRun) {
114
+ fs.rmSync(file, { force: true });
115
+ removed = true;
116
+ }
117
+ const warnings = [...who.warnings];
118
+ if (previous && previous.client_id !== clientId)
119
+ warnings.push({ code: 'login_replaced', message: `The login with client id ${previous.client_id} was replaced.` });
120
+ return {
121
+ ...who,
122
+ data: {
123
+ ...(run.globals.dryRun ? { dry_run: true } : {}),
124
+ user: who.data?.user ?? null,
125
+ organizations: orgs ?? null,
126
+ client_id: clientId,
127
+ token_expires_at: minted.expires_at,
128
+ credentials: run.globals.dryRun ? null : credentialsFile(),
129
+ secret_file: file ? { file, removed } : null,
130
+ },
131
+ warnings,
132
+ next: who.next?.length ? who.next : [{ command: 'c11r organizations list', why: 'The organizations this login reaches.' }],
133
+ };
134
+ }
135
+ function logout() {
136
+ const l = storedLogin();
137
+ if (!l)
138
+ return envelope({ forgotten: false, login: null });
139
+ storeLogin(null);
140
+ return envelope({ forgotten: true, client_id: l.client_id, token_ends_by: l.expires_at }, { warnings: [{ code: 'token_still_valid', message: `The last token of this login ends by itself${l.expires_at ? ` at ${l.expires_at}` : ''}, within two hours; it is not revoked. Organization tokens on this machine stay.` }] });
141
+ }
142
+ async function switchOrg(run) {
143
+ const slug = run.parsed.id;
144
+ const tokens = storedOrgs();
145
+ let reached = storedLogin()?.organizations.map((o) => o.slug) ?? [];
146
+ if (!tokens[slug] && !reached.includes(slug) && storedLogin()) {
147
+ // The list may be older than the organization: read it again.
148
+ const who = await request({ method: 'GET', path: '/auth', query: { limit: '100' } }, { live: false, token: await loginToken() });
149
+ const items = who.data?.organizations?.items ?? who.data?.organizations ?? [];
150
+ reached = items.map((o) => o.slug);
151
+ const l = storedLogin();
152
+ if (l)
153
+ storeLogin({ ...l, organizations: items.map((o) => ({ slug: o.slug, name: o.name, plan: o.plan ?? null })) });
154
+ }
155
+ if (!tokens[slug] && !reached.includes(slug)) {
156
+ const known = [...new Set([...Object.keys(tokens), ...reached])];
157
+ fail('not_found', `${slug} is neither an organization with a token on this machine nor one the login reaches.`, { path: 'slug', did_you_mean: closest(slug, known), details: { orgs: known }, next_step: { action: 'fix_request', command: 'c11r organizations list' } });
158
+ }
159
+ const next = tokens[slug] ? [] : [{ command: `c11r organizations connect ${slug}`, why: 'This machine has no organization token for it yet: the organization nouns need one.' }];
160
+ if (run.parsed.values.global) {
161
+ if (!run.globals.dryRun)
162
+ setDefaultOrg(slug);
163
+ return envelope({ org: slug, default: true, token: tokens[slug] ? 'stored' : 'none', ...(run.globals.dryRun ? { dry_run: true } : {}) }, { next });
164
+ }
165
+ if (run.globals.dryRun)
166
+ return envelope({ org: slug, dry_run: true, link: readLink()?.file ?? null, token: tokens[slug] ? 'stored' : 'none' }, { next });
167
+ const link = writeLink(slug);
168
+ return envelope({ org: slug, linked: link.file, previous: link.previous, token: tokens[slug] ? 'stored' : 'none' }, { next });
169
+ }