@zeyos/cli 0.1.1 → 0.3.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.
@@ -0,0 +1,20 @@
1
+ {
2
+ "list": {
3
+ "fields": {
4
+ "ID": "ID",
5
+ "Date": "date",
6
+ "Mailbox": "mailbox",
7
+ "Subject": "subject",
8
+ "From": "sender_email",
9
+ "To": "to_email",
10
+ "Ticket": "ticket",
11
+ "Opportunity": "opportunity",
12
+ "Reference": "reference",
13
+ "Message-ID": "messageid"
14
+ }
15
+ },
16
+ "get": {
17
+ "fields": ["ID", "date", "mailbox", "subject", "sender", "sender_email", "sender_name", "to", "to_email", "to_name", "cc", "bcc", "ticket", "opportunity", "reference", "messageid", "contenttype", "text", "attachments", "senddate", "senderror", "creationdate", "lastmodified"],
18
+ "params": { "extdata": 1 }
19
+ }
20
+ }
package/config/task.json CHANGED
@@ -8,11 +8,13 @@
8
8
  "Priority": "priority",
9
9
  "Agent": "assigneduser.name",
10
10
  "Due": "duedate",
11
- "Ticket": "ticket"
11
+ "Ticket": "ticket",
12
+ "Project": "project",
13
+ "Projected": "projectedeffort"
12
14
  }
13
15
  },
14
16
  "get": {
15
- "fields": ["ID", "tasknum", "name", "description", "status", "priority", "duedate", "ticket", "creator", "created", "lastmodified"],
17
+ "fields": ["ID", "tasknum", "name", "description", "status", "priority", "datefrom", "duedate", "ticket", "project", "projectedeffort", "assigneduser", "creator", "creationdate", "lastmodified"],
16
18
  "params": { "extdata": 1 }
17
19
  }
18
20
  }
@@ -7,13 +7,14 @@
7
7
  "Status": "status",
8
8
  "Priority": "priority",
9
9
  "Account": "account.lastname",
10
+ "Project": "project",
10
11
  "Agent": "assigneduser.name",
11
12
  "Due": "duedate",
12
13
  "Modified": "lastmodified"
13
14
  }
14
15
  },
15
16
  "get": {
16
- "fields": ["ID", "ticketnum", "name", "description", "status", "priority", "duedate", "created", "creator", "lastmodified"],
17
+ "fields": ["ID", "ticketnum", "name", "description", "status", "priority", "duedate", "account", "project", "assigneduser", "creator", "creationdate", "lastmodified"],
17
18
  "params": { "extdata": 1 }
18
19
  }
19
20
  }
package/lib/client.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  * Also provides a helper that persists refreshed tokens back to the config file.
4
4
  */
5
5
  import { createZeyosClient, MemoryTokenStore } from '@zeyos/client';
6
- import { loadConfigWithSource, saveConfig, requireConfig } from './config.mjs';
6
+ import { loadConfigWithSource, persistTokens, requireConfig, listProfiles } from './config.mjs';
7
7
 
8
8
  /** @typedef {import('./types.mjs').CliConfig} CliConfig */
9
9
 
@@ -12,10 +12,16 @@ import { loadConfigWithSource, saveConfig, requireConfig } from './config.mjs';
12
12
  * Throws a friendly error if required config keys are missing.
13
13
  *
14
14
  * @param {CliConfig} [overrides] Extra config values (e.g. from CLI flags)
15
- * @returns {{ client: ReturnType<typeof createZeyosClient>, config: CliConfig, tokenStore: MemoryTokenStore, configSource: 'local'|'global'|null }}
15
+ * @param {{ profile?: string }} [opts] Profile selector (from --profile / ZEYOS_PROFILE)
16
+ * @returns {{ client: ReturnType<typeof createZeyosClient>, config: CliConfig, tokenStore: MemoryTokenStore, configSource: import('./config.mjs').ConfigSource|null }}
16
17
  */
17
- export function buildClient(overrides = {}) {
18
- const loaded = loadConfigWithSource();
18
+ export function buildClient(overrides = {}, opts = {}) {
19
+ const loaded = loadConfigWithSource({ profile: opts.profile });
20
+ if (loaded.profile?.missing) {
21
+ const names = Object.keys(listProfiles().profiles);
22
+ const known = names.length ? `Known profiles: ${names.join(', ')}.` : 'No profiles defined yet — create one with `zeyos profile add <name>`.';
23
+ throw new Error(`Profile "${loaded.profile.name}" not found (selected via ${loaded.profile.origin}). ${known}`);
24
+ }
19
25
  const config = { ...loaded.config, ...overrides };
20
26
  requireConfig(['baseUrl', 'clientId', 'clientSecret', 'accessToken'], config);
21
27
 
@@ -43,25 +49,28 @@ export function buildClient(overrides = {}) {
43
49
  }
44
50
 
45
51
  /**
46
- * Persist any refreshed tokens back to the credential store.
52
+ * Persist any refreshed tokens back to the credential store the config came from
53
+ * (a named profile, the legacy local auth.json, or the legacy global file).
47
54
  * Call this after API operations to keep tokens up-to-date.
48
55
  *
49
56
  * @param {MemoryTokenStore} tokenStore
50
- * @param {'local'|'global'|null} scope
57
+ * @param {import('./config.mjs').ConfigSource|'local'|'global'|null} source
51
58
  */
52
- export async function syncTokens(tokenStore, scope = 'local') {
53
- if (!scope) {
59
+ export async function syncTokens(tokenStore, source = 'local') {
60
+ if (!source) {
54
61
  return;
55
62
  }
63
+ // Back-compat: a bare 'local'/'global' string still works.
64
+ const resolved = typeof source === 'string' ? { kind: source } : source;
56
65
  try {
57
66
  const ts = await tokenStore.get();
58
67
  if (ts?.accessToken) {
59
- saveConfig({
68
+ persistTokens(resolved, {
60
69
  accessToken: ts.accessToken,
61
70
  refreshToken: ts.refreshToken,
62
71
  expiresAt: ts.expiresAt,
63
72
  refreshTokenExpiresAt: ts.refreshTokenExpiresAt,
64
- }, scope);
73
+ });
65
74
  }
66
75
  } catch {
67
76
  // non-critical
package/lib/command.mjs CHANGED
@@ -1,7 +1,9 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
1
3
  import { buildClient, syncTokens } from './client.mjs';
2
4
  import { collectFieldFlags } from './flags.mjs';
3
5
  import { resolveResource } from './resources.mjs';
4
- import { error, info, warn } from './output.mjs';
6
+ import { error, info, warn, printQuery } from './output.mjs';
5
7
 
6
8
  export function fail(message) {
7
9
  error(message);
@@ -31,9 +33,9 @@ export function requireRecordId(id, usage) {
31
33
  }
32
34
  }
33
35
 
34
- export function buildCliClient() {
36
+ export function buildCliClient(values = {}) {
35
37
  try {
36
- return buildClient();
38
+ return buildClient({}, { profile: values.profile });
37
39
  } catch (err) {
38
40
  fail(err.message);
39
41
  }
@@ -49,6 +51,54 @@ export function parseJsonOption(value, flagName) {
49
51
  }
50
52
  }
51
53
 
54
+ export function parseJsonFileOption(value, flagName) {
55
+ if (value == null || value === '') {
56
+ fail(`--${flagName} requires a file path.`);
57
+ }
58
+
59
+ const filePath = String(value);
60
+ const absolutePath = resolve(process.cwd(), filePath);
61
+ let text;
62
+
63
+ try {
64
+ text = readFileSync(absolutePath, 'utf8');
65
+ } catch (err) {
66
+ if (err?.code === 'ENOENT') {
67
+ fail(`--${flagName} file not found: ${filePath}`);
68
+ }
69
+ if (err?.code === 'EISDIR') {
70
+ fail(`--${flagName} points to a directory, not a JSON file: ${filePath}`);
71
+ }
72
+ fail(`Could not read --${flagName} file ${filePath}: ${err.message || err}`);
73
+ }
74
+
75
+ try {
76
+ return JSON.parse(text);
77
+ } catch (err) {
78
+ fail(`--${flagName} file must contain valid JSON: ${filePath} (${err.message || err})`);
79
+ }
80
+ }
81
+
82
+ export function parseJsonOptionOrFile(values, flagName, fileFlagName = `${flagName}-file`) {
83
+ const hasInline = Object.prototype.hasOwnProperty.call(values, flagName);
84
+ const hasFile = Object.prototype.hasOwnProperty.call(values, fileFlagName);
85
+
86
+ if (hasInline && hasFile) {
87
+ fail(`Use either --${flagName} or --${fileFlagName}, not both.`);
88
+ }
89
+ if (hasInline) {
90
+ if (values[flagName] === '') {
91
+ fail(`--${flagName} requires a JSON value. Use --${fileFlagName} <path> for file input.`);
92
+ }
93
+ return parseJsonOption(values[flagName], flagName);
94
+ }
95
+ if (hasFile) {
96
+ return parseJsonFileOption(values[fileFlagName], fileFlagName);
97
+ }
98
+
99
+ return undefined;
100
+ }
101
+
52
102
  /** Cheap structural check: does this string look like an intended JSON object? */
53
103
  function looksLikeJsonObject(value) {
54
104
  return typeof value === 'string' && value.trim().startsWith('{');
@@ -90,7 +140,7 @@ function tryParseJsonObject(value) {
90
140
  * @returns {Record<string, unknown>}
91
141
  */
92
142
  export function buildRecordPayload(values, positionalData) {
93
- const parsed = parseJsonOption(values.data, 'data');
143
+ const parsed = parseJsonOptionOrFile(values, 'data', 'data-file');
94
144
  const data = parsed === undefined ? {} : parsed;
95
145
 
96
146
  if (!data || typeof data !== 'object' || Array.isArray(data)) {
@@ -125,6 +175,31 @@ export function buildRecordPayload(values, positionalData) {
125
175
  fail('No fields provided. Use --data or individual --<field> flags.');
126
176
  }
127
177
 
178
+ /**
179
+ * Handle the global `--query` flag: instead of sending the request, ask the
180
+ * client to resolve the route + payload (dry run) and print them. Returns
181
+ * `true` when it handled a dry run, so the caller can `return` early.
182
+ *
183
+ * @param {ReturnType<typeof buildCliClient>} clientState
184
+ * @param {string} operationId
185
+ * @param {unknown} input - the same input the real call would receive
186
+ * @param {Record<string, unknown>} values - parsed CLI flags
187
+ * @returns {Promise<boolean>}
188
+ */
189
+ export async function maybeDryRun(clientState, operationId, input, values) {
190
+ if (!values.query) return false;
191
+
192
+ const fn = requireApiMethod(clientState, operationId);
193
+ let descriptor;
194
+ try {
195
+ descriptor = await fn(input, { dryRun: true });
196
+ } catch (err) {
197
+ fail(`Could not build request: ${err.message}`);
198
+ }
199
+ printQuery(descriptor, values);
200
+ return true;
201
+ }
202
+
128
203
  export function requireApiMethod(clientState, operationId) {
129
204
  const fn = clientState.client.api[operationId];
130
205
  if (typeof fn !== 'function') {
package/lib/config.mjs CHANGED
@@ -1,52 +1,142 @@
1
1
  /**
2
- * Credential configuration cascade:
3
- * 1. Environment variables (ZEYOS_BASE_URL, ZEYOS_TOKEN, …)
4
- * 2. .zeyos/auth.json (walk up from CWD, like .gitconfig)
5
- * 3. ~/.config/zeyos/credentials.json
2
+ * Credential configuration with named profiles.
6
3
  *
7
- * The auth file stores connection params AND tokens.
8
- * Add .zeyos/auth.json to .gitignore.
4
+ * A "profile" is a full credential set (baseUrl, clientId, clientSecret, tokens)
5
+ * stored under a name. Profiles live in a global registry; a project can pin which
6
+ * profile it uses. The legacy single-file layout still works unchanged.
7
+ *
8
+ * Resolution cascade (first match decides which credential set is the base):
9
+ * 1. --profile <name> (CLI flag) -> named profile
10
+ * 2. ZEYOS_PROFILE (env var) -> named profile
11
+ * 3. .zeyos/profile (project pin, walked up) -> named profile
12
+ * 4. .zeyos/auth.json (legacy local, walked up)
13
+ * 5. profiles.json "active" (global active profile)
14
+ * 6. ~/.config/zeyos/credentials.json (legacy global)
15
+ * Environment credential vars (ZEYOS_BASE_URL, ZEYOS_TOKEN, …) always field-merge
16
+ * on top of whichever base was chosen.
17
+ *
18
+ * Add .zeyos/auth.json and .zeyos/profile to .gitignore.
9
19
  */
10
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
20
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
11
21
  import { join, dirname } from 'node:path';
12
22
  import { homedir } from 'node:os';
13
23
 
14
24
  /** @typedef {import('./types.mjs').CliConfig} CliConfig */
25
+ /** @typedef {{ kind: 'profile'|'local'|'global', name?: string, path?: string }} ConfigSource */
15
26
 
16
27
  // ── Constants ────────────────────────────────────────────────────────────────
17
28
 
18
- const LOCAL_DIR = '.zeyos';
19
- const LOCAL_FILE = 'auth.json';
20
- const GLOBAL_DIR = join(homedir(), '.config', 'zeyos');
21
- const GLOBAL_FILE = join(GLOBAL_DIR, 'credentials.json');
29
+ const LOCAL_DIR = '.zeyos';
30
+ const LOCAL_FILE = 'auth.json';
31
+ const PIN_FILE = 'profile';
32
+ const GLOBAL_DIR = join(homedir(), '.config', 'zeyos');
33
+ const GLOBAL_FILE = join(GLOBAL_DIR, 'credentials.json');
34
+ const PROFILES_FILE = join(GLOBAL_DIR, 'profiles.json');
35
+
36
+ /** Credential fields that make up a profile / auth file. */
37
+ const CRED_KEYS = [
38
+ 'baseUrl', 'instance', 'clientId', 'clientSecret',
39
+ 'accessToken', 'refreshToken', 'expiresAt', 'refreshTokenExpiresAt'
40
+ ];
41
+ const TOKEN_KEYS = ['accessToken', 'refreshToken', 'expiresAt', 'refreshTokenExpiresAt'];
22
42
 
23
43
  // ── Load ─────────────────────────────────────────────────────────────────────
24
44
 
25
45
  /**
26
46
  * Load the full config object using the cascade.
27
- * Returns a merged object; env vars always win over file values.
47
+ * @param {{ profile?: string }} [opts]
28
48
  */
29
- export function loadConfig() {
30
- return loadConfigWithSource().config;
49
+ export function loadConfig(opts = {}) {
50
+ return loadConfigWithSource(opts).config;
31
51
  }
32
52
 
33
53
  /**
34
- * Load config and identify the credential file scope that should receive
35
- * refreshed tokens. Local config overrides global config field-by-field, so a
36
- * partial local file cannot shadow global connection parameters.
54
+ * Load config and identify the credential store that should receive refreshed
55
+ * tokens (the `source`). Env credential vars field-merge on top of the resolved
56
+ * base so they always win.
57
+ *
58
+ * @param {{ profile?: string }} [opts]
59
+ * @returns {{ config: CliConfig, source: ConfigSource|null, profile: { name: string, origin: string }|null }}
37
60
  */
38
- export function loadConfigWithSource() {
39
- const localPath = _findLocalPath();
40
- const globalFile = _readGlobal();
41
- const localFile = localPath ? _readJson(localPath) : {};
61
+ export function loadConfigWithSource(opts = {}) {
42
62
  const env = _fromEnv();
63
+ const selection = resolveProfileSelection({ profileFlag: opts.profile });
64
+
65
+ let base = {};
66
+ let source = null;
67
+
68
+ if (selection.name) {
69
+ // An explicit/active profile was selected (flag, env, pin, or active pointer).
70
+ const prof = getProfile(selection.name);
71
+ base = prof ?? {};
72
+ source = { kind: 'profile', name: selection.name };
73
+ // selection.missing is surfaced via resolveProfileSelection consumers; base
74
+ // stays {} so requireConfig reports the missing fields.
75
+ } else {
76
+ // No named profile in play — fall back to the legacy single-file layout.
77
+ const localPath = _findLocalPath();
78
+ if (localPath) {
79
+ base = _readJson(localPath);
80
+ source = { kind: 'local', path: localPath };
81
+ } else if (existsSync(GLOBAL_FILE)) {
82
+ base = _readGlobal();
83
+ source = { kind: 'global' };
84
+ }
85
+ }
43
86
 
44
87
  return {
45
- config: { ...globalFile, ...localFile, ...env },
46
- source: localPath ? 'local' : (existsSync(GLOBAL_FILE) ? 'global' : null)
88
+ config: { ...base, ...env },
89
+ source,
90
+ profile: selection.name
91
+ ? { name: selection.name, origin: selection.origin, missing: Boolean(selection.missing) }
92
+ : null
47
93
  };
48
94
  }
49
95
 
96
+ /**
97
+ * Decide which profile name applies, and where the decision came from.
98
+ * Order: flag > ZEYOS_PROFILE env > project pin (.zeyos/profile) > global active.
99
+ * `.zeyos/auth.json` (legacy local) deliberately sits BELOW the pin but is handled
100
+ * in loadConfigWithSource (it is not a named profile).
101
+ *
102
+ * @param {{ profileFlag?: string }} [opts]
103
+ * @returns {{ name: string|null, origin: 'flag'|'env'|'pin'|'active'|null, path?: string, missing?: boolean }}
104
+ */
105
+ export function resolveProfileSelection(opts = {}) {
106
+ const flag = opts.profileFlag;
107
+ if (flag) return _withExistence({ name: flag, origin: 'flag' });
108
+
109
+ const envName = process.env.ZEYOS_PROFILE;
110
+ if (envName) return _withExistence({ name: envName, origin: 'env' });
111
+
112
+ const pin = readLocalPin();
113
+ // A pin only selects a named profile if there is NO legacy local auth.json that
114
+ // sits closer to the cwd (legacy projects keep working). When both exist at the
115
+ // same place the explicit pin wins.
116
+ if (pin) {
117
+ const localPath = _findLocalPath();
118
+ if (!localPath || _isSameOrShallower(pin.dir, localPath)) {
119
+ return _withExistence({ name: pin.name, origin: 'pin', path: pin.path });
120
+ }
121
+ }
122
+
123
+ // If a legacy local auth.json exists, it (not the global active profile) is the
124
+ // base — handled by the caller. Only fall through to the active profile when no
125
+ // local file shadows it.
126
+ if (_findLocalPath()) return { name: null, origin: null };
127
+
128
+ const active = getActiveProfileName();
129
+ if (active) return _withExistence({ name: active, origin: 'active' });
130
+
131
+ return { name: null, origin: null };
132
+ }
133
+
134
+ function _withExistence(sel) {
135
+ return { ...sel, missing: getProfile(sel.name) == null };
136
+ }
137
+
138
+ // ── Require ──────────────────────────────────────────────────────────────────
139
+
50
140
  /**
51
141
  * Require specific keys to be present; throws a human-friendly error if not.
52
142
  * @param {string[]} keys
@@ -67,12 +157,11 @@ export function requireConfig(keys, config) {
67
157
  throw new Error(`Missing required configuration:\n${messages.join('\n')}`);
68
158
  }
69
159
 
70
- // ── Save ─────────────────────────────────────────────────────────────────────
160
+ // ── Save (legacy single-file) ─────────────────────────────────────────────────
71
161
 
72
162
  /**
73
- * Save (merge) config values into the nearest .zeyos/auth.json found while
74
- * walking up, or create one in the current directory. Falls back to the
75
- * global file when `scope === 'global'`.
163
+ * Save (merge) config values into the nearest .zeyos/auth.json (or create one in
164
+ * the current directory), or the global credentials file when scope === 'global'.
76
165
  *
77
166
  * @param {CliConfig} updates
78
167
  * @param {'local'|'global'} scope
@@ -90,39 +179,163 @@ export function saveConfig(updates, scope = 'local') {
90
179
 
91
180
  /** Remove the stored tokens (leave connection params intact). */
92
181
  export function clearTokens(scope = 'local') {
93
- const strip = o => {
94
- const { accessToken, refreshToken, expiresAt, refreshTokenExpiresAt, ...rest } = o;
95
- return rest;
96
- };
97
182
  if (scope === 'global') {
98
- _writeGlobal(strip(_readGlobal()));
183
+ _writeGlobal(_stripTokens(_readGlobal()));
99
184
  return;
100
185
  }
101
186
  const path = _findLocalPath();
102
- if (path) _writeJson(path, strip(_readJson(path)));
187
+ if (path) _writeJson(path, _stripTokens(_readJson(path)));
188
+ }
189
+
190
+ /**
191
+ * Persist refreshed tokens back to wherever the active credentials came from.
192
+ * @param {ConfigSource|null} source
193
+ * @param {Partial<CliConfig>} tokens
194
+ */
195
+ export function persistTokens(source, tokens) {
196
+ if (!source) return;
197
+ const slice = {};
198
+ for (const k of TOKEN_KEYS) if (k in tokens) slice[k] = tokens[k];
199
+ if (source.kind === 'profile') {
200
+ upsertProfile(source.name, slice);
201
+ } else if (source.kind === 'global') {
202
+ saveConfig(slice, 'global');
203
+ } else {
204
+ saveConfig(slice, 'local');
205
+ }
206
+ }
207
+
208
+ /** Clear tokens from whichever store the source points at. */
209
+ export function clearTokensForSource(source) {
210
+ if (!source) return;
211
+ if (source.kind === 'profile') {
212
+ const prof = getProfile(source.name);
213
+ if (prof) upsertProfile(source.name, _stripTokens(prof), { replace: true });
214
+ } else if (source.kind === 'global') {
215
+ clearTokens('global');
216
+ } else {
217
+ clearTokens('local');
218
+ }
219
+ }
220
+
221
+ // ── Profiles ───────────────────────────────────────────────────────────────────
222
+
223
+ /** Read the profiles registry: { active, profiles }. */
224
+ export function readProfiles() {
225
+ const raw = existsSync(PROFILES_FILE) ? _readJson(PROFILES_FILE) : {};
226
+ return {
227
+ active: typeof raw.active === 'string' ? raw.active : null,
228
+ profiles: raw.profiles && typeof raw.profiles === 'object' ? raw.profiles : {}
229
+ };
230
+ }
231
+
232
+ /** List profile names with their (token-stripped-safe) creds and the active name. */
233
+ export function listProfiles() {
234
+ const { active, profiles } = readProfiles();
235
+ return {
236
+ active,
237
+ profiles: Object.fromEntries(Object.entries(profiles).map(([name, creds]) => [name, { ...creds }]))
238
+ };
239
+ }
240
+
241
+ /** Return a single profile's credentials, or null. */
242
+ export function getProfile(name) {
243
+ if (!name) return null;
244
+ const { profiles } = readProfiles();
245
+ return profiles[name] ? { ...profiles[name] } : null;
246
+ }
247
+
248
+ /**
249
+ * Create or update a profile (field merge by default).
250
+ * @param {string} name
251
+ * @param {Partial<CliConfig>} updates
252
+ * @param {{ replace?: boolean }} [opts] replace: overwrite instead of merge
253
+ */
254
+ export function upsertProfile(name, updates, opts = {}) {
255
+ if (!name) throw new Error('Profile name is required.');
256
+ const reg = readProfiles();
257
+ const current = opts.replace ? {} : (reg.profiles[name] || {});
258
+ reg.profiles[name] = _onlyCredKeys({ ...current, ...updates });
259
+ if (!reg.active) reg.active = name; // first profile becomes active
260
+ _writeProfiles(reg);
261
+ }
262
+
263
+ /** Delete a profile. Clears the active pointer if it referenced this profile. */
264
+ export function removeProfile(name) {
265
+ const reg = readProfiles();
266
+ if (!(name in reg.profiles)) return false;
267
+ delete reg.profiles[name];
268
+ if (reg.active === name) {
269
+ reg.active = Object.keys(reg.profiles)[0] ?? null;
270
+ }
271
+ _writeProfiles(reg);
272
+ return true;
273
+ }
274
+
275
+ /** Set the global active profile. Throws if it does not exist. */
276
+ export function setActiveProfile(name) {
277
+ const reg = readProfiles();
278
+ if (!(name in reg.profiles)) throw new Error(`No such profile: "${name}".`);
279
+ reg.active = name;
280
+ _writeProfiles(reg);
281
+ }
282
+
283
+ export function getActiveProfileName() {
284
+ return readProfiles().active;
285
+ }
286
+
287
+ // ── Project pin (.zeyos/profile) ─────────────────────────────────────────────
288
+
289
+ /** Read the nearest project pin: { name, path, dir } or null. */
290
+ export function readLocalPin() {
291
+ let dir = process.cwd();
292
+ for (let i = 0; i < 20; i++) {
293
+ const candidate = join(dir, LOCAL_DIR, PIN_FILE);
294
+ if (existsSync(candidate)) {
295
+ try {
296
+ const name = readFileSync(candidate, 'utf8').trim();
297
+ if (name) return { name, path: candidate, dir };
298
+ } catch { /* ignore unreadable pin */ }
299
+ }
300
+ const parent = dirname(dir);
301
+ if (parent === dir) break;
302
+ dir = parent;
303
+ }
304
+ return null;
103
305
  }
104
306
 
105
- /** Return the path of the active local .zeyos/auth.json (if any). */
106
- export function localConfigPath() {
107
- return _findLocalPath();
307
+ /** Pin a profile for the current project (writes ./.zeyos/profile). */
308
+ export function writeLocalPin(name, dir = process.cwd()) {
309
+ const path = join(dir, LOCAL_DIR, PIN_FILE);
310
+ mkdirSync(dirname(path), { recursive: true });
311
+ writeFileSync(path, `${name}\n`, { mode: 0o600 });
312
+ return path;
108
313
  }
109
314
 
110
- /** Return the global credentials file path. */
111
- export function globalConfigPath() {
112
- return GLOBAL_FILE;
315
+ /** Remove the nearest project pin, if any. Returns the removed path or null. */
316
+ export function clearLocalPin() {
317
+ const pin = readLocalPin();
318
+ if (pin) { unlinkSync(pin.path); return pin.path; }
319
+ return null;
113
320
  }
114
321
 
322
+ // ── Paths (for messages) ───────────────────────────────────────────────────────
323
+
324
+ export function localConfigPath() { return _findLocalPath(); }
325
+ export function globalConfigPath() { return GLOBAL_FILE; }
326
+ export function profilesConfigPath() { return PROFILES_FILE; }
327
+
115
328
  // ── Internals ────────────────────────────────────────────────────────────────
116
329
 
117
330
  function _fromEnv() {
118
331
  const e = process.env;
119
332
  const out = {};
120
- if (e.ZEYOS_BASE_URL) out.baseUrl = e.ZEYOS_BASE_URL;
121
- if (e.ZEYOS_INSTANCE) out.instance = e.ZEYOS_INSTANCE;
122
- if (e.ZEYOS_CLIENT_ID) out.clientId = e.ZEYOS_CLIENT_ID;
123
- if (e.ZEYOS_CLIENT_SECRET) out.clientSecret = e.ZEYOS_CLIENT_SECRET;
124
- if (e.ZEYOS_TOKEN) out.accessToken = e.ZEYOS_TOKEN;
125
- if (e.ZEYOS_REFRESH_TOKEN) out.refreshToken = e.ZEYOS_REFRESH_TOKEN;
333
+ if (e.ZEYOS_BASE_URL) out.baseUrl = e.ZEYOS_BASE_URL;
334
+ if (e.ZEYOS_INSTANCE) out.instance = e.ZEYOS_INSTANCE;
335
+ if (e.ZEYOS_CLIENT_ID) out.clientId = e.ZEYOS_CLIENT_ID;
336
+ if (e.ZEYOS_CLIENT_SECRET) out.clientSecret = e.ZEYOS_CLIENT_SECRET;
337
+ if (e.ZEYOS_TOKEN) out.accessToken = e.ZEYOS_TOKEN;
338
+ if (e.ZEYOS_REFRESH_TOKEN) out.refreshToken = e.ZEYOS_REFRESH_TOKEN;
126
339
  return out;
127
340
  }
128
341
 
@@ -138,6 +351,23 @@ function _findLocalPath() {
138
351
  return null;
139
352
  }
140
353
 
354
+ /** True when the pin directory is at or above the auth.json's directory. */
355
+ function _isSameOrShallower(pinDir, localPath) {
356
+ const localDir = dirname(dirname(localPath)); // strip /.zeyos/auth.json
357
+ return pinDir.length <= localDir.length;
358
+ }
359
+
360
+ function _onlyCredKeys(obj) {
361
+ const out = {};
362
+ for (const k of CRED_KEYS) if (obj[k] != null) out[k] = obj[k];
363
+ return out;
364
+ }
365
+
366
+ function _stripTokens(o) {
367
+ const { accessToken, refreshToken, expiresAt, refreshTokenExpiresAt, ...rest } = o;
368
+ return rest;
369
+ }
370
+
141
371
  function _readGlobal() {
142
372
  return existsSync(GLOBAL_FILE) ? _readJson(GLOBAL_FILE) : {};
143
373
  }
@@ -147,6 +377,11 @@ function _writeGlobal(data) {
147
377
  _writeJson(GLOBAL_FILE, data);
148
378
  }
149
379
 
380
+ function _writeProfiles(reg) {
381
+ mkdirSync(GLOBAL_DIR, { recursive: true });
382
+ _writeJson(PROFILES_FILE, { active: reg.active ?? null, profiles: reg.profiles ?? {} });
383
+ }
384
+
150
385
  function _readJson(path) {
151
386
  try {
152
387
  return JSON.parse(readFileSync(path, 'utf8'));
package/lib/flags.mjs CHANGED
@@ -6,11 +6,11 @@
6
6
  // Global CLI flags that are never record fields. Any other --flag on
7
7
  // create/update is treated as a field on the record being written.
8
8
  const RESERVED_FLAGS = new Set([
9
- 'data', 'json', 'yaml', 'help', 'h',
10
- 'no-color', 'force', 'fields', 'filter', 'sort',
9
+ 'data', 'data-file', 'json', 'yaml', 'help', 'h',
10
+ 'no-color', 'force', 'fields', 'filter', 'filter-file', 'sort',
11
11
  'limit', 'offset', 'expand', 'base-url', 'client-id',
12
12
  'secret', 'scope', 'global', 'port', 'manual', 'show-token',
13
- 'extdata', 'tags', 'all', 'clean',
13
+ 'extdata', 'tags', 'all', 'clean', 'query', 'profile',
14
14
  ]);
15
15
 
16
16
  /**