@motherbase/cli 0.2.6 → 0.2.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.
Files changed (62) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +8 -1
  3. package/bin/.motherbase/config.json +5 -0
  4. package/bin/motherbase.js +10 -10
  5. package/package.json +4 -4
  6. package/src/api.js +39 -16
  7. package/src/commands/_util.js +106 -106
  8. package/src/commands/backups.js +16 -16
  9. package/src/commands/branches.js +113 -113
  10. package/src/commands/completion.js +90 -90
  11. package/src/commands/config.js +47 -47
  12. package/src/commands/cron.js +65 -65
  13. package/src/commands/db-catalog.js +39 -1
  14. package/src/commands/db.js +103 -103
  15. package/src/commands/domains.js +124 -124
  16. package/src/commands/gen.js +178 -172
  17. package/src/commands/import.js +55 -55
  18. package/src/commands/index.js +86 -85
  19. package/src/commands/inspect.js +178 -178
  20. package/src/commands/keys.js +72 -72
  21. package/src/commands/link.js +35 -35
  22. package/src/commands/local.js +101 -24
  23. package/src/commands/login.js +9 -3
  24. package/src/commands/logout.js +16 -16
  25. package/src/commands/mcp.js +61 -61
  26. package/src/commands/migrate.js +0 -5
  27. package/src/commands/migration.js +1 -1
  28. package/src/commands/network.js +83 -83
  29. package/src/commands/orgs.js +40 -40
  30. package/src/commands/postgres-config.js +113 -113
  31. package/src/commands/projects.js +131 -130
  32. package/src/commands/queues.js +133 -133
  33. package/src/commands/realtime.js +35 -35
  34. package/src/commands/rls.js +176 -176
  35. package/src/commands/secrets.js +66 -66
  36. package/src/commands/signing.js +130 -130
  37. package/src/commands/snippets.js +44 -44
  38. package/src/commands/status.js +42 -42
  39. package/src/commands/storage.js +261 -261
  40. package/src/commands/test.js +9 -9
  41. package/src/commands/unlink.js +15 -15
  42. package/src/commands/users.js +29 -1
  43. package/src/commands/vault.js +82 -82
  44. package/src/commands/webhooks.js +62 -62
  45. package/src/commands/whoami.js +20 -20
  46. package/src/commands/wrappers.js +150 -150
  47. package/src/config.js +98 -98
  48. package/src/context.js +106 -106
  49. package/src/edge-templates.js +194 -194
  50. package/src/format.js +92 -92
  51. package/src/inspect/csv.js +22 -22
  52. package/src/inspect/queries.js +562 -562
  53. package/src/main.js +145 -66
  54. package/src/mcp/platform-tools.js +233 -233
  55. package/src/mcp/protocol.js +152 -152
  56. package/src/mcp/storage-tools.js +93 -93
  57. package/src/mcp/tools.js +478 -478
  58. package/src/migration/commands.js +2 -2
  59. package/src/migration/files.js +13 -0
  60. package/src/prompt.js +91 -91
  61. package/src/storage/remote.js +62 -62
  62. package/src/ui.js +111 -111
@@ -1,130 +1,130 @@
1
- 'use strict';
2
-
3
- // Local key material, the halves of `gen` that need no server: signing keys
4
- // (`gen signing-key`), tokens signed with them (`gen bearer-jwt`) and a fresh
5
- // set of stack secrets (`gen keys`). Node's crypto does all of it; the CLI has
6
- // no dependencies and this does not add one.
7
-
8
- const crypto = require('crypto');
9
- const fs = require('fs');
10
- const path = require('path');
11
- const api = require('../api');
12
- const { output } = require('./_util');
13
- const { CliError } = require('../context');
14
-
15
- const DEFAULT_KEY_FILE = path.join('motherbase', 'signing_keys.json');
16
- const ALGORITHMS = {
17
- ES256: () => crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }),
18
- RS256: () => crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }),
19
- EdDSA: () => crypto.generateKeyPairSync('ed25519'),
20
- };
21
-
22
- /** A private JWK with the kid and alg the server keeps when it is imported. */
23
- function generateSigningKey(algorithm = 'ES256') {
24
- const generate = ALGORITHMS[algorithm];
25
- if (!generate) throw new CliError(`--algorithm must be one of: ${Object.keys(ALGORITHMS).join(', ')}`);
26
- const { privateKey } = generate();
27
- return { ...privateKey.export({ format: 'jwk' }), kid: crypto.randomUUID(), alg: algorithm, use: 'sig' };
28
- }
29
-
30
- function algorithmOf(jwk) {
31
- if (jwk.kty === 'EC' && jwk.crv === 'P-256') return 'ES256';
32
- if (jwk.kty === 'RSA') return 'RS256';
33
- if (jwk.kty === 'OKP' && jwk.crv === 'Ed25519') return 'EdDSA';
34
- throw new CliError('The signing key must be an ES256, RS256 or EdDSA private JWK.');
35
- }
36
-
37
- const b64url = value => Buffer.from(typeof value === 'string' ? value : JSON.stringify(value)).toString('base64url');
38
-
39
- /** A compact JWS over `claims`, signed with a private JWK. */
40
- function signJwt(jwk, claims) {
41
- const alg = algorithmOf(jwk);
42
- const input = `${b64url({ alg, kid: jwk.kid, typ: 'JWT' })}.${b64url(claims)}`;
43
- const key = crypto.createPrivateKey({ key: jwk, format: 'jwk' });
44
- const signature = alg === 'EdDSA'
45
- ? crypto.sign(null, Buffer.from(input), key)
46
- : crypto.sign('sha256', Buffer.from(input), alg === 'ES256' ? { key, dsaEncoding: 'ieee-p1363' } : key);
47
- return `${input}.${signature.toString('base64url')}`;
48
- }
49
-
50
- /** `30s`, `15m`, `2h`, `7d`, or plain seconds. */
51
- function parseDuration(value) {
52
- const match = /^(\d+)([smhd]?)$/.exec(String(value).trim());
53
- if (!match) throw new CliError(`invalid duration "${value}" (use e.g. 30m, 2h, 7d)`);
54
- return Number(match[1]) * { '': 1, s: 1, m: 60, h: 3600, d: 86400 }[match[2]];
55
- }
56
-
57
- function readKeys(file) {
58
- if (!fs.existsSync(file)) {
59
- throw new CliError(`${file} does not exist. Run \`motherbase gen signing-key --append\` first.`);
60
- }
61
- const keys = JSON.parse(fs.readFileSync(file, 'utf8'));
62
- if (!Array.isArray(keys) || !keys.length) throw new CliError(`${file} holds no keys.`);
63
- return keys;
64
- }
65
-
66
- const signingKey = {
67
- summary: 'Generate a JWT signing key (a private JWK) to import into a project',
68
- usage: 'motherbase gen signing-key [--algorithm ES256|RS256|EdDSA] [--append] [--key-file motherbase/signing_keys.json]',
69
- async run(ctx, _args, flags) {
70
- const key = generateSigningKey(flags.algorithm || 'ES256');
71
- if (!flags.append) {
72
- output(ctx, key, (d, ui) => ui.print(JSON.stringify(d, null, 2)));
73
- return;
74
- }
75
- const file = path.resolve(ctx.cwd, flags['key-file'] || DEFAULT_KEY_FILE);
76
- const keys = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : [];
77
- fs.mkdirSync(path.dirname(file), { recursive: true });
78
- // Private keys: readable by the owner only.
79
- fs.writeFileSync(file, JSON.stringify([...keys, key], null, 2) + '\n', { mode: 0o600 });
80
- output(ctx, { kid: key.kid, alg: key.alg, file }, (d, ui) => {
81
- ui.success(`Appended ${d.alg} key ${d.kid} to ${d.file}`);
82
- ui.info('Import it in Studio (Project Settings > JWT Keys) to have the project accept its tokens.');
83
- });
84
- },
85
- };
86
-
87
- const bearerJwt = {
88
- summary: 'Sign an access token with a local signing key, for calling the API as a user',
89
- usage: 'motherbase gen bearer-jwt --sub <user id> [--role authenticated] [--valid-for 1h | --exp <unix>] [--payload <json>] [--issuer <url>] [--key-file <file>]',
90
- async run(ctx, _args, flags) {
91
- const [jwk] = readKeys(path.resolve(ctx.cwd, flags['key-file'] || DEFAULT_KEY_FILE));
92
- if (!flags.sub) throw new CliError('--sub is required: the project resolves the token to that user.');
93
- // The project verifies iss and aud against its own URL; ask it rather than guess.
94
- const issuer = flags.issuer || (await api.adminRequest(ctx, 'GET', '/auth/signing-keys')).issuer;
95
- const now = Math.floor(Date.now() / 1000);
96
- const extra = flags.payload ? JSON.parse(String(flags.payload)) : {};
97
- const claims = {
98
- ...extra,
99
- sub: String(flags.sub),
100
- role: flags.role || extra.role || 'authenticated',
101
- iss: issuer,
102
- aud: flags.audience || issuer,
103
- iat: now,
104
- exp: flags.exp ? Number(flags.exp) : now + parseDuration(flags['valid-for'] || '1h'),
105
- };
106
- const token = signJwt(jwk, claims);
107
- output(ctx, { token, kid: jwk.kid, claims }, d => process.stdout.write(`${d.token}\n`));
108
- },
109
- };
110
-
111
- const hex = n => crypto.randomBytes(n).toString('hex');
112
-
113
- const keys = {
114
- summary: 'Generate a fresh set of secrets for a local stack (use -o env)',
115
- usage: 'motherbase gen keys [-o env]',
116
- async run(ctx) {
117
- const data = {
118
- JWT_SECRET: hex(32),
119
- CONFIG_ENCRYPTION_KEY: crypto.randomBytes(32).toString('base64'),
120
- PLATFORM_SERVICE_TOKEN: hex(32),
121
- EDGE_RUNTIME_TOKEN: hex(32),
122
- POSTGRES_PASSWORD: hex(16),
123
- S3_ACCESS_KEY_ID: hex(8),
124
- S3_SECRET_ACCESS_KEY: hex(20),
125
- };
126
- output(ctx, data, (d, ui) => ui.keyValue(d));
127
- },
128
- };
129
-
130
- module.exports = { signingKey, bearerJwt, keys, generateSigningKey, signJwt, parseDuration };
1
+ 'use strict';
2
+
3
+ // Local key material, the halves of `gen` that need no server: signing keys
4
+ // (`gen signing-key`), tokens signed with them (`gen bearer-jwt`) and a fresh
5
+ // set of stack secrets (`gen keys`). Node's crypto does all of it; the CLI has
6
+ // no dependencies and this does not add one.
7
+
8
+ const crypto = require('crypto');
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const api = require('../api');
12
+ const { output } = require('./_util');
13
+ const { CliError } = require('../context');
14
+
15
+ const DEFAULT_KEY_FILE = path.join('motherbase', 'signing_keys.json');
16
+ const ALGORITHMS = {
17
+ ES256: () => crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }),
18
+ RS256: () => crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }),
19
+ EdDSA: () => crypto.generateKeyPairSync('ed25519'),
20
+ };
21
+
22
+ /** A private JWK with the kid and alg the server keeps when it is imported. */
23
+ function generateSigningKey(algorithm = 'ES256') {
24
+ const generate = ALGORITHMS[algorithm];
25
+ if (!generate) throw new CliError(`--algorithm must be one of: ${Object.keys(ALGORITHMS).join(', ')}`);
26
+ const { privateKey } = generate();
27
+ return { ...privateKey.export({ format: 'jwk' }), kid: crypto.randomUUID(), alg: algorithm, use: 'sig' };
28
+ }
29
+
30
+ function algorithmOf(jwk) {
31
+ if (jwk.kty === 'EC' && jwk.crv === 'P-256') return 'ES256';
32
+ if (jwk.kty === 'RSA') return 'RS256';
33
+ if (jwk.kty === 'OKP' && jwk.crv === 'Ed25519') return 'EdDSA';
34
+ throw new CliError('The signing key must be an ES256, RS256 or EdDSA private JWK.');
35
+ }
36
+
37
+ const b64url = value => Buffer.from(typeof value === 'string' ? value : JSON.stringify(value)).toString('base64url');
38
+
39
+ /** A compact JWS over `claims`, signed with a private JWK. */
40
+ function signJwt(jwk, claims) {
41
+ const alg = algorithmOf(jwk);
42
+ const input = `${b64url({ alg, kid: jwk.kid, typ: 'JWT' })}.${b64url(claims)}`;
43
+ const key = crypto.createPrivateKey({ key: jwk, format: 'jwk' });
44
+ const signature = alg === 'EdDSA'
45
+ ? crypto.sign(null, Buffer.from(input), key)
46
+ : crypto.sign('sha256', Buffer.from(input), alg === 'ES256' ? { key, dsaEncoding: 'ieee-p1363' } : key);
47
+ return `${input}.${signature.toString('base64url')}`;
48
+ }
49
+
50
+ /** `30s`, `15m`, `2h`, `7d`, or plain seconds. */
51
+ function parseDuration(value) {
52
+ const match = /^(\d+)([smhd]?)$/.exec(String(value).trim());
53
+ if (!match) throw new CliError(`invalid duration "${value}" (use e.g. 30m, 2h, 7d)`);
54
+ return Number(match[1]) * { '': 1, s: 1, m: 60, h: 3600, d: 86400 }[match[2]];
55
+ }
56
+
57
+ function readKeys(file) {
58
+ if (!fs.existsSync(file)) {
59
+ throw new CliError(`${file} does not exist. Run \`motherbase gen signing-key --append\` first.`);
60
+ }
61
+ const keys = JSON.parse(fs.readFileSync(file, 'utf8'));
62
+ if (!Array.isArray(keys) || !keys.length) throw new CliError(`${file} holds no keys.`);
63
+ return keys;
64
+ }
65
+
66
+ const signingKey = {
67
+ summary: 'Generate a JWT signing key (a private JWK) to import into a project',
68
+ usage: 'motherbase gen signing-key [--algorithm ES256|RS256|EdDSA] [--append] [--key-file motherbase/signing_keys.json]',
69
+ async run(ctx, _args, flags) {
70
+ const key = generateSigningKey(flags.algorithm || 'ES256');
71
+ if (!flags.append) {
72
+ output(ctx, key, (d, ui) => ui.print(JSON.stringify(d, null, 2)));
73
+ return;
74
+ }
75
+ const file = path.resolve(ctx.cwd, flags['key-file'] || DEFAULT_KEY_FILE);
76
+ const keys = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : [];
77
+ fs.mkdirSync(path.dirname(file), { recursive: true });
78
+ // Private keys: readable by the owner only.
79
+ fs.writeFileSync(file, JSON.stringify([...keys, key], null, 2) + '\n', { mode: 0o600 });
80
+ output(ctx, { kid: key.kid, alg: key.alg, file }, (d, ui) => {
81
+ ui.success(`Appended ${d.alg} key ${d.kid} to ${d.file}`);
82
+ ui.info('Import it in Studio (Project Settings > JWT Keys) to have the project accept its tokens.');
83
+ });
84
+ },
85
+ };
86
+
87
+ const bearerJwt = {
88
+ summary: 'Sign an access token with a local signing key, for calling the API as a user',
89
+ usage: 'motherbase gen bearer-jwt --sub <user id> [--role authenticated] [--valid-for 1h | --exp <unix>] [--payload <json>] [--issuer <url>] [--key-file <file>]',
90
+ async run(ctx, _args, flags) {
91
+ const [jwk] = readKeys(path.resolve(ctx.cwd, flags['key-file'] || DEFAULT_KEY_FILE));
92
+ if (!flags.sub) throw new CliError('--sub is required: the project resolves the token to that user.');
93
+ // The project verifies iss and aud against its own URL; ask it rather than guess.
94
+ const issuer = flags.issuer || (await api.adminRequest(ctx, 'GET', '/auth/signing-keys')).issuer;
95
+ const now = Math.floor(Date.now() / 1000);
96
+ const extra = flags.payload ? JSON.parse(String(flags.payload)) : {};
97
+ const claims = {
98
+ ...extra,
99
+ sub: String(flags.sub),
100
+ role: flags.role || extra.role || 'authenticated',
101
+ iss: issuer,
102
+ aud: flags.audience || issuer,
103
+ iat: now,
104
+ exp: flags.exp ? Number(flags.exp) : now + parseDuration(flags['valid-for'] || '1h'),
105
+ };
106
+ const token = signJwt(jwk, claims);
107
+ output(ctx, { token, kid: jwk.kid, claims }, d => process.stdout.write(`${d.token}\n`));
108
+ },
109
+ };
110
+
111
+ const hex = n => crypto.randomBytes(n).toString('hex');
112
+
113
+ const keys = {
114
+ summary: 'Generate a fresh set of secrets for a local stack (use -o env)',
115
+ usage: 'motherbase gen keys [-o env]',
116
+ async run(ctx) {
117
+ const data = {
118
+ JWT_SECRET: hex(32),
119
+ CONFIG_ENCRYPTION_KEY: crypto.randomBytes(32).toString('base64'),
120
+ PLATFORM_SERVICE_TOKEN: hex(32),
121
+ EDGE_RUNTIME_TOKEN: hex(32),
122
+ POSTGRES_PASSWORD: hex(16),
123
+ S3_ACCESS_KEY_ID: hex(8),
124
+ S3_SECRET_ACCESS_KEY: hex(20),
125
+ };
126
+ output(ctx, data, (d, ui) => ui.keyValue(d));
127
+ },
128
+ };
129
+
130
+ module.exports = { signingKey, bearerJwt, keys, generateSigningKey, signJwt, parseDuration };
@@ -1,44 +1,44 @@
1
- 'use strict';
2
-
3
- const api = require('../api');
4
- const { output, requireArg } = require('./_util');
5
-
6
- // SQL Editor snippets, kept by the project (see the Studio's SQL Editor). Like
7
- // `supabase snippets`: list what you can see, download one as plain SQL.
8
- const subcommands = {
9
- list: {
10
- summary: 'List SQL snippets (yours and the ones shared with the project)',
11
- usage: 'motherbase snippets list',
12
- async run(ctx) {
13
- const data = await api.adminRequest(ctx, 'GET', '/content', { query: { type: 'sql' } });
14
- const items = (data && data.items) || [];
15
- output(ctx, data, (_d, ui) =>
16
- ui.table(items.map(item => ({ ...item, visibility: item.visibility === 'project' ? 'shared' : 'private' })), [
17
- { key: 'id', label: 'ID' },
18
- { key: 'name', label: 'NAME' },
19
- { key: 'visibility', label: 'VISIBILITY' },
20
- { key: 'ownerId', label: 'OWNER' },
21
- { key: 'updatedAt', label: 'UPDATED' },
22
- ]),
23
- );
24
- },
25
- },
26
- download: {
27
- summary: 'Print a snippet as plain SQL (redirect it to a file to save it)',
28
- usage: 'motherbase snippets download <id>',
29
- async run(ctx, args) {
30
- const id = requireArg(args[0], 'id');
31
- const data = await api.adminRequest(ctx, 'GET', `/content/${encodeURIComponent(id)}`);
32
- const item = data && data.item;
33
- // Structured output keeps the whole record; the default is the bare SQL,
34
- // with no decoration, so `> query.sql` writes a runnable file.
35
- output(ctx, item, () => process.stdout.write(`${item.content.sql}\n`));
36
- },
37
- },
38
- };
39
-
40
- module.exports = {
41
- summary: 'SQL Editor snippets',
42
- usage: 'motherbase snippets <list|download> ...',
43
- subcommands,
44
- };
1
+ 'use strict';
2
+
3
+ const api = require('../api');
4
+ const { output, requireArg } = require('./_util');
5
+
6
+ // SQL Editor snippets, kept by the project (see the Studio's SQL Editor). Like
7
+ // `supabase snippets`: list what you can see, download one as plain SQL.
8
+ const subcommands = {
9
+ list: {
10
+ summary: 'List SQL snippets (yours and the ones shared with the project)',
11
+ usage: 'motherbase snippets list',
12
+ async run(ctx) {
13
+ const data = await api.adminRequest(ctx, 'GET', '/content', { query: { type: 'sql' } });
14
+ const items = (data && data.items) || [];
15
+ output(ctx, data, (_d, ui) =>
16
+ ui.table(items.map(item => ({ ...item, visibility: item.visibility === 'project' ? 'shared' : 'private' })), [
17
+ { key: 'id', label: 'ID' },
18
+ { key: 'name', label: 'NAME' },
19
+ { key: 'visibility', label: 'VISIBILITY' },
20
+ { key: 'ownerId', label: 'OWNER' },
21
+ { key: 'updatedAt', label: 'UPDATED' },
22
+ ]),
23
+ );
24
+ },
25
+ },
26
+ download: {
27
+ summary: 'Print a snippet as plain SQL (redirect it to a file to save it)',
28
+ usage: 'motherbase snippets download <id>',
29
+ async run(ctx, args) {
30
+ const id = requireArg(args[0], 'id');
31
+ const data = await api.adminRequest(ctx, 'GET', `/content/${encodeURIComponent(id)}`);
32
+ const item = data && data.item;
33
+ // Structured output keeps the whole record; the default is the bare SQL,
34
+ // with no decoration, so `> query.sql` writes a runnable file.
35
+ output(ctx, item, () => process.stdout.write(`${item.content.sql}\n`));
36
+ },
37
+ },
38
+ };
39
+
40
+ module.exports = {
41
+ summary: 'SQL Editor snippets',
42
+ usage: 'motherbase snippets <list|download> ...',
43
+ subcommands,
44
+ };
@@ -1,42 +1,42 @@
1
- 'use strict';
2
-
3
- const api = require('../api');
4
- const { output } = require('./_util');
5
-
6
- module.exports = {
7
- summary: 'Show connection status: console, link, and a project health probe',
8
- usage: 'motherbase status',
9
- async run(ctx) {
10
- const data = {
11
- console: ctx.consoleUrl || null,
12
- loggedIn: Boolean(ctx.token),
13
- mode: ctx.mode,
14
- project: ctx.projectId || null,
15
- reachable: null,
16
- api: null,
17
- };
18
-
19
- if (ctx.projectId || ctx.mode === 'direct') {
20
- try {
21
- const info = await api.adminRequest(ctx, 'GET', '/connect-info');
22
- data.reachable = true;
23
- data.api = info && info.apiUrl;
24
- } catch (err) {
25
- data.reachable = false;
26
- data.error = err.message;
27
- }
28
- }
29
-
30
- output(ctx, data, (d, ui) => {
31
- ui.keyValue({
32
- Console: d.console || '(none)',
33
- 'Logged in': d.loggedIn ? 'yes' : 'no',
34
- Mode: d.mode,
35
- Project: d.project || '(not linked)',
36
- Reachable: d.reachable === null ? 'n/a' : d.reachable ? 'yes' : 'no',
37
- 'API URL': d.api || '',
38
- });
39
- if (d.error) ui.warn(d.error);
40
- });
41
- },
42
- };
1
+ 'use strict';
2
+
3
+ const api = require('../api');
4
+ const { output } = require('./_util');
5
+
6
+ module.exports = {
7
+ summary: 'Show connection status: console, link, and a project health probe',
8
+ usage: 'motherbase status',
9
+ async run(ctx) {
10
+ const data = {
11
+ console: ctx.consoleUrl || null,
12
+ loggedIn: Boolean(ctx.token),
13
+ mode: ctx.mode,
14
+ project: ctx.projectId || null,
15
+ reachable: null,
16
+ api: null,
17
+ };
18
+
19
+ if (ctx.projectId || ctx.mode === 'direct') {
20
+ try {
21
+ const info = await api.adminRequest(ctx, 'GET', '/connect-info');
22
+ data.reachable = true;
23
+ data.api = info && info.apiUrl;
24
+ } catch (err) {
25
+ data.reachable = false;
26
+ data.error = err.message;
27
+ }
28
+ }
29
+
30
+ output(ctx, data, (d, ui) => {
31
+ ui.keyValue({
32
+ Console: d.console || '(none)',
33
+ 'Logged in': d.loggedIn ? 'yes' : 'no',
34
+ Mode: d.mode,
35
+ Project: d.project || '(not linked)',
36
+ Reachable: d.reachable === null ? 'n/a' : d.reachable ? 'yes' : 'no',
37
+ 'API URL': d.api || '',
38
+ });
39
+ if (d.error) ui.warn(d.error);
40
+ });
41
+ },
42
+ };