@zeyos/cli 0.4.0 → 0.6.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 CHANGED
@@ -47,6 +47,9 @@ zeyos whoami --json
47
47
  ```
48
48
 
49
49
  `whoami` does not print access tokens by default. Use `zeyos whoami --show-token --json` only when you intentionally need to pass a token to another local tool.
50
+ If a stored refresh token has expired, interactive `whoami` offers to re-authenticate
51
+ and then retries the user lookup. In `--json`/`--yaml` or non-interactive runs, it
52
+ prints a diagnostic plus the matching `zeyos login --force` command instead.
50
53
 
51
54
  Inspect the CLI-supported resource registry:
52
55
 
@@ -77,15 +80,25 @@ Inspect dynamic schema definitions:
77
80
  ```bash
78
81
  zeyos count customfields --json
79
82
  zeyos list customfields --fields ID,name,identifier,context,type --json
83
+ zeyos count dunning --json
80
84
  ```
81
85
 
82
86
  Inspect actionsteps/time-entry evidence and ticket mail:
83
87
 
84
88
  ```bash
85
89
  zeyos list actionsteps --fields ID,name,status,date,duedate,effort,ticket,account --json
90
+ zeyos sum actionsteps effort --filter '{"status":[1,3]}' --json
86
91
  zeyos list messages --fields ID,date,mailbox,subject,sender_email,to_email,ticket,reference --filter '{"ticket":42}' --json
87
92
  ```
88
93
 
94
+ Filters accept common agent-generated shapes and normalize them to the native ZeyOS
95
+ request form. Use `--query --json` to inspect the request without sending it:
96
+
97
+ ```bash
98
+ zeyos list tickets --filter '{"status":{"$nin":[8,9,10]},"priority":[3,4]}' --query --json
99
+ zeyos list accounts --filter '{"name__like":"Acme%","ID__in":[1,2,3]}' --query --json
100
+ ```
101
+
89
102
  Create, update, and delete:
90
103
 
91
104
  ```bash
package/bin/zeyos.mjs CHANGED
@@ -6,10 +6,11 @@
6
6
  *
7
7
  * Commands:
8
8
  * login Authenticate with ZeyOS
9
- * logout Revoke session and clear tokens
9
+ * logout Revoke session and clear stored credentials
10
10
  * whoami Show current user info
11
11
  * list <resource> List records
12
12
  * count <resource> Count records
13
+ * sum <resource> Sum a numeric field across matching records
13
14
  * get <resource> <id> Fetch a single record
14
15
  * show <resource> <id> Alias for get
15
16
  * create <resource> Create a new record
@@ -38,10 +39,11 @@ Usage: ${_z} <command> [options] [args…]
38
39
 
39
40
  ${_c.bold('Commands:')}
40
41
  ${_c.cyan('login')} Authenticate with a ZeyOS instance
41
- ${_c.cyan('logout')} Revoke session and clear stored tokens
42
+ ${_c.cyan('logout')} Revoke session and clear stored credentials
42
43
  ${_c.cyan('whoami')} Show currently authenticated user
43
44
  ${_c.cyan('list')} <resource> List / query records
44
45
  ${_c.cyan('count')} <resource> Count records (with optional filter)
46
+ ${_c.cyan('sum')} <resource> <field> Sum a numeric field
45
47
  ${_c.cyan('get')} <resource> <id> Fetch a single record by ID
46
48
  ${_c.cyan('show')} <resource> <id> Alias for get
47
49
  ${_c.cyan('create')} <resource> Create a new record
@@ -68,6 +70,7 @@ ${_c.bold('Examples:')}
68
70
  ${_z} list tickets --filter '{"status":1}' --sort -lastmodified
69
71
  ${_z} list tickets --filter-file ./filters/open-tickets.json
70
72
  ${_z} count tickets --filter '{"status":1}'
73
+ ${_z} sum actionsteps effort --filter '{"status":[1,3]}'
71
74
  ${_z} get ticket 42
72
75
  ${_z} get ticket 42 --all
73
76
  ${_z} create ticket --name "Fix login bug" --priority 3
@@ -105,6 +108,7 @@ const OPTIONS = {
105
108
  'sort': { type: 'string' },
106
109
  'limit': { type: 'string' },
107
110
  'offset': { type: 'string' },
111
+ 'page-size': { type: 'string' },
108
112
  'expand': { type: 'string' },
109
113
  'extdata': { type: 'boolean' },
110
114
  'tags': { type: 'boolean' },
@@ -127,6 +131,12 @@ const OPTIONS = {
127
131
  'from-current': { type: 'boolean' },
128
132
  };
129
133
 
134
+ const COMMON_COMMAND_HELP = `\
135
+ Global options:
136
+ --profile <name> Use a named credential profile for this command
137
+ --no-color Disable ANSI colors
138
+ `;
139
+
130
140
  // ── Command registry ──────────────────────────────────────────────────────────
131
141
  // Maps every command and alias to the module that implements it.
132
142
 
@@ -136,6 +146,7 @@ const COMMANDS = {
136
146
  whoami: '../commands/whoami.mjs',
137
147
  list: '../commands/list.mjs',
138
148
  count: '../commands/count.mjs',
149
+ sum: '../commands/sum.mjs',
139
150
  get: '../commands/get.mjs',
140
151
  show: '../commands/get.mjs',
141
152
  create: '../commands/create.mjs',
@@ -161,6 +172,7 @@ const COMMANDS = {
161
172
  // exception: they accept arbitrary `--<field>` flags, marked with `null` below.
162
173
 
163
174
  const ALWAYS_FLAGS = ['help', 'json', 'yaml', 'no-color', 'profile'];
175
+ const LEADING_FLAGS = [...ALWAYS_FLAGS, 'version', 'query'];
164
176
  const SKILLS_FLAGS = ['target', 'dir', 'global', 'local', 'force', 'yes', 'no-logo'];
165
177
  const OKF_FLAGS = ['dir', 'out', 'force', 'no-logo'];
166
178
  const PROFILE_FLAGS = ['base-url', 'client-id', 'secret', 'local', 'from-current'];
@@ -173,6 +185,7 @@ const COMMAND_FLAGS = {
173
185
  whoami: ['show-token'],
174
186
  list: ['fields', 'filter', 'filter-file', 'sort', 'limit', 'offset', 'extdata', 'expand', 'query'],
175
187
  count: ['filter', 'filter-file', 'query'],
188
+ sum: ['filter', 'filter-file', 'limit', 'offset', 'page-size', 'query'],
176
189
  get: GET_FLAGS,
177
190
  show: GET_FLAGS,
178
191
  create: null,
@@ -192,6 +205,8 @@ const COMMAND_FLAGS = {
192
205
  profiles: PROFILE_FLAGS,
193
206
  };
194
207
 
208
+ const CREDENTIAL_MUTATION_COMMANDS = new Set(['login', 'logout', 'profile', 'profiles']);
209
+
195
210
  // ── Main ──────────────────────────────────────────────────────────────────────
196
211
 
197
212
  async function main() {
@@ -208,20 +223,37 @@ async function main() {
208
223
  process.exit(0);
209
224
  }
210
225
 
211
- const command = argv[0];
226
+ const lead = _splitLeadingFlags(argv);
227
+ if (lead.values.help && lead.argv.length === 0) {
228
+ process.stdout.write(HELP);
229
+ process.exit(0);
230
+ }
231
+ if (lead.values.version && lead.argv.length === 0) {
232
+ process.stdout.write(_VERSION + '\n');
233
+ process.exit(0);
234
+ }
235
+ if (lead.argv.length === 0) {
236
+ process.stdout.write(HELP);
237
+ process.exit(0);
238
+ }
212
239
 
213
- // A leading flag (e.g. `zeyos --invalid`) is not a command — surface it as a
214
- // bad option rather than letting it masquerade as one.
240
+ const command = lead.argv[0];
215
241
  if (command.startsWith('-')) {
216
- process.stderr.write(`Unknown option: "${command}". Run 'zeyos --help' for usage.\n`);
217
- process.exit(1);
242
+ _failLeadingOption(command);
218
243
  }
219
244
 
220
- const rest = argv.slice(1);
245
+ const rest = lead.argv.slice(1);
246
+
247
+ if (process.env.ZEYOS_CREDENTIALS_READONLY && CREDENTIAL_MUTATION_COMMANDS.has(command)) {
248
+ process.stderr.write(`Credential command "${command}" is disabled because ZEYOS_CREDENTIALS_READONLY is set.\n`);
249
+ process.exit(1);
250
+ }
221
251
 
222
252
  // Parse remaining args permissively: known options are parsed normally and
223
253
  // unknown --key value flags are captured too (so create/update accept fields).
224
- const { values, positional } = _parsePermissive(rest, OPTIONS);
254
+ const parsed = _parsePermissive(rest, OPTIONS);
255
+ const values = { ...lead.values, ...parsed.values };
256
+ const positional = parsed.positional;
225
257
 
226
258
  const modulePath = COMMANDS[command];
227
259
  if (!modulePath) {
@@ -232,7 +264,7 @@ async function main() {
232
264
  const mod = await import(modulePath);
233
265
 
234
266
  if (values.help) {
235
- process.stdout.write(mod.USAGE ?? HELP);
267
+ process.stdout.write(_formatCommandHelp(mod.USAGE ?? HELP));
236
268
  process.exit(0);
237
269
  }
238
270
 
@@ -254,11 +286,121 @@ async function main() {
254
286
  }
255
287
  }
256
288
 
289
+ _validateKnownStringValues(values);
290
+
257
291
  await mod.run(values, positional);
258
292
  }
259
293
 
260
294
  // ── Helpers ───────────────────────────────────────────────────────────────────
261
295
 
296
+ /**
297
+ * Parse documented global flags that appear before the command name, e.g.
298
+ * `zeyos --profile dev whoami`. Command-specific flags remain after the
299
+ * command so the per-command allow-list can validate them.
300
+ */
301
+ function _splitLeadingFlags(argv) {
302
+ const values = {};
303
+ let i = 0;
304
+
305
+ while (i < argv.length) {
306
+ const arg = argv[i];
307
+
308
+ if (arg === '--') {
309
+ return { values, argv: argv.slice(i + 1) };
310
+ }
311
+
312
+ if (arg.startsWith('--')) {
313
+ const eqIdx = arg.indexOf('=');
314
+ const key = eqIdx === -1 ? arg.slice(2) : arg.slice(2, eqIdx);
315
+ const inlineVal = eqIdx === -1 ? undefined : arg.slice(eqIdx + 1);
316
+ const opt = OPTIONS[key];
317
+
318
+ if (!LEADING_FLAGS.includes(key)) {
319
+ _failLeadingOption(arg);
320
+ }
321
+
322
+ if (opt?.type === 'boolean') {
323
+ values[key] = true;
324
+ i++;
325
+ continue;
326
+ }
327
+
328
+ if (opt?.type === 'string') {
329
+ if (inlineVal !== undefined) {
330
+ values[key] = inlineVal;
331
+ i++;
332
+ } else {
333
+ const next = argv[i + 1];
334
+ if (next !== undefined && next.startsWith('--')) {
335
+ values[key] = '';
336
+ i++;
337
+ } else {
338
+ values[key] = next ?? '';
339
+ i += 2;
340
+ }
341
+ }
342
+ continue;
343
+ }
344
+ }
345
+
346
+ if (arg.startsWith('-') && arg.length === 2) {
347
+ const short = arg[1];
348
+ const match = Object.entries(OPTIONS).find(([, o]) => o.short === short);
349
+ if (!match || !LEADING_FLAGS.includes(match[0])) {
350
+ _failLeadingOption(arg);
351
+ }
352
+
353
+ const [key, opt] = match;
354
+ if (opt.type === 'boolean') {
355
+ values[key] = true;
356
+ i++;
357
+ } else {
358
+ const next = argv[i + 1];
359
+ if (next !== undefined && next.startsWith('--')) {
360
+ values[key] = '';
361
+ i++;
362
+ } else {
363
+ values[key] = next ?? '';
364
+ i += 2;
365
+ }
366
+ }
367
+ continue;
368
+ }
369
+
370
+ break;
371
+ }
372
+
373
+ return { values, argv: argv.slice(i) };
374
+ }
375
+
376
+ function _failLeadingOption(flag) {
377
+ process.stderr.write(`Unknown option: "${flag}". Run 'zeyos --help' for usage.\n`);
378
+ process.exit(1);
379
+ }
380
+
381
+ function _formatCommandHelp(usage) {
382
+ if (usage === HELP || /--profile\s+<name>/.test(usage)) {
383
+ return usage;
384
+ }
385
+ if (/Global options:\n/.test(usage)) {
386
+ return usage.replace(
387
+ /Global options:\n/,
388
+ `Global options:\n --profile <name> Use a named credential profile for this command\n --no-color Disable ANSI colors\n`
389
+ );
390
+ }
391
+ const trimmed = usage.endsWith('\n') ? usage : `${usage}\n`;
392
+ return `${trimmed}\n${COMMON_COMMAND_HELP}`;
393
+ }
394
+
395
+ function _validateKnownStringValues(values) {
396
+ for (const [key, value] of Object.entries(values)) {
397
+ if (OPTIONS[key]?.type === 'string' && value === '') {
398
+ process.stderr.write(`Option --${key} requires a value.\n`);
399
+ process.exit(1);
400
+ }
401
+ }
402
+ }
403
+
262
404
  /**
263
405
  * Parse argv with known options; capture unknown --key value pairs too.
264
406
  * This lets create/update accept arbitrary --fieldName value flags.
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { normalizeCountResult } from '@zeyos/client';
14
- import { buildCliClient, callApi, maybeDryRun, parseJsonOptionOrFile, requireResource } from '../lib/command.mjs';
14
+ import { buildCliClient, callApi, maybeDryRun, normalizeFilterOperators, parseJsonOptionOrFile, requireResource } from '../lib/command.mjs';
15
15
  import { outputMode, printJson, printYaml } from '../lib/output.mjs';
16
16
 
17
17
  export const USAGE = `\
@@ -24,6 +24,8 @@ Arguments:
24
24
 
25
25
  Options:
26
26
  --filter <json> JSON filter object e.g. '{"status":1}'
27
+ Arrays normalize to IN; $lt/$lte/$gt/$gte/$ne/$in/$nin and suffix
28
+ keys like field__startswith/field__gt normalize to native operators
27
29
  --filter-file <path>
28
30
  Read JSON filter object from a file
29
31
  --json Output as JSON ({ "count": N })
@@ -47,7 +49,7 @@ export async function run(values, positional) {
47
49
 
48
50
  const filters = parseJsonOptionOrFile(values, 'filter', 'filter-file');
49
51
  if (filters !== undefined) {
50
- body.filters = filters;
52
+ body.filters = normalizeFilterOperators(filters, { fieldAliases: res.filterAliases });
51
53
  }
52
54
 
53
55
  // ── Call API ───────────────────────────────────────────────────────────────
@@ -21,6 +21,8 @@ const ENV_KEYS = {
21
21
  ZEYOS_CLIENT_SECRET: 'clientSecret',
22
22
  ZEYOS_TOKEN: 'accessToken',
23
23
  ZEYOS_REFRESH_TOKEN: 'refreshToken',
24
+ ZEYOS_NO_REFRESH: 'tokenOnly',
25
+ ZEYOS_CREDENTIALS_READONLY: 'credentialsReadonly',
24
26
  };
25
27
 
26
28
  export const USAGE = `\
@@ -82,8 +84,9 @@ function buildAgentReport() {
82
84
  clientSecret: Boolean(config.clientSecret),
83
85
  accessToken: Boolean(config.accessToken),
84
86
  refreshToken: Boolean(config.refreshToken),
87
+ tokenOnly: Boolean(process.env.ZEYOS_TOKEN) || isTruthyEnv(process.env.ZEYOS_NO_REFRESH),
85
88
  };
86
- const ready = Boolean(effective.baseUrl && effective.clientId && effective.clientSecret && effective.accessToken);
89
+ const ready = Boolean(effective.baseUrl && effective.accessToken && (effective.tokenOnly || (effective.clientId && effective.clientSecret)));
87
90
  const resources = inspectResources();
88
91
 
89
92
  return {
@@ -117,6 +120,10 @@ function buildAgentReport() {
117
120
  };
118
121
  }
119
122
 
123
+ function isTruthyEnv(value) {
124
+ return /^(1|true|yes|on)$/i.test(String(value || '').trim());
125
+ }
126
+
120
127
  function inspectResources() {
121
128
  const names = listResources();
122
129
  const missing = [];
package/commands/list.mjs CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  callApi,
27
27
  fail,
28
28
  maybeDryRun,
29
+ normalizeFilterOperators,
29
30
  parseJsonOptionOrFile,
30
31
  requireApiMethod,
31
32
  requireResource
@@ -42,6 +43,8 @@ Arguments:
42
43
  Options:
43
44
  --fields <list> Field selection (see formats below)
44
45
  --filter <json> JSON filter object e.g. '{"status":1}'
46
+ Arrays normalize to IN; $lt/$lte/$gt/$gte/$ne/$in/$nin and suffix
47
+ keys like field__startswith/field__gt normalize to native operators
45
48
  --filter-file <path>
46
49
  Read JSON filter object from a file
47
50
  --sort <fields> Sort expression e.g. '-lastmodified'
@@ -86,7 +89,7 @@ export async function run(values, positional) {
86
89
 
87
90
  const filters = parseJsonOptionOrFile(values, 'filter', 'filter-file');
88
91
  if (filters !== undefined) {
89
- body.filters = filters;
92
+ body.filters = normalizeFilterOperators(filters, { fieldAliases: res.filterAliases });
90
93
  }
91
94
 
92
95
  if (values.sort) body.sort = values.sort.split(',').map(s => s.trim()).filter(Boolean);
@@ -49,6 +49,10 @@ export async function run(values) {
49
49
  const profileName = values.profile || null;
50
50
  const scope = values.global ? 'global' : 'local';
51
51
  const port = values.port ? Number(values.port) : DEFAULT_CALLBACK_PORT;
52
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
53
+ error('--port must be an integer between 1 and 65535.');
54
+ process.exit(1);
55
+ }
52
56
  const redirectUri = callbackUri(port);
53
57
 
54
58
  // Persist either into a named profile or the legacy local/global credential file.
@@ -1,22 +1,28 @@
1
1
  /**
2
2
  * zeyos logout
3
3
  *
4
- * Revokes the current access token (best-effort) and removes stored tokens
5
- * from the credential file. Connection params (baseUrl, clientId, clientSecret)
6
- * are kept so a subsequent `zeyos login` works without re-entering them.
4
+ * Revokes the current access token (best-effort) and clears the selected
5
+ * stored session. Local legacy credentials are cleared completely so a
6
+ * subsequent `zeyos login` starts from fresh connection parameters.
7
7
  *
8
8
  * Options:
9
9
  * --global Clear the global credentials file
10
10
  */
11
11
 
12
12
  import { createZeyosClient, MemoryTokenStore } from '@zeyos/client';
13
- import { loadConfigWithSource, clearTokens, clearTokensForSource } from '../lib/config.mjs';
14
- import { success, warn, info } from '../lib/output.mjs';
13
+ import {
14
+ loadConfigWithSource,
15
+ loadGlobalConfig,
16
+ clearTokensForSource,
17
+ clearLocalCredentialsForSource,
18
+ listProfiles
19
+ } from '../lib/config.mjs';
20
+ import { success, warn, info, error } from '../lib/output.mjs';
15
21
 
16
22
  export const USAGE = `\
17
23
  Usage: zeyos logout [options]
18
24
 
19
- Revoke the current session and clear stored tokens.
25
+ Revoke the current session and clear stored credentials.
20
26
 
21
27
  Options:
22
28
  --profile <name> Log out of a specific profile
@@ -25,9 +31,29 @@ Options:
25
31
  `;
26
32
 
27
33
  export async function run(values) {
28
- const { config, source } = loadConfigWithSource({ profile: values.profile });
34
+ let config;
35
+ let source;
36
+
37
+ if (values.global) {
38
+ config = loadGlobalConfig();
39
+ source = { kind: 'global' };
40
+ } else {
41
+ const loaded = loadConfigWithSource({ profile: values.profile });
42
+ if (loaded.profile?.missing) {
43
+ const names = Object.keys(listProfiles().profiles);
44
+ const known = names.length ? `Known profiles: ${names.join(', ')}.` : 'No profiles defined yet.';
45
+ error(`Profile "${loaded.profile.name}" not found (selected via ${loaded.profile.origin}). ${known}`);
46
+ process.exit(1);
47
+ }
48
+ config = loaded.config;
49
+ source = loaded.source;
50
+ }
29
51
 
30
52
  if (!config.accessToken) {
53
+ if (source?.kind === 'local' && clearLocalCredentialsForSource(source)) {
54
+ success('Logged out (local credentials).');
55
+ return;
56
+ }
31
57
  warn('Not currently logged in.');
32
58
  return;
33
59
  }
@@ -58,8 +84,8 @@ export async function run(values) {
58
84
  }
59
85
  }
60
86
 
61
- if (values.global) {
62
- clearTokens('global');
87
+ if (source?.kind === 'local') {
88
+ clearLocalCredentialsForSource(source);
63
89
  } else {
64
90
  clearTokensForSource(source);
65
91
  }
@@ -14,6 +14,7 @@
14
14
  * the credentials currently in effect (including tokens) into the new profile.
15
15
  */
16
16
 
17
+ import { createInterface } from 'node:readline';
17
18
  import {
18
19
  listProfiles, getProfile, upsertProfile, removeProfile,
19
20
  setActiveProfile, writeLocalPin, readLocalPin,
@@ -31,7 +32,7 @@ Commands:
31
32
  current Show which profile is in effect, and why
32
33
  use <name> Make <name> the active profile (global)
33
34
  use <name> --local Pin <name> to the current project (.zeyos/profile)
34
- add <name> [options] Create or update a profile
35
+ add [<name>] [options] Create or update a profile; prompts when run without connection options
35
36
  remove <name> Delete a profile
36
37
 
37
38
  Add options:
@@ -45,6 +46,7 @@ Global options:
45
46
  -h, --help Show this help
46
47
 
47
48
  Examples:
49
+ zeyos profile add # prompt for name and connection params
48
50
  zeyos profile add dev --base-url https://zeyos.cms-it.de/dev
49
51
  zeyos profile add prod --from-current
50
52
  zeyos profile use prod
@@ -147,30 +149,42 @@ function cmdUse(values, name) {
147
149
 
148
150
  // ── add ────────────────────────────────────────────────────────────────────────
149
151
 
150
- function cmdAdd(values, name) {
151
- if (!name) fail('Usage: zeyos profile add <name> [--base-url <url>] [--client-id <id>] [--secret <secret>] | --from-current');
152
+ async function cmdAdd(values, name) {
153
+ let promptSession = null;
154
+ const ask = (question, opts) => {
155
+ promptSession ??= createPromptSession();
156
+ return promptSession.ask(question, opts);
157
+ };
152
158
 
153
- let updates = {};
154
- if (values['from-current']) {
155
- const cfg = loadConfigWithSource().config; // whatever is in effect right now
156
- for (const k of ['baseUrl', 'instance', 'clientId', 'clientSecret', 'accessToken', 'refreshToken', 'expiresAt', 'refreshTokenExpiresAt']) {
157
- if (cfg[k] != null) updates[k] = cfg[k];
158
- }
159
- if (!updates.baseUrl) fail('Nothing to snapshot: no credentials are currently in effect.');
160
- } else {
161
- if (values['base-url']) updates.baseUrl = values['base-url'];
162
- if (values['client-id']) updates.clientId = values['client-id'];
163
- if (values.secret) updates.clientSecret = values.secret;
164
- if (Object.keys(updates).length === 0) {
165
- fail('Provide at least --base-url (and ideally --client-id/--secret), or use --from-current.');
159
+ try {
160
+ const profileName = name || await ask('Profile name');
161
+ if (!profileName) fail('Profile name is required.');
162
+
163
+ let updates = {};
164
+ if (values['from-current']) {
165
+ const cfg = loadConfigWithSource().config; // whatever is in effect right now
166
+ for (const k of ['baseUrl', 'instance', 'clientId', 'clientSecret', 'accessToken', 'refreshToken', 'expiresAt', 'refreshTokenExpiresAt']) {
167
+ if (cfg[k] != null) updates[k] = cfg[k];
168
+ }
169
+ if (!updates.baseUrl) fail('Nothing to snapshot: no credentials are currently in effect.');
170
+ } else {
171
+ if (values['base-url']) updates.baseUrl = values['base-url'];
172
+ if (values['client-id']) updates.clientId = values['client-id'];
173
+ if (values.secret) updates.clientSecret = values.secret;
174
+
175
+ if (Object.keys(updates).length === 0) {
176
+ updates = await promptProfileCredentials(profileName, ask);
177
+ }
166
178
  }
167
- }
168
179
 
169
- const existed = Boolean(getProfile(name));
170
- upsertProfile(name, updates);
171
- success(`${existed ? 'Updated' : 'Created'} profile "${name}".`);
172
- if (!updates.accessToken) {
173
- info(`Finish authenticating with: zeyos login --profile ${name}`);
180
+ const existed = Boolean(getProfile(profileName));
181
+ upsertProfile(profileName, updates);
182
+ success(`${existed ? 'Updated' : 'Created'} profile "${profileName}".`);
183
+ if (!updates.accessToken) {
184
+ info(`Finish authenticating with: zeyos login --profile ${profileName}`);
185
+ }
186
+ } finally {
187
+ promptSession?.close();
174
188
  }
175
189
  }
176
190
 
@@ -209,3 +223,84 @@ function fail(message) {
209
223
  error(message);
210
224
  process.exit(1);
211
225
  }
226
+
227
+ async function promptProfileCredentials(name, ask) {
228
+ const existing = getProfile(name) || {};
229
+
230
+ info(`Creating profile "${name}".`);
231
+ info('This stores the platform and OAuth app credentials; tokens are added by login.');
232
+ console.error('');
233
+
234
+ const baseUrl = await ask('ZeyOS platform URL', { currentValue: existing.baseUrl });
235
+ const clientId = await ask('Application ID', { currentValue: existing.clientId });
236
+ const clientSecret = await ask('Application secret', { currentValue: existing.clientSecret, secret: true });
237
+
238
+ if (!baseUrl || !clientId || !clientSecret) {
239
+ fail('ZeyOS URL, application ID and secret are all required.');
240
+ }
241
+
242
+ return { baseUrl, clientId, clientSecret };
243
+ }
244
+
245
+ function createPromptSession() {
246
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: process.stdin.isTTY && process.stderr.isTTY });
247
+ const originalWrite = rl._writeToOutput.bind(rl);
248
+ let hiddenPrompt = null;
249
+ let closed = false;
250
+ const queuedLines = [];
251
+ const waitingResolvers = [];
252
+
253
+ rl.on('line', (line) => {
254
+ const resolve = waitingResolvers.shift();
255
+ if (resolve) {
256
+ resolve(line);
257
+ } else {
258
+ queuedLines.push(line);
259
+ }
260
+ });
261
+
262
+ rl.on('close', () => {
263
+ closed = true;
264
+ let resolve;
265
+ while ((resolve = waitingResolvers.shift())) {
266
+ resolve('');
267
+ }
268
+ });
269
+
270
+ rl._writeToOutput = (value) => {
271
+ if (!hiddenPrompt || String(value).includes(hiddenPrompt) || value === '\n' || value === '\r\n') {
272
+ originalWrite(value);
273
+ }
274
+ };
275
+
276
+ const readLine = () => {
277
+ if (queuedLines.length) {
278
+ return Promise.resolve(queuedLines.shift());
279
+ }
280
+ if (closed) {
281
+ return Promise.resolve('');
282
+ }
283
+ return new Promise(resolve => {
284
+ waitingResolvers.push(resolve);
285
+ });
286
+ };
287
+
288
+ return {
289
+ ask(question, opts = {}) {
290
+ const currentValue = opts.currentValue || '';
291
+ const defaultLabel = opts.secret && currentValue ? 'stored, press Enter to keep' : currentValue;
292
+ const prompt = defaultLabel ? `${question} [${defaultLabel}]` : question;
293
+ hiddenPrompt = opts.secret && process.stdin.isTTY && process.stderr.isTTY ? prompt : null;
294
+ process.stderr.write(`${prompt}: `);
295
+
296
+ return readLine()
297
+ .then(answer => {
298
+ hiddenPrompt = null;
299
+ return answer.trim() || currentValue || '';
300
+ });
301
+ },
302
+ close() {
303
+ rl.close();
304
+ }
305
+ };
306
+ }