@commercelayer/agent-cli 0.8.0 → 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.
- package/README.md +95 -215
- package/dist/argv.js +214 -0
- package/dist/bin.js +15 -178
- package/dist/builtins/auth.js +169 -0
- package/dist/builtins/extensions.js +123 -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 +211 -0
- package/dist/confirm.js +56 -0
- package/dist/engine.js +173 -0
- package/dist/errors.js +146 -0
- package/dist/help.js +78 -0
- package/dist/http.js +117 -0
- package/dist/login.js +133 -0
- package/dist/resources.js +41 -0
- package/dist/schema.js +152 -0
- package/dist/schemas.js +72 -0
- package/dist/secrets.js +189 -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 -366
- 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 -230
- 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 -360
- 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/config.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// What the CLI keeps on the machine, and which organization a command acts on.
|
|
2
|
+
//
|
|
3
|
+
// Tokens live only in ~/.config/c11r/credentials.json (XDG_CONFIG_HOME respected), mode 600,
|
|
4
|
+
// keyed by organization slug. A token is printed never: only its hint.
|
|
5
|
+
//
|
|
6
|
+
// A project folder links to its organization with .c11r.json, { "org": "<slug>" }: no secret,
|
|
7
|
+
// safe to commit, found by walking up from the current folder like .git. The project folder is
|
|
8
|
+
// the one with .c11r.json, else the nearest with store.yml or loops.yml, else the current one.
|
|
9
|
+
//
|
|
10
|
+
// The organization: --org, then C11R_ORG, then .c11r.json, then the stored one when exactly one
|
|
11
|
+
// is stored. With several stored and nothing naming one, the command stops and names them.
|
|
12
|
+
// C11R_TOKEN, for CI, is used as is, whatever is stored.
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fail, fixWith } from './errors.js';
|
|
17
|
+
export const LINK_FILE = '.c11r.json';
|
|
18
|
+
export function credentialsFile() {
|
|
19
|
+
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
20
|
+
return path.join(base, 'c11r', 'credentials.json');
|
|
21
|
+
}
|
|
22
|
+
function readCredentials() {
|
|
23
|
+
try {
|
|
24
|
+
const raw = JSON.parse(fs.readFileSync(credentialsFile(), 'utf8'));
|
|
25
|
+
return { ...raw, orgs: raw.orgs && typeof raw.orgs === 'object' ? raw.orgs : {} };
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
if (e.code === 'ENOENT')
|
|
29
|
+
return { orgs: {} };
|
|
30
|
+
fail('invalid_config', `${credentialsFile()} cannot be read: ${e.message}.`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function writeCredentials(c) {
|
|
34
|
+
const file = credentialsFile();
|
|
35
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
36
|
+
// Written aside then renamed, so a crash never leaves half a file; 600 from the start.
|
|
37
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
38
|
+
fs.writeFileSync(tmp, JSON.stringify(c, null, 2) + '\n', { mode: 0o600 });
|
|
39
|
+
fs.chmodSync(tmp, 0o600);
|
|
40
|
+
fs.renameSync(tmp, file);
|
|
41
|
+
}
|
|
42
|
+
export function storedOrgs() {
|
|
43
|
+
return readCredentials().orgs;
|
|
44
|
+
}
|
|
45
|
+
export function storeOrg(slug, org) {
|
|
46
|
+
const c = readCredentials();
|
|
47
|
+
c.orgs[slug] = org;
|
|
48
|
+
writeCredentials(c);
|
|
49
|
+
}
|
|
50
|
+
/** After a reset: the same token, a new organization. */
|
|
51
|
+
export function renameOrg(from, to, patch = {}) {
|
|
52
|
+
const c = readCredentials();
|
|
53
|
+
const entry = c.orgs[from];
|
|
54
|
+
if (!entry)
|
|
55
|
+
return;
|
|
56
|
+
delete c.orgs[from];
|
|
57
|
+
c.orgs[to] = { ...entry, ...patch };
|
|
58
|
+
writeCredentials(c);
|
|
59
|
+
}
|
|
60
|
+
export function forgetOrg(slug) {
|
|
61
|
+
const c = readCredentials();
|
|
62
|
+
if (!c.orgs[slug])
|
|
63
|
+
return;
|
|
64
|
+
delete c.orgs[slug];
|
|
65
|
+
writeCredentials(c);
|
|
66
|
+
}
|
|
67
|
+
export function storedLogin() {
|
|
68
|
+
return readCredentials().login ?? null;
|
|
69
|
+
}
|
|
70
|
+
export function storeLogin(login) {
|
|
71
|
+
const c = readCredentials();
|
|
72
|
+
if (login)
|
|
73
|
+
c.login = login;
|
|
74
|
+
else
|
|
75
|
+
delete c.login;
|
|
76
|
+
writeCredentials(c);
|
|
77
|
+
}
|
|
78
|
+
export function defaultOrg() {
|
|
79
|
+
return readCredentials().default_org ?? null;
|
|
80
|
+
}
|
|
81
|
+
export function setDefaultOrg(org) {
|
|
82
|
+
const c = readCredentials();
|
|
83
|
+
if (org)
|
|
84
|
+
c.default_org = org;
|
|
85
|
+
else
|
|
86
|
+
delete c.default_org;
|
|
87
|
+
writeCredentials(c);
|
|
88
|
+
}
|
|
89
|
+
export function outWritten(org) {
|
|
90
|
+
return Boolean(readCredentials().out_written?.[org]);
|
|
91
|
+
}
|
|
92
|
+
export function markOutWritten(org, written) {
|
|
93
|
+
const c = readCredentials();
|
|
94
|
+
const all = { ...(c.out_written ?? {}) };
|
|
95
|
+
if (written === Boolean(all[org]))
|
|
96
|
+
return;
|
|
97
|
+
if (written)
|
|
98
|
+
all[org] = true;
|
|
99
|
+
else
|
|
100
|
+
delete all[org];
|
|
101
|
+
c.out_written = all;
|
|
102
|
+
writeCredentials(c);
|
|
103
|
+
}
|
|
104
|
+
export function storedBooking(id) {
|
|
105
|
+
return readCredentials().bookings?.[id] ?? null;
|
|
106
|
+
}
|
|
107
|
+
export function storeBooking(id, booking) {
|
|
108
|
+
const c = readCredentials();
|
|
109
|
+
const all = { ...(c.bookings ?? {}) };
|
|
110
|
+
if (booking)
|
|
111
|
+
all[id] = booking;
|
|
112
|
+
else
|
|
113
|
+
delete all[id];
|
|
114
|
+
c.bookings = all;
|
|
115
|
+
writeCredentials(c);
|
|
116
|
+
}
|
|
117
|
+
/** The last four characters, as the API shows them. */
|
|
118
|
+
export const hintOf = (token) => `${token.slice(0, 4)}...${token.slice(-4)}`;
|
|
119
|
+
/** The nearest folder, from the current one up, that holds one of the files. */
|
|
120
|
+
function findUp(names, from = process.cwd()) {
|
|
121
|
+
let dir = path.resolve(from);
|
|
122
|
+
for (;;) {
|
|
123
|
+
if (names.some((n) => fs.existsSync(path.join(dir, n))))
|
|
124
|
+
return dir;
|
|
125
|
+
const up = path.dirname(dir);
|
|
126
|
+
if (up === dir)
|
|
127
|
+
return null;
|
|
128
|
+
dir = up;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** The project folder: where .c11r.json, store.yml and loops.yml go. */
|
|
132
|
+
export function projectDir() {
|
|
133
|
+
return findUp([LINK_FILE]) ?? findUp(['store.yml', 'loops.yml']) ?? process.cwd();
|
|
134
|
+
}
|
|
135
|
+
export function readLink() {
|
|
136
|
+
const dir = findUp([LINK_FILE]);
|
|
137
|
+
if (!dir)
|
|
138
|
+
return null;
|
|
139
|
+
const file = path.join(dir, LINK_FILE);
|
|
140
|
+
let raw;
|
|
141
|
+
try {
|
|
142
|
+
raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
fail('invalid_config', `${file} is not valid JSON: ${e.message}.`, { path: LINK_FILE });
|
|
146
|
+
}
|
|
147
|
+
const org = raw?.org;
|
|
148
|
+
if (typeof org !== 'string' || !org)
|
|
149
|
+
fail('invalid_config', `${file} has no org.`, { path: `${LINK_FILE}.org` });
|
|
150
|
+
return { file, org };
|
|
151
|
+
}
|
|
152
|
+
/** Links the project folder to the organization; returns the file and the org it linked before. */
|
|
153
|
+
export function writeLink(org) {
|
|
154
|
+
const existing = readLink();
|
|
155
|
+
const file = existing?.file ?? path.join(process.cwd(), LINK_FILE);
|
|
156
|
+
fs.writeFileSync(file, JSON.stringify({ org }, null, 2) + '\n');
|
|
157
|
+
return { file, previous: existing && existing.org !== org ? existing.org : null };
|
|
158
|
+
}
|
|
159
|
+
/** Removes the link, only when it names this organization. */
|
|
160
|
+
export function removeLink(org) {
|
|
161
|
+
const link = readLink();
|
|
162
|
+
if (!link || link.org !== org)
|
|
163
|
+
return null;
|
|
164
|
+
fs.rmSync(link.file);
|
|
165
|
+
return link.file;
|
|
166
|
+
}
|
|
167
|
+
/** Which organization, and which token. Never throws: callers decide what a problem means. */
|
|
168
|
+
export function resolveOrg(flag) {
|
|
169
|
+
if (process.env.C11R_TOKEN)
|
|
170
|
+
return { org: flag ?? (process.env.C11R_ORG || null), source: 'token_env', token: process.env.C11R_TOKEN, problem: null };
|
|
171
|
+
const orgs = storedOrgs();
|
|
172
|
+
const named = [
|
|
173
|
+
[flag, 'flag'],
|
|
174
|
+
[process.env.C11R_ORG || undefined, 'env'],
|
|
175
|
+
];
|
|
176
|
+
let link = null;
|
|
177
|
+
try {
|
|
178
|
+
link = readLink();
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
return { org: null, source: null, token: null, problem: { code: 'invalid_config', message: e.message } };
|
|
182
|
+
}
|
|
183
|
+
named.push([link?.org, 'link']);
|
|
184
|
+
named.push([defaultOrg() ?? undefined, 'default']);
|
|
185
|
+
for (const [org, source] of named) {
|
|
186
|
+
if (!org)
|
|
187
|
+
continue;
|
|
188
|
+
const stored = orgs[org];
|
|
189
|
+
if (stored)
|
|
190
|
+
return { org, source, token: stored.token, problem: null };
|
|
191
|
+
const where = source === 'flag' ? '--org' : source === 'env' ? 'C11R_ORG' : source === 'default' ? 'The default organization (c11r auth switch --global)' : link.file;
|
|
192
|
+
return { org, source, token: null, problem: { code: 'no_credentials', message: `${where} names ${org}, and this machine has no token for it.`, orgs: Object.keys(orgs) } };
|
|
193
|
+
}
|
|
194
|
+
const slugs = Object.keys(orgs);
|
|
195
|
+
if (slugs.length === 1)
|
|
196
|
+
return { org: slugs[0], source: 'only_stored', token: orgs[slugs[0]].token, problem: null };
|
|
197
|
+
if (slugs.length > 1)
|
|
198
|
+
return { org: null, source: null, token: null, problem: { code: 'org_required', message: `This machine has tokens for ${slugs.length} organizations (${slugs.join(', ')}), and nothing names one: no --org, no C11R_ORG, no ${LINK_FILE}.`, orgs: slugs } };
|
|
199
|
+
return { org: null, source: null, token: null, problem: null };
|
|
200
|
+
}
|
|
201
|
+
/** The token for a command that needs an organization; a problem becomes its error. */
|
|
202
|
+
export function raiseProblem(r, line) {
|
|
203
|
+
if (!r.problem)
|
|
204
|
+
return;
|
|
205
|
+
const { code, message, orgs } = r.problem;
|
|
206
|
+
if (code === 'org_required')
|
|
207
|
+
fail('org_required', message, { path: 'org', next_step: fixWith(`${line} --org ${orgs[0]}`), details: { orgs } }, 'usage');
|
|
208
|
+
if (code === 'no_credentials')
|
|
209
|
+
fail('no_credentials', message, { path: 'org', next_step: { action: 'none' }, details: { stored: orgs } }, 'auth');
|
|
210
|
+
fail(code, message);
|
|
211
|
+
}
|
package/dist/confirm.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Confirmation for destructive verbs. --yes skips it. With a terminal, it asks on stderr and
|
|
2
|
+
// reads one line. With no terminal (an agent, a pipe, a schedule) it never waits: it fails with
|
|
3
|
+
// confirmation_required, and the agent asks the human, then repeats the command with --yes.
|
|
4
|
+
import readline from 'node:readline';
|
|
5
|
+
import { fail } from './errors.js';
|
|
6
|
+
export const terminal = {
|
|
7
|
+
get interactive() {
|
|
8
|
+
return Boolean(process.stdin.isTTY && process.stderr.isTTY) && process.env.C11R_NO_PROMPT !== '1';
|
|
9
|
+
},
|
|
10
|
+
ask(question) {
|
|
11
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
// Resolve before close(): close emits at once, and its handler would answer '' first.
|
|
14
|
+
rl.question(question, (answer) => {
|
|
15
|
+
resolve(answer);
|
|
16
|
+
rl.close();
|
|
17
|
+
});
|
|
18
|
+
// Ctrl-D or a closed stdin is a no.
|
|
19
|
+
rl.on('close', () => resolve(''));
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
askSecret(question) {
|
|
23
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
24
|
+
// Nothing typed is echoed: only the question is written.
|
|
25
|
+
const out = rl;
|
|
26
|
+
let asked = false;
|
|
27
|
+
out._writeToOutput = (s) => {
|
|
28
|
+
if (!asked) {
|
|
29
|
+
process.stderr.write(s);
|
|
30
|
+
asked = true;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
rl.question(question, (answer) => {
|
|
35
|
+
process.stderr.write('\n');
|
|
36
|
+
resolve(answer);
|
|
37
|
+
rl.close();
|
|
38
|
+
});
|
|
39
|
+
rl.on('close', () => resolve(''));
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
export async function confirm(action, command, opts) {
|
|
44
|
+
if (opts.yes)
|
|
45
|
+
return;
|
|
46
|
+
const p = opts.prompter ?? terminal;
|
|
47
|
+
if (!p.interactive) {
|
|
48
|
+
// The agent asks the human; on a yes it repeats the command with --yes.
|
|
49
|
+
fail('confirmation_required', `${action} needs confirmation, and there is no terminal to ask in. Ask the human whether to go ahead.`, {
|
|
50
|
+
next_step: { action: 'fix_request', command: `${command} --yes` },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const answer = (await p.ask(`${action}. Go ahead? [y/N] `)).trim().toLowerCase();
|
|
54
|
+
if (answer !== 'y' && answer !== 'yes')
|
|
55
|
+
fail('confirmation_declined', `${action}: not confirmed, nothing was changed.`);
|
|
56
|
+
}
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// The grammar engine. c11r <noun> <verb> [id] [--field value ...] becomes, in order: the noun's
|
|
2
|
+
// schema (built in, or from the API), the verb, the id and the typed values, the checks the CLI
|
|
3
|
+
// can make before sending (required flags, an organization to act on), confirmation for a
|
|
4
|
+
// destructive verb, then the request. A plain noun needs no code here: its schema says
|
|
5
|
+
// everything. Every answer is the API's envelope, { data, state, warnings, next }.
|
|
6
|
+
import { parseCommand, parseFields, toFlag } from './argv.js';
|
|
7
|
+
import { raiseProblem, resolveOrg } from './config.js';
|
|
8
|
+
import { confirm } from './confirm.js';
|
|
9
|
+
import { CliError, closest, fail, fixWith } from './errors.js';
|
|
10
|
+
import { indexHelp, nounHelp, verbHelp } from './help.js';
|
|
11
|
+
import { envelope, request } from './http.js';
|
|
12
|
+
import { hasLogin, loginToken } from './login.js';
|
|
13
|
+
import { resourceRequest } from './resources.js';
|
|
14
|
+
import { describePlan, planSecret, writeSecret } from './secrets.js';
|
|
15
|
+
import { verbOf } from './schema.js';
|
|
16
|
+
import { apiNouns, apiSchema } from './schemas.js';
|
|
17
|
+
import { VERSION } from './version.js';
|
|
18
|
+
export async function execute(argv, opts = {}) {
|
|
19
|
+
const cmd = parseCommand(argv);
|
|
20
|
+
const builtins = opts.builtins ?? {};
|
|
21
|
+
if (cmd.globals.version && !cmd.noun)
|
|
22
|
+
return envelope({ version: VERSION });
|
|
23
|
+
if (!cmd.noun || cmd.noun === 'help')
|
|
24
|
+
return help(cmd, opts);
|
|
25
|
+
if (cmd.noun === 'schema')
|
|
26
|
+
return schemaOf(cmd, builtins);
|
|
27
|
+
const schema = builtins[cmd.noun]?.schema ?? (await apiSchema(cmd.noun, Object.keys(builtins)));
|
|
28
|
+
if (cmd.globals.help && !cmd.verb && !builtins[cmd.noun]?.defaultVerb)
|
|
29
|
+
return envelope(nounHelp(schema));
|
|
30
|
+
const verb = verbOf(schema, cmd.verb ?? builtins[cmd.noun]?.defaultVerb);
|
|
31
|
+
if (cmd.globals.help)
|
|
32
|
+
return envelope(verbHelp(schema, verb));
|
|
33
|
+
// A verb the schema lists as refused: no request, no confirmation for something that cannot happen.
|
|
34
|
+
if (verb.refused) {
|
|
35
|
+
const by = verb.refused.by === 'commercelayer' ? 'Commerce Layer' : verb.refused.by;
|
|
36
|
+
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' } });
|
|
37
|
+
}
|
|
38
|
+
const ext = opts.extensions?.[schema.noun];
|
|
39
|
+
const positional = ext?.positional?.[verb.name];
|
|
40
|
+
const rest = positional && !verb.id && cmd.rest[0] !== undefined && !cmd.rest[0].startsWith('-') ? [`--${positional}`, ...cmd.rest] : cmd.rest;
|
|
41
|
+
const parsed = parseFields(schema.noun, verb, rest);
|
|
42
|
+
ext?.prepare?.(verb, parsed);
|
|
43
|
+
check(schema, verb, parsed);
|
|
44
|
+
const line = ['c11r', ...argv.filter((a) => a !== '--yes' && a !== '-y')].map(quote).join(' ');
|
|
45
|
+
const org = resolveOrg(cmd.globals.org);
|
|
46
|
+
const login = hasLogin();
|
|
47
|
+
// The bearer, from the schema (contract section 23). An organization token for the organization
|
|
48
|
+
// nouns, the login for the Provisioning ones; credentials take the login when there is one and
|
|
49
|
+
// the organization token is not a sandbox's.
|
|
50
|
+
let bearer = verb.credential === 'login' ? 'login' : verb.credential === 'organization|login' ? (login && !org.token?.startsWith('sbx_') ? 'login' : 'organization') : verb.credential === 'none' ? 'none' : 'organization';
|
|
51
|
+
// members and roles with an organization token and no login: the API says what is needed (a
|
|
52
|
+
// claim for a sandbox, a login for a claimed organization). organizations and auth stop here.
|
|
53
|
+
if (bearer === 'login' && !login && org.token && schema.noun !== 'organizations' && schema.noun !== 'auth')
|
|
54
|
+
bearer = 'organization';
|
|
55
|
+
// A verb that needs an organization token: say which is missing before the API does.
|
|
56
|
+
if (bearer === 'organization' && verb.state !== 'none' && !org.token) {
|
|
57
|
+
if (org.problem?.code === 'no_credentials' && login && org.org) {
|
|
58
|
+
fail('no_credentials', `${org.problem.message} The login can make one: c11r organizations connect ${org.org}.`, { path: 'org', next_step: { action: 'fix_request', command: `c11r organizations connect ${org.org}` } }, 'auth');
|
|
59
|
+
}
|
|
60
|
+
raiseProblem(org, line);
|
|
61
|
+
fail('state_required', `c11r ${schema.noun} ${verb.name} needs a sandbox, and this machine has none.`, { required_state: 'sandbox', next_step: { action: 'create_sandbox', command: 'c11r sandbox create' } });
|
|
62
|
+
}
|
|
63
|
+
// The login names its organization with ?org=, from --org, C11R_ORG, .c11r.json or the default:
|
|
64
|
+
// never a guess from the only token stored, which may be a sandbox the login does not reach.
|
|
65
|
+
const loginOrg = org.source && org.source !== 'only_stored' ? org.org : null;
|
|
66
|
+
if (cmd.globals.live && !verb.live) {
|
|
67
|
+
fail('state_required', `c11r ${schema.noun} ${verb.name} does not run in live mode.`, { required_state: 'claimed', path: 'live', next_step: fixWith(line.replace(/\s--live\b/, '')) });
|
|
68
|
+
}
|
|
69
|
+
let confirmed = false;
|
|
70
|
+
const confirmFlags = verb.confirmIf.filter((k) => parsed.values[k] !== undefined && parsed.values[k] !== false);
|
|
71
|
+
if ((verb.destructive || confirmFlags.length) && !cmd.globals.dryRun) {
|
|
72
|
+
const what = `${verb.name} ${schema.noun}${parsed.id ? ` ${parsed.id}` : ''}${confirmFlags.length ? ` with ${confirmFlags.map(toFlag).join(' ')}` : ''}`;
|
|
73
|
+
await confirm(what.charAt(0).toUpperCase() + what.slice(1), line, { yes: cmd.globals.yes, prompter: opts.prompter });
|
|
74
|
+
confirmed = true;
|
|
75
|
+
}
|
|
76
|
+
// Where a secret goes, checked before the request makes one.
|
|
77
|
+
const secretPlan = verb.secret ? planSecret({ org: org.org, verb: verb.name, id: parsed.id, values: parsed.values, yes: cmd.globals.yes }) : null;
|
|
78
|
+
const ctx = { live: cmd.globals.live, token: bearer === 'login' ? await loginToken() : org.token };
|
|
79
|
+
const orgQuery = bearer === 'login' && loginOrg && schema.noun !== 'organizations' && schema.noun !== 'auth' ? { org: loginOrg } : {};
|
|
80
|
+
const sendOnce = (req) => request({ ...req, query: { ...(req.query ?? {}), ...orgQuery }, dryRun: req.dryRun ?? cmd.globals.dryRun, confirm: confirmed || req.confirm }, ctx);
|
|
81
|
+
const run = {
|
|
82
|
+
globals: cmd.globals,
|
|
83
|
+
schema,
|
|
84
|
+
verb,
|
|
85
|
+
parsed,
|
|
86
|
+
line,
|
|
87
|
+
org,
|
|
88
|
+
ctx,
|
|
89
|
+
bearer,
|
|
90
|
+
prompter: opts.prompter,
|
|
91
|
+
async send(req) {
|
|
92
|
+
try {
|
|
93
|
+
return await sendOnce(req);
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
// A login token Commerce Layer no longer takes: mint a new one, once.
|
|
97
|
+
if (!(e instanceof CliError && e.code === 'login_required') || !login)
|
|
98
|
+
throw e;
|
|
99
|
+
if (bearer === 'login' && process.env.C11R_PROVISIONING_TOKEN)
|
|
100
|
+
throw e;
|
|
101
|
+
if (bearer === 'organization' && verb.credential !== 'organization|login')
|
|
102
|
+
throw e;
|
|
103
|
+
ctx.token = await loginToken({ fresh: bearer === 'login' });
|
|
104
|
+
run.bearer = 'login';
|
|
105
|
+
if (loginOrg && schema.noun !== 'organizations')
|
|
106
|
+
Object.assign(orgQuery, { org: loginOrg });
|
|
107
|
+
return sendOnce(req);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
const own = (await builtins[schema.noun]?.run?.(run)) ?? (await ext?.run?.(run));
|
|
112
|
+
const r = own ?? (await run.send(resourceRequest(schema.noun, verb, parsed.id, parsed.values)));
|
|
113
|
+
return secretOf(r, run, secretPlan);
|
|
114
|
+
}
|
|
115
|
+
/** A secret in the answer goes to its file; a dry run says where it would go. */
|
|
116
|
+
function secretOf(r, run, plan) {
|
|
117
|
+
const data = r.data;
|
|
118
|
+
const secret = data && typeof data === 'object' ? data.secret : undefined;
|
|
119
|
+
if (secret && typeof secret === 'object' && 'value' in secret) {
|
|
120
|
+
// A verb the schema did not mark: still never printed.
|
|
121
|
+
const p = plan ?? planSecret({ org: run.org.org, verb: run.verb.name, id: run.parsed.id, values: run.parsed.values, yes: true });
|
|
122
|
+
return writeSecret(r, p, { org: run.org.org, verb: run.verb.name, id: run.parsed.id });
|
|
123
|
+
}
|
|
124
|
+
if (plan && run.globals.dryRun)
|
|
125
|
+
return { ...r, data: { ...(data && typeof data === 'object' ? data : { result: data }), secret_file: describePlan(plan) } };
|
|
126
|
+
return r;
|
|
127
|
+
}
|
|
128
|
+
/** What the command needs before anything is sent: the id, the required flags, known --data keys. */
|
|
129
|
+
function check(schema, verb, parsed) {
|
|
130
|
+
const cmd = `c11r ${schema.noun} ${verb.name}`;
|
|
131
|
+
if (verb.id && parsed.id === undefined) {
|
|
132
|
+
const accepts = schema.id?.accepts.length ? ` (or its ${schema.id.accepts.filter((a) => a !== 'id').join(' or ')})` : '';
|
|
133
|
+
fail('missing_id', `${cmd} needs the id${accepts} of one of the ${schema.noun}.`, { path: 'id', next_step: fixWith(`c11r ${schema.noun} list`) });
|
|
134
|
+
}
|
|
135
|
+
const keys = Object.keys(verb.flags);
|
|
136
|
+
for (const key of Object.keys(parsed.values)) {
|
|
137
|
+
if (!verb.flags[key])
|
|
138
|
+
fail('unknown_field', `${cmd} has no field ${key}.`, { path: key, did_you_mean: closest(key, keys), details: { flags: keys.map(toFlag) } });
|
|
139
|
+
}
|
|
140
|
+
const missing = Object.entries(verb.flags)
|
|
141
|
+
.filter(([k, f]) => f.required && parsed.values[k] === undefined)
|
|
142
|
+
.map(([k]) => k);
|
|
143
|
+
if (missing.length)
|
|
144
|
+
fail('missing_argument', `${cmd} needs ${missing.map(toFlag).join(', ')}.`, { path: missing[0], details: { missing: missing.map(toFlag) }, next_step: fixWith(`${cmd} --help`) });
|
|
145
|
+
if (verb.name === 'update' && !Object.keys(parsed.values).length)
|
|
146
|
+
fail('no_fields', `${cmd} needs at least one field to change.`, { details: { flags: keys.map(toFlag) }, next_step: fixWith(`${cmd} --help`) });
|
|
147
|
+
}
|
|
148
|
+
async function help(cmd, opts) {
|
|
149
|
+
const builtins = opts.builtins ?? {};
|
|
150
|
+
// c11r help <noun> [verb] is c11r <noun> [verb] --help.
|
|
151
|
+
if (cmd.noun === 'help' && cmd.verb)
|
|
152
|
+
return execute([cmd.verb, ...cmd.rest.filter((a) => !a.startsWith('-')).slice(0, 1), '--help'], opts);
|
|
153
|
+
const own = [
|
|
154
|
+
...Object.values(builtins).map((b) => ({ noun: b.schema.noun, description: b.schema.description, ...(b.schema.doc.group ? { group: b.schema.doc.group } : {}), ...(b.schema.skill ? { skill: b.schema.skill } : {}) })),
|
|
155
|
+
{ noun: 'schema', description: 'c11r schema <noun> returns a noun\'s fields, filters and verbs, and what a loop policy may automate.' },
|
|
156
|
+
{ noun: 'help', description: 'c11r help [noun [verb]] describes the CLI, a noun or a verb.' },
|
|
157
|
+
];
|
|
158
|
+
try {
|
|
159
|
+
const api = (await apiNouns()).filter((n) => !builtins[n.noun]);
|
|
160
|
+
return envelope(indexHelp(own, api));
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
return envelope(indexHelp(own, null), { warnings: [{ code: 'nouns_incomplete', message: `Only the built-in nouns are listed: ${e.message}` }] });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function schemaOf(cmd, builtins) {
|
|
167
|
+
if (!cmd.verb)
|
|
168
|
+
fail('missing_argument', 'c11r schema needs a noun, as in c11r schema markets.', { next_step: fixWith('c11r help') });
|
|
169
|
+
if (cmd.rest.length)
|
|
170
|
+
fail('unexpected_argument', `c11r schema takes one noun, not ${cmd.rest.join(' ')}.`);
|
|
171
|
+
return envelope((builtins[cmd.verb]?.schema ?? (await apiSchema(cmd.verb, Object.keys(builtins)))).doc);
|
|
172
|
+
}
|
|
173
|
+
const quote = (a) => (/^[\w@%+=:,./#-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`);
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Every failure is one JSON object on stdout, { error, state }, in the API contract's shape
|
|
2
|
+
// (section 3), whether the API or the CLI raised it, and a stable exit code. The code is the
|
|
3
|
+
// contract with the agent: it never changes meaning. next_step says what would make the command
|
|
4
|
+
// work, for the agent to propose to the human; the agent does not retry the failed call.
|
|
5
|
+
/**
|
|
6
|
+
* Exit codes. Stable: an agent or a script can branch on them.
|
|
7
|
+
* 0 success, 1 unexpected, 2 usage, 3 confirmation needed, 4 state or plan needed,
|
|
8
|
+
* 5 not found, 6 refused by the API, 7 API unreachable or failing, 8 authentication.
|
|
9
|
+
*/
|
|
10
|
+
export const EXIT = {
|
|
11
|
+
ok: 0,
|
|
12
|
+
unexpected: 1,
|
|
13
|
+
usage: 2,
|
|
14
|
+
confirmation: 3,
|
|
15
|
+
requirement: 4,
|
|
16
|
+
not_found: 5,
|
|
17
|
+
rejected: 6,
|
|
18
|
+
unavailable: 7,
|
|
19
|
+
auth: 8,
|
|
20
|
+
};
|
|
21
|
+
/** The exit kind of every code the CLI raises, and of the contract's codes (section 3). */
|
|
22
|
+
const KIND = {
|
|
23
|
+
// The CLI's own, before anything is sent.
|
|
24
|
+
unknown_noun: 'usage',
|
|
25
|
+
unknown_verb: 'usage',
|
|
26
|
+
unknown_field: 'usage',
|
|
27
|
+
invalid_value: 'usage',
|
|
28
|
+
missing_value: 'usage',
|
|
29
|
+
missing_argument: 'usage',
|
|
30
|
+
missing_id: 'usage',
|
|
31
|
+
unexpected_argument: 'usage',
|
|
32
|
+
no_fields: 'usage',
|
|
33
|
+
confirmation_declined: 'confirmation',
|
|
34
|
+
invalid_response: 'unavailable',
|
|
35
|
+
schema_unavailable: 'unavailable',
|
|
36
|
+
api_unreachable: 'unavailable',
|
|
37
|
+
invalid_config: 'usage',
|
|
38
|
+
secret_required: 'usage',
|
|
39
|
+
insecure_file: 'usage',
|
|
40
|
+
// A secret the API answered once and the disk refused: nothing else to do but say so.
|
|
41
|
+
secret_not_written: 'unexpected',
|
|
42
|
+
// The contract's.
|
|
43
|
+
confirmation_required: 'confirmation',
|
|
44
|
+
state_required: 'requirement',
|
|
45
|
+
plan_required: 'requirement',
|
|
46
|
+
expired: 'requirement',
|
|
47
|
+
limit_reached: 'requirement',
|
|
48
|
+
not_found: 'not_found',
|
|
49
|
+
invalid_argument: 'rejected',
|
|
50
|
+
invalid_transition: 'rejected',
|
|
51
|
+
conflict: 'rejected',
|
|
52
|
+
dependency_missing: 'rejected',
|
|
53
|
+
commercelayer_refused: 'rejected',
|
|
54
|
+
policy_forbidden: 'rejected',
|
|
55
|
+
not_supported: 'rejected',
|
|
56
|
+
rate_limited: 'unavailable',
|
|
57
|
+
commercelayer_busy: 'unavailable',
|
|
58
|
+
commercelayer_unavailable: 'unavailable',
|
|
59
|
+
api_error: 'unavailable',
|
|
60
|
+
unauthorized: 'auth',
|
|
61
|
+
login_required: 'auth',
|
|
62
|
+
no_credentials: 'auth',
|
|
63
|
+
forbidden: 'rejected',
|
|
64
|
+
claim_not_found: 'not_found',
|
|
65
|
+
};
|
|
66
|
+
/** For an API error whose code the CLI does not know: from the HTTP status. */
|
|
67
|
+
export function kindOfStatus(status) {
|
|
68
|
+
if (status === 401)
|
|
69
|
+
return 'auth';
|
|
70
|
+
if (status === 403 || status === 410)
|
|
71
|
+
return 'requirement';
|
|
72
|
+
if (status === 404)
|
|
73
|
+
return 'not_found';
|
|
74
|
+
if (status === 428)
|
|
75
|
+
return 'confirmation';
|
|
76
|
+
if (status === 429 || status >= 500)
|
|
77
|
+
return 'unavailable';
|
|
78
|
+
if (status >= 400)
|
|
79
|
+
return 'rejected';
|
|
80
|
+
return 'unexpected';
|
|
81
|
+
}
|
|
82
|
+
export class CliError extends Error {
|
|
83
|
+
body;
|
|
84
|
+
kind;
|
|
85
|
+
/** The API's state envelope, when the error came from the API. */
|
|
86
|
+
state;
|
|
87
|
+
constructor(body, kind, state = null) {
|
|
88
|
+
super(body.message);
|
|
89
|
+
this.body = body;
|
|
90
|
+
this.kind = kind ?? KIND[body.code] ?? 'unexpected';
|
|
91
|
+
this.state = state;
|
|
92
|
+
}
|
|
93
|
+
get code() {
|
|
94
|
+
return this.body.code;
|
|
95
|
+
}
|
|
96
|
+
get exitCode() {
|
|
97
|
+
return EXIT[this.kind];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export function errorBody(code, message, extra = {}) {
|
|
101
|
+
const { next_step, required_state = null, required_plan = null, missing = null, path = null, ...rest } = extra;
|
|
102
|
+
return {
|
|
103
|
+
code,
|
|
104
|
+
message,
|
|
105
|
+
required_state,
|
|
106
|
+
required_plan,
|
|
107
|
+
missing,
|
|
108
|
+
path,
|
|
109
|
+
next_step: next_step ? { action: next_step.action, command: next_step.command ?? null, url: next_step.url ?? null } : null,
|
|
110
|
+
...rest,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
export function fail(code, message, extra = {}, kind) {
|
|
114
|
+
throw new CliError(errorBody(code, message, extra), kind);
|
|
115
|
+
}
|
|
116
|
+
/** A next step that fixes the command: the command to look at, or to run instead. */
|
|
117
|
+
export const fixWith = (command) => ({ action: 'fix_request', command });
|
|
118
|
+
/** Any thrown value as a CliError: unexpected ones keep their message. */
|
|
119
|
+
export function asCliError(e) {
|
|
120
|
+
if (e instanceof CliError)
|
|
121
|
+
return e;
|
|
122
|
+
return new CliError(errorBody('unexpected', `c11r failed unexpectedly: ${e instanceof Error ? e.message : String(e)}. This is a bug in c11r.`), 'unexpected');
|
|
123
|
+
}
|
|
124
|
+
/** Close matches by edit distance, for did_you_mean. */
|
|
125
|
+
export function closest(word, candidates, max = 3) {
|
|
126
|
+
const scored = [...candidates].map((c) => ({ c, d: distance(word.toLowerCase(), c.toLowerCase()) }));
|
|
127
|
+
const limit = Math.max(2, Math.floor(word.length / 3));
|
|
128
|
+
return scored
|
|
129
|
+
.filter((s) => s.d <= limit || s.c.startsWith(word) || word.startsWith(s.c))
|
|
130
|
+
.sort((a, b) => a.d - b.d || (a.c < b.c ? -1 : 1))
|
|
131
|
+
.slice(0, max)
|
|
132
|
+
.map((s) => s.c);
|
|
133
|
+
}
|
|
134
|
+
function distance(a, b) {
|
|
135
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
136
|
+
for (let i = 1; i <= a.length; i++) {
|
|
137
|
+
let prev = row[0];
|
|
138
|
+
row[0] = i;
|
|
139
|
+
for (let j = 1; j <= b.length; j++) {
|
|
140
|
+
const tmp = row[j];
|
|
141
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
142
|
+
prev = tmp;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return row[b.length];
|
|
146
|
+
}
|
package/dist/help.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Help, as JSON in the envelope like everything else. It follows the conversational rule: it says
|
|
2
|
+
// what a command does and what it returns, never that a human should run something. The agent
|
|
3
|
+
// reads it and offers to do the work.
|
|
4
|
+
import { toFlag } from './argv.js';
|
|
5
|
+
import { EXIT } from './errors.js';
|
|
6
|
+
export const USAGE = 'c11r <noun> <verb> [id] [--field value ...]';
|
|
7
|
+
export const GLOBAL_FLAG_HELP = {
|
|
8
|
+
'--json': 'Output as JSON. The default, and the only format: one object on stdout, { data, state, warnings, next } or { error, state }.',
|
|
9
|
+
'--dry-run': 'On a write, the API checks everything and says what would change, and changes nothing. Reads ignore it.',
|
|
10
|
+
'--yes': 'Confirms a destructive verb in advance. Without it a destructive verb asks in the terminal, or, with no terminal, stops with confirmation_required.',
|
|
11
|
+
'--org <slug>': 'The organization to act on, by its stored token. Without it: C11R_ORG, then the folder\'s .c11r.json, then the only stored organization.',
|
|
12
|
+
'--live': 'Acts on live data, on a claimed organization. Without it every command works in test mode.',
|
|
13
|
+
'--help': 'Describes the noun or verb it follows, from its schema.',
|
|
14
|
+
};
|
|
15
|
+
export const EXIT_CODE_HELP = {
|
|
16
|
+
[EXIT.ok]: 'done',
|
|
17
|
+
[EXIT.unexpected]: 'unexpected failure, a bug in c11r',
|
|
18
|
+
[EXIT.usage]: 'the command is not valid: unknown noun, verb or flag, a bad value, or no organization named',
|
|
19
|
+
[EXIT.confirmation]: 'a destructive verb needs confirmation (--yes)',
|
|
20
|
+
[EXIT.requirement]: 'the organization needs another state or plan, has expired, or reached a limit',
|
|
21
|
+
[EXIT.not_found]: 'no such resource',
|
|
22
|
+
[EXIT.rejected]: 'the API or Commerce Layer refused the request as it is',
|
|
23
|
+
[EXIT.unavailable]: 'the API did not answer, is busy, or answered with an error of its own',
|
|
24
|
+
[EXIT.auth]: 'a wrong or revoked token, or no token stored for the organization named',
|
|
25
|
+
};
|
|
26
|
+
export function indexHelp(builtins, api) {
|
|
27
|
+
return {
|
|
28
|
+
usage: USAGE,
|
|
29
|
+
description: 'c11r is the Commerce Layer CLI for coding agents. Every answer, errors included, is one JSON object on stdout.',
|
|
30
|
+
nouns: [...builtins, ...(api ?? [])].sort((a, b) => (a.noun < b.noun ? -1 : 1)),
|
|
31
|
+
standard_verbs: 'list, get, create, update, delete. Nouns with a state machine add their own verbs.',
|
|
32
|
+
flags: 'Fields in kebab or snake case (--price-list, --price_list). On list, --<field> <value> filters by equality and --<field>.<op> <value> by another operator (--created-at.gte 2026-11-01; in takes values separated by commas). A dotted flag sets part of an object (--customer.email). An array takes a JSON array, values separated by commas, or the flag repeated; an array of objects takes key=value pairs (--items sku=COFFEE,quantity=2). --data takes the fields as one JSON object, or @ and a file name.',
|
|
33
|
+
global_flags: GLOBAL_FLAG_HELP,
|
|
34
|
+
exit_codes: EXIT_CODE_HELP,
|
|
35
|
+
more: 'c11r <noun> --help describes a noun and its verbs; c11r <noun> <verb> --help a verb and its flags; c11r schema <noun> returns the full schema.',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function nounHelp(s) {
|
|
39
|
+
return {
|
|
40
|
+
noun: s.noun,
|
|
41
|
+
description: s.description,
|
|
42
|
+
...(s.skill ? { skill: s.skill } : {}),
|
|
43
|
+
...(s.id?.accepts.length ? { id: `The id, or ${s.id.accepts.filter((a) => a !== 'id').join(' or ')}${s.id.example ? `, as in ${s.id.example}` : ''}.` } : {}),
|
|
44
|
+
verbs: Object.values(s.verbs).map((v) => ({ verb: v.name, usage: usageOf(s.noun, v), description: v.description, ...(v.destructive ? { destructive: true } : {}), ...(v.loopable ? { loopable: true } : {}), ...(v.refused ? { served: false, refused_by: v.refused.by } : {}) })),
|
|
45
|
+
...(s.waiting ? { waiting: s.waiting } : {}),
|
|
46
|
+
...(s.loop ? { loop: s.loop } : {}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function verbHelp(s, v) {
|
|
50
|
+
return {
|
|
51
|
+
usage: usageOf(s.noun, v),
|
|
52
|
+
description: v.description,
|
|
53
|
+
state: v.state,
|
|
54
|
+
...(v.plan ? { plan: v.plan } : {}),
|
|
55
|
+
...(v.refused ? { served: false, refused_by: v.refused.by } : {}),
|
|
56
|
+
...(v.destructive ? { destructive: true, confirmation: 'Asks for confirmation unless --yes; with no terminal and no --yes it stops with confirmation_required.' } : {}),
|
|
57
|
+
...(v.loopable ? { loopable: 'A loop policy may allow this verb.' } : {}),
|
|
58
|
+
...(v.live ? {} : { live: 'Not in live mode.' }),
|
|
59
|
+
flags: Object.entries(v.flags).map(([key, f]) => flagHelp(key, f)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function flagHelp(key, f) {
|
|
63
|
+
return {
|
|
64
|
+
flag: toFlag(key),
|
|
65
|
+
type: f.type === 'array' ? `array of ${f.items ?? 'string'}` : f.type === 'ref' ? `${f.noun ?? 'resource'} id` : f.type === 'money' ? 'amount in major units' : f.type,
|
|
66
|
+
...(f.required ? { required: true } : {}),
|
|
67
|
+
...(f.values ? { values: f.values } : {}),
|
|
68
|
+
...(f.filter ? { filter: f.filter.op } : {}),
|
|
69
|
+
...(f.description ? { description: f.description } : {}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function usageOf(noun, v) {
|
|
73
|
+
const required = Object.entries(v.flags)
|
|
74
|
+
.filter(([, f]) => f.required)
|
|
75
|
+
.map(([k, f]) => `${toFlag(k)} <${f.type === 'enum' ? f.values?.join('|') : f.type}>`);
|
|
76
|
+
const optional = Object.values(v.flags).some((f) => !f.required);
|
|
77
|
+
return [`c11r ${noun} ${v.name}`, v.id ? '<id>' : '', ...required, optional ? '[--field value ...]' : ''].filter(Boolean).join(' ');
|
|
78
|
+
}
|