@zeyos/cli 0.3.0 → 0.5.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
 
package/bin/zeyos.mjs CHANGED
@@ -6,7 +6,7 @@
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
@@ -38,7 +38,7 @@ Usage: ${_z} <command> [options] [args…]
38
38
 
39
39
  ${_c.bold('Commands:')}
40
40
  ${_c.cyan('login')} Authenticate with a ZeyOS instance
41
- ${_c.cyan('logout')} Revoke session and clear stored tokens
41
+ ${_c.cyan('logout')} Revoke session and clear stored credentials
42
42
  ${_c.cyan('whoami')} Show currently authenticated user
43
43
  ${_c.cyan('list')} <resource> List / query records
44
44
  ${_c.cyan('count')} <resource> Count records (with optional filter)
@@ -51,6 +51,7 @@ ${_c.bold('Commands:')}
51
51
  ${_c.cyan('describe')} <resource> Show a resource's fields, types and enums
52
52
  ${_c.cyan('doctor')} agent Check local CLI readiness for coding agents
53
53
  ${_c.cyan('skills')} <command> List / show / install ZeyOS agent skills
54
+ ${_c.cyan('okf')} <command> List / show / check / export the OKF knowledge bundle
54
55
  ${_c.cyan('profile')} <command> Manage credential profiles / switch instances
55
56
 
56
57
  ${_c.bold('Global options:')}
@@ -120,6 +121,8 @@ const OPTIONS = {
120
121
  'target': { type: 'string' },
121
122
  'dir': { type: 'string' },
122
123
  'no-logo': { type: 'boolean' },
124
+ // okf
125
+ 'out': { type: 'string' },
123
126
  // profile
124
127
  'from-current': { type: 'boolean' },
125
128
  };
@@ -147,6 +150,7 @@ const COMMANDS = {
147
150
  doctor: '../commands/doctor.mjs',
148
151
  skills: '../commands/skills.mjs',
149
152
  skill: '../commands/skills.mjs',
153
+ okf: '../commands/okf.mjs',
150
154
  profile: '../commands/profile.mjs',
151
155
  profiles: '../commands/profile.mjs',
152
156
  };
@@ -158,6 +162,7 @@ const COMMANDS = {
158
162
 
159
163
  const ALWAYS_FLAGS = ['help', 'json', 'yaml', 'no-color', 'profile'];
160
164
  const SKILLS_FLAGS = ['target', 'dir', 'global', 'local', 'force', 'yes', 'no-logo'];
165
+ const OKF_FLAGS = ['dir', 'out', 'force', 'no-logo'];
161
166
  const PROFILE_FLAGS = ['base-url', 'client-id', 'secret', 'local', 'from-current'];
162
167
  const DELETE_FLAGS = ['force', 'query'];
163
168
  const GET_FLAGS = ['fields', 'extdata', 'tags', 'expand', 'all', 'query'];
@@ -182,6 +187,7 @@ const COMMAND_FLAGS = {
182
187
  doctor: [],
183
188
  skills: SKILLS_FLAGS,
184
189
  skill: SKILLS_FLAGS,
190
+ okf: OKF_FLAGS,
185
191
  profile: PROFILE_FLAGS,
186
192
  profiles: PROFILE_FLAGS,
187
193
  };
@@ -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
  }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * zeyos okf <list|show|check|build|export>
3
+ *
4
+ * Work with the Open Knowledge Format (OKF v0.1) bundle that ships with
5
+ * @zeyos/client (under okf/): a directory of Markdown concept docs describing the
6
+ * ZeyOS data model (entities, schema, foreign keys, enums, indexes, operations)
7
+ * plus curated metrics, playbooks, and query concepts. Consumers — coding agents,
8
+ * viewers, search — read it; this command lists/shows/validates the shipped bundle
9
+ * and can synthesize or export one.
10
+ */
11
+
12
+ import { readFileSync, existsSync, cpSync, mkdirSync, writeFileSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import path from 'node:path';
15
+ import { createRequire } from 'node:module';
16
+
17
+ import { loadOkfBundle, validateOkfFiles, buildOkf, OKF_VERSION } from '@zeyos/client';
18
+ import { outputMode, printJson, printYaml, printTable, colors, success, error, info, warn } from '../lib/output.mjs';
19
+
20
+ const require = createRequire(import.meta.url);
21
+
22
+ export const USAGE = `\
23
+ Usage: zeyos okf <command> [options]
24
+
25
+ Commands:
26
+ list List concepts in the OKF bundle (type, id, title)
27
+ show <concept> Print a concept doc (e.g. "tickets" or "entities/tickets")
28
+ check Validate the bundle for OKF v0.1 conformance
29
+ build [--out <dir>] Synthesize an OKF bundle from the client's schema
30
+ export [--out <dir>] Copy the shipped okf/ bundle into a directory
31
+
32
+ Options:
33
+ --dir <path> Read from an explicit bundle directory (list/show/check)
34
+ --out <path> Write to this directory (build/export; default ./okf)
35
+ --force Overwrite an existing target (export)
36
+ --json Output as JSON
37
+ --yaml Output as YAML
38
+ -h, --help Show this help
39
+
40
+ Examples:
41
+ zeyos okf list
42
+ zeyos okf show tickets
43
+ zeyos okf check
44
+ zeyos okf export --out ./vendor/okf
45
+ zeyos okf build --out ./okf-live`;
46
+
47
+ // Locate the okf/ bundle shipped inside the @zeyos/client package (mirrors the
48
+ // skills command's findAgentsDir).
49
+ function findOkfDir() {
50
+ let entry;
51
+ try {
52
+ entry = require.resolve('@zeyos/client');
53
+ } catch {
54
+ return null;
55
+ }
56
+ let dir = path.dirname(entry);
57
+ for (let i = 0; i < 6; i++) {
58
+ const candidate = path.join(dir, 'okf');
59
+ if (existsSync(path.join(candidate, 'index.md'))) return candidate;
60
+ const parent = path.dirname(dir);
61
+ if (parent === dir) break;
62
+ dir = parent;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function expandHome(p) {
68
+ if (p === '~') return homedir();
69
+ if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(homedir(), p.slice(2));
70
+ return p;
71
+ }
72
+
73
+ function displayPath(abs) {
74
+ const rel = path.relative(process.cwd(), abs);
75
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) return rel;
76
+ const home = homedir();
77
+ if (abs === home || abs.startsWith(home + path.sep)) return '~' + abs.slice(home.length);
78
+ return abs;
79
+ }
80
+
81
+ function resolveInputDir(values) {
82
+ if (values.dir) return path.resolve(expandHome(values.dir));
83
+ const found = findOkfDir();
84
+ if (!found) {
85
+ error('Could not locate the bundled OKF bundle (the @zeyos/client okf/ directory). Pass --dir <path>.');
86
+ process.exit(1);
87
+ }
88
+ return found;
89
+ }
90
+
91
+ function conceptsToRows(concepts) {
92
+ return Object.entries(concepts)
93
+ .map(([id, { frontmatter }]) => ({ type: frontmatter.type || '(none)', concept: id, title: frontmatter.title || '' }))
94
+ .sort((a, b) => a.concept.localeCompare(b.concept));
95
+ }
96
+
97
+ async function runList(values) {
98
+ const dir = resolveInputDir(values);
99
+ const { version, concepts } = await loadOkfBundle(dir);
100
+ const rows = conceptsToRows(concepts);
101
+ const mode = outputMode(values);
102
+ if (mode === 'json') return printJson({ version, concepts: rows });
103
+ if (mode === 'yaml') return printYaml({ version, concepts: rows });
104
+ printTable(rows, ['type', 'concept', 'title']);
105
+ info(`OKF v${version || OKF_VERSION} — ${rows.length} concepts in ${displayPath(dir)}. Show one with: zeyos okf show <concept>`);
106
+ }
107
+
108
+ function runShow(values, name) {
109
+ const dir = resolveInputDir(values);
110
+ const candidates = [name, `${name}.md`, `entities/${name}.md`, path.join(dir, name), path.join(dir, `${name}.md`), path.join(dir, 'entities', `${name}.md`)];
111
+ for (const candidate of candidates) {
112
+ const abs = path.isAbsolute(candidate) ? candidate : path.join(dir, candidate);
113
+ if (existsSync(abs)) {
114
+ process.stdout.write(readFileSync(abs, 'utf8'));
115
+ return;
116
+ }
117
+ }
118
+ error(`Unknown concept "${name}". Run "zeyos okf list".`);
119
+ process.exit(1);
120
+ }
121
+
122
+ async function runCheck(values) {
123
+ const dir = resolveInputDir(values);
124
+ const { files } = await loadOkfBundle(dir);
125
+ const result = validateOkfFiles(files);
126
+ const mode = outputMode(values);
127
+ if (mode === 'json') { printJson(result); process.exit(result.valid ? 0 : 1); }
128
+ if (mode === 'yaml') { printYaml(result); process.exit(result.valid ? 0 : 1); }
129
+ if (result.valid) {
130
+ success(`OKF bundle is conformant: ${result.conceptCount} concepts, 0 errors (${displayPath(dir)}).`);
131
+ return;
132
+ }
133
+ for (const err of result.errors) error(`${err.path}: ${err.message}`);
134
+ error(`OKF bundle is NOT conformant: ${result.errors.length} error(s).`);
135
+ process.exit(1);
136
+ }
137
+
138
+ function writeBundle(outDir, files) {
139
+ for (const [rel, content] of Object.entries(files)) {
140
+ const abs = path.join(outDir, rel);
141
+ mkdirSync(path.dirname(abs), { recursive: true });
142
+ writeFileSync(abs, content, 'utf8');
143
+ }
144
+ }
145
+
146
+ function runBuild(values) {
147
+ const outDir = path.resolve(expandHome(values.out || 'okf'));
148
+ const files = buildOkf();
149
+ writeBundle(outDir, files);
150
+ const mode = outputMode(values);
151
+ const summary = { out: outDir, files: Object.keys(files).length };
152
+ if (mode === 'json') return printJson(summary);
153
+ if (mode === 'yaml') return printYaml(summary);
154
+ success(`Synthesized ${summary.files} OKF files → ${displayPath(outDir)}`);
155
+ info('This is the runtime projection from the client schema (structural only). The shipped okf/ bundle adds curated metrics, playbooks, and notes.');
156
+ }
157
+
158
+ function runExport(values) {
159
+ const dir = resolveInputDir(values);
160
+ const outDir = path.resolve(expandHome(values.out || 'okf'));
161
+ if (existsSync(outDir) && !values.force) {
162
+ if (path.resolve(dir) === outDir) {
163
+ warn(`Source and target are the same (${displayPath(outDir)}); nothing to do.`);
164
+ return;
165
+ }
166
+ error(`Target ${displayPath(outDir)} already exists. Use --force to overwrite.`);
167
+ process.exit(1);
168
+ }
169
+ cpSync(dir, outDir, { recursive: true });
170
+ const mode = outputMode(values);
171
+ const summary = { from: dir, out: outDir };
172
+ if (mode === 'json') return printJson(summary);
173
+ if (mode === 'yaml') return printYaml(summary);
174
+ success(`Exported OKF bundle → ${displayPath(outDir)}`);
175
+ }
176
+
177
+ export async function run(values, positional = []) {
178
+ const sub = positional[0] || 'list';
179
+ const rest = positional.slice(1);
180
+ switch (sub) {
181
+ case 'list': return runList(values);
182
+ case 'show':
183
+ if (!rest[0]) { error('Usage: zeyos okf show <concept>'); process.exit(1); }
184
+ return runShow(values, rest[0]);
185
+ case 'check': return runCheck(values);
186
+ case 'build': return runBuild(values);
187
+ case 'export': return runExport(values);
188
+ default:
189
+ error(`Unknown okf command "${sub}".\n\n${USAGE}`);
190
+ process.exit(1);
191
+ }
192
+ }
@@ -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
+ }
@@ -9,8 +9,11 @@
9
9
  * --show-token Include the current access token in output
10
10
  */
11
11
 
12
+ import { createInterface } from 'node:readline';
12
13
  import { buildClient, syncTokens } from '../lib/client.mjs';
14
+ import { globalConfigPath, profilesConfigPath } from '../lib/config.mjs';
13
15
  import { outputMode, printJson, printYaml, printRecord, formatDate, error } from '../lib/output.mjs';
16
+ import { run as runLogin } from './login.mjs';
14
17
 
15
18
  export const USAGE = `\
16
19
  Usage: zeyos whoami [options]
@@ -25,35 +28,23 @@ Options:
25
28
  `;
26
29
 
27
30
  export async function run(values) {
28
- let client, config, tokenStore, configSource;
29
- try {
30
- ({ client, config, tokenStore, configSource } = buildClient({}, { profile: values.profile }));
31
- } catch (err) {
32
- error(err.message);
33
- process.exit(1);
34
- }
31
+ let state = _buildClientState(values);
35
32
 
36
33
  let userInfo;
37
34
  try {
38
- userInfo = await client.oauth2.getUserInfo();
39
- await syncTokens(tokenStore, configSource);
35
+ userInfo = await _fetchUserInfo(state);
40
36
  } catch (err) {
41
- const status = err?.status;
42
- if (status === 502 || status === 503 || status === 504) {
43
- error(`ZeyOS instance is temporarily unavailable (HTTP ${status}). The server at ${config.baseUrl} may be down or restarting — this is server-side, not your credentials.`);
44
- } else if (status === 401) {
45
- error('Your session has expired or is invalid. Re-authenticate with: zeyos login --force');
46
- } else {
47
- error(`Failed to fetch user info: ${err.message}`);
48
- }
49
- process.exit(1);
37
+ const handled = await _handleFetchError(err, state, values);
38
+ if (!handled) process.exit(1);
39
+ state = handled.state;
40
+ userInfo = handled.userInfo;
50
41
  }
51
42
 
52
43
  const mode = outputMode(values);
53
44
 
54
45
  const output = { ...userInfo };
55
46
  if (values['show-token']) {
56
- const tokenSet = await tokenStore.get();
47
+ const tokenSet = await state.tokenStore.get();
57
48
  if (tokenSet?.accessToken) output.accessToken = tokenSet.accessToken;
58
49
  }
59
50
 
@@ -63,7 +54,7 @@ export async function run(values) {
63
54
  printYaml(output);
64
55
  } else {
65
56
  // Pretty key-value record with custom formatters
66
- const dateFormat = config.dateFormat ?? 'YYYY-MM-DD HH:mm';
57
+ const dateFormat = state.config.dateFormat ?? 'YYYY-MM-DD HH:mm';
67
58
  const keys = Object.keys(output);
68
59
  const formatters = {};
69
60
 
@@ -86,6 +77,56 @@ export async function run(values) {
86
77
  }
87
78
  }
88
79
 
80
+ function _buildClientState(values) {
81
+ try {
82
+ const state = buildClient({}, { profile: values.profile });
83
+ return {
84
+ client: state.client,
85
+ config: state.config,
86
+ tokenStore: state.tokenStore,
87
+ configSource: state.configSource
88
+ };
89
+ } catch (err) {
90
+ error(err.message);
91
+ process.exit(1);
92
+ }
93
+ }
94
+
95
+ async function _fetchUserInfo(state) {
96
+ const userInfo = await state.client.oauth2.getUserInfo();
97
+ await syncTokens(state.tokenStore, state.configSource);
98
+ return userInfo;
99
+ }
100
+
101
+ async function _handleFetchError(err, state, values) {
102
+ const status = err?.status;
103
+ if (status === 502 || status === 503 || status === 504) {
104
+ error(`ZeyOS instance is temporarily unavailable (HTTP ${status}). The server at ${state.config.baseUrl} may be down or restarting — this is server-side, not your credentials.`);
105
+ return null;
106
+ }
107
+
108
+ const authFailure = _authFailureSummary(err);
109
+ if (!authFailure) {
110
+ error(`Failed to fetch user info: ${err.message}`);
111
+ return null;
112
+ }
113
+
114
+ error(_formatAuthFailure(authFailure, err, state.config, state.configSource, values));
115
+ const reauthenticated = await _maybeReauthenticate(state.configSource, values);
116
+ if (!reauthenticated) return null;
117
+
118
+ const nextState = _buildClientState(values);
119
+ try {
120
+ return {
121
+ state: nextState,
122
+ userInfo: await _fetchUserInfo(nextState)
123
+ };
124
+ } catch (retryErr) {
125
+ error(`Re-authentication completed, but fetching user info still failed: ${retryErr.message}`);
126
+ return null;
127
+ }
128
+ }
129
+
89
130
  /**
90
131
  * Format an array of objects as a multi-line list.
91
132
  * Each item is shown as "name (rw)" or "name (ro)" on its own line.
@@ -105,3 +146,145 @@ function _formatObjectList(items, nameKey, writableKey) {
105
146
  })
106
147
  .join('\n');
107
148
  }
149
+
150
+ function _isInvalidRefreshTokenError(err) {
151
+ const detail = `${err?.message ?? ''}\n${_stringifyErrorBody(err?.body)}`;
152
+ return [400, 401, 403].includes(err?.status) &&
153
+ /refresh[_ -]?token|invalid_grant/i.test(detail) &&
154
+ /invalid|expired|forbidden|invalid_grant/i.test(detail);
155
+ }
156
+
157
+ function _authFailureSummary(err) {
158
+ if (_isInvalidRefreshTokenError(err)) {
159
+ return 'Your stored refresh token is invalid or expired.';
160
+ }
161
+ if (err?.status === 401) {
162
+ return 'Your session has expired or is invalid.';
163
+ }
164
+ return null;
165
+ }
166
+
167
+ function _formatAuthFailure(summary, err, config, source, values) {
168
+ const lines = [
169
+ summary,
170
+ `Platform URL: ${config.baseUrl ?? '(not configured)'}`,
171
+ `Credential source: ${_describeConfigSource(source)}`
172
+ ];
173
+
174
+ if (err?.url) {
175
+ lines.push(`OAuth endpoint: ${err.url}`);
176
+ }
177
+ if (err?.status) {
178
+ lines.push(`HTTP status: ${err.status}${err.statusText ? ` ${err.statusText}` : ''}`);
179
+ }
180
+
181
+ const detail = _authErrorDetail(err);
182
+ if (detail) {
183
+ lines.push(`OAuth error: ${detail}`);
184
+ }
185
+
186
+ lines.push(`Next step: ${_loginCommand(source, values)}`);
187
+ if (process.env.ZEYOS_TOKEN || process.env.ZEYOS_REFRESH_TOKEN) {
188
+ lines.push('Note: ZEYOS_TOKEN or ZEYOS_REFRESH_TOKEN is set and overrides stored credentials; update or unset it before retrying.');
189
+ }
190
+ return lines.join('\n');
191
+ }
192
+
193
+ function _describeConfigSource(source) {
194
+ if (!source) {
195
+ return 'environment variables';
196
+ }
197
+ if (source.kind === 'profile') {
198
+ return `profile "${source.name}" (${profilesConfigPath()})`;
199
+ }
200
+ if (source.kind === 'global') {
201
+ return `global credentials (${globalConfigPath()})`;
202
+ }
203
+ if (source.kind === 'local') {
204
+ return `local file ${source.path ?? '.zeyos/auth.json'}`;
205
+ }
206
+ return source.kind ?? 'unknown';
207
+ }
208
+
209
+ function _loginCommand(source, values) {
210
+ const profile = values.profile ?? (source?.kind === 'profile' ? source.name : null);
211
+ if (profile) {
212
+ return `zeyos login --profile ${_quoteArg(profile)} --force`;
213
+ }
214
+ if (source?.kind === 'global') {
215
+ return 'zeyos login --global --force';
216
+ }
217
+ return 'zeyos login --force';
218
+ }
219
+
220
+ async function _maybeReauthenticate(source, values) {
221
+ if (!_canPromptForReauthentication(source, values)) {
222
+ return false;
223
+ }
224
+
225
+ const command = _loginCommand(source, values);
226
+ const confirmed = await _confirm(`Re-authenticate now (${command})? [y/N] `);
227
+ if (!confirmed) return false;
228
+
229
+ await runLogin(_loginValues(source, values));
230
+ return true;
231
+ }
232
+
233
+ function _canPromptForReauthentication(source, values) {
234
+ return Boolean(source) &&
235
+ !values.json &&
236
+ !values.yaml &&
237
+ process.stdin.isTTY &&
238
+ process.stderr.isTTY &&
239
+ !process.env.ZEYOS_TOKEN &&
240
+ !process.env.ZEYOS_REFRESH_TOKEN;
241
+ }
242
+
243
+ function _loginValues(source, values) {
244
+ return {
245
+ ...values,
246
+ force: true,
247
+ profile: values.profile ?? (source?.kind === 'profile' ? source.name : undefined),
248
+ global: source?.kind === 'global' ? true : values.global
249
+ };
250
+ }
251
+
252
+ function _confirm(prompt) {
253
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
254
+ return new Promise(resolve => {
255
+ rl.question(prompt, answer => {
256
+ rl.close();
257
+ resolve(answer.trim().toLowerCase() === 'y');
258
+ });
259
+ });
260
+ }
261
+
262
+ function _authErrorDetail(err) {
263
+ const body = _stringifyErrorBody(err?.body).trim();
264
+ if (body) {
265
+ return body;
266
+ }
267
+ return String(err?.message ?? '').trim();
268
+ }
269
+
270
+ function _stringifyErrorBody(body) {
271
+ if (body == null) {
272
+ return '';
273
+ }
274
+ if (typeof body === 'string') {
275
+ return body;
276
+ }
277
+ try {
278
+ return JSON.stringify(body);
279
+ } catch {
280
+ return String(body);
281
+ }
282
+ }
283
+
284
+ function _quoteArg(value) {
285
+ const text = String(value);
286
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(text)) {
287
+ return text;
288
+ }
289
+ return `'${text.replaceAll("'", "'\\''")}'`;
290
+ }
package/lib/config.mjs CHANGED
@@ -187,6 +187,21 @@ export function clearTokens(scope = 'local') {
187
187
  if (path) _writeJson(path, _stripTokens(_readJson(path)));
188
188
  }
189
189
 
190
+ /** Remove all credential/session fields from the resolved legacy local auth file. */
191
+ export function clearLocalCredentialsForSource(source) {
192
+ if (!source || source.kind !== 'local') return false;
193
+ const path = source.path ?? _findLocalPath();
194
+ if (!path) return false;
195
+
196
+ const current = _readJson(path);
197
+ const hadCredentials = CRED_KEYS.some((key) => Object.prototype.hasOwnProperty.call(current, key));
198
+ if (!hadCredentials) return false;
199
+
200
+ const next = _stripCredentials(current);
201
+ _writeJson(path, next);
202
+ return true;
203
+ }
204
+
190
205
  /**
191
206
  * Persist refreshed tokens back to wherever the active credentials came from.
192
207
  * @param {ConfigSource|null} source
@@ -325,6 +340,11 @@ export function localConfigPath() { return _findLocalPath(); }
325
340
  export function globalConfigPath() { return GLOBAL_FILE; }
326
341
  export function profilesConfigPath() { return PROFILES_FILE; }
327
342
 
343
+ /** Read the legacy global credentials file directly, without applying the cascade. */
344
+ export function loadGlobalConfig() {
345
+ return _readGlobal();
346
+ }
347
+
328
348
  // ── Internals ────────────────────────────────────────────────────────────────
329
349
 
330
350
  function _fromEnv() {
@@ -368,6 +388,14 @@ function _stripTokens(o) {
368
388
  return rest;
369
389
  }
370
390
 
391
+ function _stripCredentials(o) {
392
+ const out = { ...o };
393
+ for (const key of CRED_KEYS) {
394
+ delete out[key];
395
+ }
396
+ return out;
397
+ }
398
+
371
399
  function _readGlobal() {
372
400
  return existsSync(GLOBAL_FILE) ? _readJson(GLOBAL_FILE) : {};
373
401
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeyos/cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Command-line interface for the ZeyOS API",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,7 +39,7 @@
39
39
  "node": ">=18.3"
40
40
  },
41
41
  "dependencies": {
42
- "@zeyos/client": "^0.3.0"
42
+ "@zeyos/client": "^0.5.0"
43
43
  },
44
44
  "scripts": {
45
45
  "test": "node --test test/offline.mjs"