@jacebenson/jsn 2.0.2 ā 2.1.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/package.json +1 -1
- package/src/auth.js +31 -0
- package/src/cli.js +19 -0
- package/src/commands/auth.js +58 -10
- package/src/commands/profiles.js +91 -4
- package/src/commands/setup.js +41 -5
- package/src/mutations.js +52 -0
- package/src/output.js +24 -4
- package/src/sdk.js +7 -0
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -180,12 +180,33 @@ function loadCredentials(instance) {
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
function saveCredentials(instance, creds) {
|
|
183
|
+
// Stamp last_seen on every credential save
|
|
184
|
+
creds.last_seen = creds.last_seen || Math.floor(Date.now() / 1000);
|
|
183
185
|
// Try keyring first, fall back to file
|
|
184
186
|
if (!keyringStore(instance, creds)) {
|
|
185
187
|
fs.writeFileSync(credentialsPath(instance), JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
186
188
|
}
|
|
187
189
|
}
|
|
188
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Get the last_seen timestamp for an instance, if available.
|
|
193
|
+
*/
|
|
194
|
+
function getLastSeen(instance) {
|
|
195
|
+
const creds = loadCredentials(instance);
|
|
196
|
+
if (!creds || !creds.last_seen) return null;
|
|
197
|
+
return creds.last_seen;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Update the last_seen timestamp for an instance to now.
|
|
202
|
+
*/
|
|
203
|
+
function touchLastSeen(instance) {
|
|
204
|
+
const creds = loadCredentials(instance);
|
|
205
|
+
if (!creds) return;
|
|
206
|
+
creds.last_seen = Math.floor(Date.now() / 1000);
|
|
207
|
+
saveCredentials(instance, creds);
|
|
208
|
+
}
|
|
209
|
+
|
|
189
210
|
function deleteCredentials(instance) {
|
|
190
211
|
keyringDelete(instance);
|
|
191
212
|
try {
|
|
@@ -250,6 +271,14 @@ export class AuthManager {
|
|
|
250
271
|
this.httpClient = { timeout: 30000 };
|
|
251
272
|
}
|
|
252
273
|
|
|
274
|
+
getLastSeen(instance) {
|
|
275
|
+
return getLastSeen(instance);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
touchLastSeen(instance) {
|
|
279
|
+
return touchLastSeen(instance);
|
|
280
|
+
}
|
|
281
|
+
|
|
253
282
|
isAuthenticated() {
|
|
254
283
|
if (process.env.SERVICENOW_OAUTH_TOKEN) return true;
|
|
255
284
|
if (getBasicAuthFromEnv()) return true;
|
|
@@ -515,3 +544,5 @@ export class AuthManager {
|
|
|
515
544
|
deleteCredentials(instance);
|
|
516
545
|
}
|
|
517
546
|
}
|
|
547
|
+
|
|
548
|
+
export { saveCredentials, loadCredentials, askHidden };
|
package/src/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import process from 'node:process';
|
|
|
6
6
|
import { loadConfig, getEffectiveInstance } from './config.js';
|
|
7
7
|
import { App } from './app.js';
|
|
8
8
|
import { renderHelp } from './help.js';
|
|
9
|
+
import { isMutationCommand } from './mutations.js';
|
|
9
10
|
|
|
10
11
|
// Command modules
|
|
11
12
|
import { setupCmd } from './commands/setup.js';
|
|
@@ -122,6 +123,24 @@ export const cli = yargs(hideBin(process.argv))
|
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
// Read-only profile check ā block mutation commands on read-only profiles
|
|
127
|
+
const activeProfileName = cfg.activeProfile || cfg.defaultProfile;
|
|
128
|
+
if (activeProfileName && cfg.profiles[activeProfileName] && cfg.profiles[activeProfileName].read_only) {
|
|
129
|
+
const skipReadOnlyCheck = ['help', 'version', 'setup', 'auth', 'profiles', 'profile', 'skill', undefined].includes(cmd);
|
|
130
|
+
if (!skipReadOnlyCheck && isMutationCommand(argv)) {
|
|
131
|
+
process.stderr.write(`\nš Profile "${activeProfileName}" is read-only.\n`);
|
|
132
|
+
process.stderr.write(` Mutation commands are disabled on this profile.\n`);
|
|
133
|
+
const nonReadOnlyProfiles = Object.entries(cfg.profiles || {})
|
|
134
|
+
.filter(([n, p]) => n !== activeProfileName && !p.read_only)
|
|
135
|
+
.map(([n]) => n);
|
|
136
|
+
if (nonReadOnlyProfiles.length > 0) {
|
|
137
|
+
process.stderr.write(` Switch to a writable profile: jsn profiles use ${nonReadOnlyProfiles[0]}\n`);
|
|
138
|
+
}
|
|
139
|
+
process.stderr.write('\n');
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
125
144
|
// Auto-check skill on every command (fire-and-forget, non-blocking)
|
|
126
145
|
const skipSkillCheck = ['help', 'version', 'completion', 'skill'].includes(cmd)
|
|
127
146
|
|| process.env.JSN_NO_SKILL_CHECK === '1'
|
package/src/commands/auth.js
CHANGED
|
@@ -105,14 +105,28 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
|
|
|
105
105
|
// Non-fatal ā token is saved, just couldn't verify
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
// Save to profiles
|
|
109
|
-
const profileName = instanceURL.replace(/https?:\/\//, '').replace(/\.service-now\.com.*/, '').replace(/[^a-zA-Z0-9]/g, '-');
|
|
108
|
+
// Save to profiles ā deduplicate by instance URL
|
|
110
109
|
if (!app.config.profiles) {
|
|
111
110
|
app.config.profiles = {};
|
|
112
111
|
}
|
|
112
|
+
|
|
113
|
+
// Check if a profile already exists for this instance
|
|
114
|
+
let profileName = null;
|
|
115
|
+
for (const [existingName, existingProfile] of Object.entries(app.config.profiles)) {
|
|
116
|
+
if (existingProfile.instance_url === instanceURL) {
|
|
117
|
+
profileName = existingName;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// If no existing profile found, generate a name from the URL
|
|
123
|
+
if (!profileName) {
|
|
124
|
+
profileName = instanceURL.replace(/https?:\/\//, '').replace(/\.service-now\.com.*/, '').replace(/[^a-zA-Z0-9]/g, '-');
|
|
125
|
+
}
|
|
126
|
+
|
|
113
127
|
app.config.profiles[profileName] = {
|
|
114
128
|
instance_url: instanceURL,
|
|
115
|
-
auth_method: 'oauth',
|
|
129
|
+
auth_method: argv.password ? 'basic' : 'oauth',
|
|
116
130
|
username: username || undefined,
|
|
117
131
|
};
|
|
118
132
|
|
|
@@ -130,11 +144,12 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
|
|
|
130
144
|
authenticated: true,
|
|
131
145
|
instance: instanceURL,
|
|
132
146
|
username: username || undefined,
|
|
147
|
+
profile: profileName,
|
|
133
148
|
default: setDefault || undefined,
|
|
134
149
|
};
|
|
135
150
|
const summary = username
|
|
136
|
-
? `ā Authenticated to ${instanceURL} as ${username}`
|
|
137
|
-
: `ā Authenticated to ${instanceURL}`;
|
|
151
|
+
? `ā Authenticated to ${instanceURL} as ${username} (profile: ${profileName})`
|
|
152
|
+
: `ā Authenticated to ${instanceURL} (profile: ${profileName})`;
|
|
138
153
|
app.ok(result, { summary });
|
|
139
154
|
}),
|
|
140
155
|
})
|
|
@@ -170,21 +185,54 @@ Examples:
|
|
|
170
185
|
|
|
171
186
|
const profiles = [];
|
|
172
187
|
for (const [name, profile] of Object.entries(app.config.profiles || {})) {
|
|
173
|
-
const
|
|
188
|
+
const instance = profile.instance_url;
|
|
189
|
+
const isAuth = app.auth.isAuthenticatedFor(instance);
|
|
190
|
+
const lastSeen = app.auth.getLastSeen(instance);
|
|
191
|
+
|
|
192
|
+
// Try live verification
|
|
193
|
+
let verified = null;
|
|
194
|
+
let verifiedAt = null;
|
|
195
|
+
if (isAuth && instance) {
|
|
196
|
+
try {
|
|
197
|
+
const { SDKClient } = await import('../sdk.js');
|
|
198
|
+
const sdk = new SDKClient(instance, app.auth);
|
|
199
|
+
const user = await sdk.getCurrentUser();
|
|
200
|
+
if (user && user.user_name) {
|
|
201
|
+
verified = true;
|
|
202
|
+
verifiedAt = user.user_name;
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
verified = false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Calculate days since last seen
|
|
210
|
+
let daysSinceLastSeen = null;
|
|
211
|
+
if (lastSeen) {
|
|
212
|
+
daysSinceLastSeen = Math.floor((Date.now() / 1000 - lastSeen) / 86400);
|
|
213
|
+
}
|
|
214
|
+
|
|
174
215
|
profiles.push({
|
|
175
216
|
name,
|
|
176
|
-
instance
|
|
217
|
+
instance,
|
|
177
218
|
authenticated: isAuth,
|
|
178
|
-
|
|
219
|
+
verified,
|
|
220
|
+
verified_as: verifiedAt || undefined,
|
|
221
|
+
last_seen: lastSeen || undefined,
|
|
222
|
+
days_since_last_seen: daysSinceLastSeen,
|
|
223
|
+
stale: daysSinceLastSeen > 7,
|
|
224
|
+
default: instance === defaultInstance,
|
|
179
225
|
});
|
|
180
226
|
}
|
|
181
227
|
|
|
182
|
-
|
|
228
|
+
const result = {
|
|
183
229
|
default_instance: defaultInstance,
|
|
184
230
|
authenticated: app.auth.isAuthenticated(),
|
|
185
231
|
environment_auth: envToken ? true : undefined,
|
|
186
232
|
profiles,
|
|
187
|
-
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
app.ok(result, { summary: `${profiles.length} profile(s)` });
|
|
188
236
|
}),
|
|
189
237
|
})
|
|
190
238
|
.command({
|
package/src/commands/profiles.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { saveConfig } from '../config.js';
|
|
1
|
+
import { normalizeInstanceURL, saveConfig, setProfile } from '../config.js';
|
|
2
2
|
|
|
3
3
|
export function profilesCmd(wrap) {
|
|
4
4
|
return {
|
|
@@ -7,6 +7,74 @@ export function profilesCmd(wrap) {
|
|
|
7
7
|
describe: 'Manage configuration profiles',
|
|
8
8
|
builder: (yargs) => {
|
|
9
9
|
return yargs
|
|
10
|
+
.command({
|
|
11
|
+
command: 'create <name>',
|
|
12
|
+
describe: 'Create a new profile',
|
|
13
|
+
builder: (sub) => sub
|
|
14
|
+
.positional('name', {
|
|
15
|
+
describe: 'Profile name',
|
|
16
|
+
type: 'string',
|
|
17
|
+
})
|
|
18
|
+
.option('instance', {
|
|
19
|
+
alias: 'i',
|
|
20
|
+
describe: 'ServiceNow instance URL (e.g. dev12345.service-now.com)',
|
|
21
|
+
type: 'string',
|
|
22
|
+
})
|
|
23
|
+
.option('username', {
|
|
24
|
+
alias: 'u',
|
|
25
|
+
describe: 'Username for basic auth',
|
|
26
|
+
type: 'string',
|
|
27
|
+
})
|
|
28
|
+
.option('password', {
|
|
29
|
+
alias: 'p',
|
|
30
|
+
describe: 'Password for basic auth',
|
|
31
|
+
type: 'string',
|
|
32
|
+
})
|
|
33
|
+
.option('read-only', {
|
|
34
|
+
describe: 'Mark profile as read-only (blocks mutation commands)',
|
|
35
|
+
type: 'boolean',
|
|
36
|
+
default: false,
|
|
37
|
+
}),
|
|
38
|
+
handler: wrap(async (argv, app) => {
|
|
39
|
+
const name = argv.name;
|
|
40
|
+
if (app.config.profiles && app.config.profiles[name]) {
|
|
41
|
+
throw new Error(`Profile "${name}" already exists. Use "jsn profiles update" or remove it first.`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let instance = argv.instance;
|
|
45
|
+
if (!instance) {
|
|
46
|
+
throw new Error(`Instance URL is required.\n\n jsn profiles create ${name} --instance https://dev12345.service-now.com`);
|
|
47
|
+
}
|
|
48
|
+
instance = normalizeInstanceURL(instance);
|
|
49
|
+
|
|
50
|
+
const profile = { instance_url: instance };
|
|
51
|
+
|
|
52
|
+
// If username/password provided, save credentials too
|
|
53
|
+
if (argv.username && argv.password) {
|
|
54
|
+
const { saveCredentials } = await import('../auth.js');
|
|
55
|
+
saveCredentials(instance, {
|
|
56
|
+
auth_method: 'basic',
|
|
57
|
+
username: argv.username,
|
|
58
|
+
password: argv.password,
|
|
59
|
+
});
|
|
60
|
+
profile.auth_method = 'basic';
|
|
61
|
+
profile.username = argv.username;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// If read-only flag is set, mark the profile
|
|
65
|
+
if (argv['read-only']) {
|
|
66
|
+
profile.read_only = true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await setProfile(app.config, name, profile);
|
|
70
|
+
|
|
71
|
+
app.ok({
|
|
72
|
+
profile: name,
|
|
73
|
+
instance,
|
|
74
|
+
auth_method: profile.auth_method || 'oauth (not yet logged in)',
|
|
75
|
+
}, { summary: `Profile "${name}" created for ${instance}` });
|
|
76
|
+
}),
|
|
77
|
+
})
|
|
10
78
|
.command({
|
|
11
79
|
command: 'list',
|
|
12
80
|
describe: 'List all profiles',
|
|
@@ -22,9 +90,26 @@ export function profilesCmd(wrap) {
|
|
|
22
90
|
username: profile.username || '',
|
|
23
91
|
authenticated: isAuth,
|
|
24
92
|
default: isDefault,
|
|
93
|
+
read_only: profile.read_only || false,
|
|
25
94
|
});
|
|
26
95
|
}
|
|
27
|
-
|
|
96
|
+
|
|
97
|
+
// Build context-aware breadcrumb: suggest switching to a non-active profile
|
|
98
|
+
const breadcrumbs = [];
|
|
99
|
+
if (profiles.length > 0) {
|
|
100
|
+
const activeName = app.config.activeProfile || app.config.defaultProfile;
|
|
101
|
+
const nonActiveProfiles = profiles.filter(p => p.name !== activeName);
|
|
102
|
+
if (nonActiveProfiles.length > 0) {
|
|
103
|
+
const suggestName = nonActiveProfiles[0].name;
|
|
104
|
+
breadcrumbs.push({
|
|
105
|
+
action: 'switch',
|
|
106
|
+
cmd: `jsn profiles use ${suggestName}`,
|
|
107
|
+
description: 'Switch to a different profile',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
app.ok({ profiles }, { summary: `${profiles.length} profile(s)`, breadcrumbs });
|
|
28
113
|
}),
|
|
29
114
|
})
|
|
30
115
|
.command({
|
|
@@ -50,7 +135,8 @@ export function profilesCmd(wrap) {
|
|
|
50
135
|
throw new Error('No profiles configured');
|
|
51
136
|
}
|
|
52
137
|
const profile = app.config.profiles[name];
|
|
53
|
-
|
|
138
|
+
const readOnlyNote = profile.read_only ? ' [READ-ONLY]' : '';
|
|
139
|
+
app.ok({ name, ...profile }, { summary: `Profile: ${name}${readOnlyNote}` });
|
|
54
140
|
}),
|
|
55
141
|
})
|
|
56
142
|
.command({
|
|
@@ -78,8 +164,9 @@ export function profilesCmd(wrap) {
|
|
|
78
164
|
// When no subcommand is given, show help
|
|
79
165
|
if (!argv._[1]) {
|
|
80
166
|
console.log('Manage your ServiceNow instance profiles.\n');
|
|
81
|
-
console.log('Usage: jsn profiles <list|show|use|remove> [options]\n');
|
|
167
|
+
console.log('Usage: jsn profiles <create|list|show|use|remove> [options]\n');
|
|
82
168
|
console.log('Commands:');
|
|
169
|
+
console.log(' create <name> Create a new profile (--instance required)');
|
|
83
170
|
console.log(' list List all profiles');
|
|
84
171
|
console.log(' show [name] Show profile details');
|
|
85
172
|
console.log(' use <name> Switch to a different profile');
|
package/src/commands/setup.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import readline from 'node:readline';
|
|
2
2
|
import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setProfile } from '../config.js';
|
|
3
|
+
import { saveCredentials, loadCredentials, askHidden } from '../auth.js';
|
|
3
4
|
|
|
4
5
|
export function setupCmd(wrap) {
|
|
5
6
|
return {
|
|
6
7
|
command: 'setup',
|
|
7
8
|
describe: 'Interactive first-time setup',
|
|
9
|
+
builder: (yargs) => {
|
|
10
|
+
return yargs
|
|
11
|
+
.option('read-only', {
|
|
12
|
+
describe: 'Mark profile as read-only (blocks mutation commands)',
|
|
13
|
+
type: 'boolean',
|
|
14
|
+
default: false,
|
|
15
|
+
});
|
|
16
|
+
},
|
|
8
17
|
handler: wrap(async (_argv, app) => {
|
|
9
18
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
10
19
|
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
@@ -20,16 +29,43 @@ export function setupCmd(wrap) {
|
|
|
20
29
|
console.log(`Instance: ${instance}`);
|
|
21
30
|
|
|
22
31
|
const profileName = await ask('Profile name (default): ') || 'default';
|
|
23
|
-
|
|
32
|
+
const profile = { instance_url: instance };
|
|
33
|
+
if (_argv['read-only']) {
|
|
34
|
+
profile.read_only = true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const authMethod = await ask('Authentication method (OAuth/Basic) [OAuth]: ');
|
|
38
|
+
const useBasic = authMethod.toLowerCase().startsWith('b');
|
|
39
|
+
|
|
40
|
+
if (useBasic) {
|
|
41
|
+
const existing = loadCredentials(instance);
|
|
42
|
+
if (existing && existing.auth_method === 'basic') {
|
|
43
|
+
console.log(`Using existing credentials for ${instance}`);
|
|
44
|
+
} else {
|
|
45
|
+
const username = await ask('Username: ');
|
|
46
|
+
rl.close(); // Close outer readline before askHidden creates its own
|
|
47
|
+
const password = await askHidden('Password: ');
|
|
48
|
+
saveCredentials(instance, { auth_method: 'basic', username, password });
|
|
49
|
+
profile.username = username;
|
|
50
|
+
console.log('Basic auth credentials saved');
|
|
51
|
+
}
|
|
52
|
+
profile.auth_method = 'basic';
|
|
53
|
+
} else {
|
|
54
|
+
profile.auth_method = 'oauth';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await setProfile(app.config, profileName, profile);
|
|
24
58
|
// Set as active profile so subsequent commands know which instance to use
|
|
25
59
|
app.config.activeProfile = profileName;
|
|
26
60
|
app.config.defaultProfile = profileName;
|
|
27
61
|
saveConfig(app.config);
|
|
28
62
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
63
|
+
if (!useBasic) {
|
|
64
|
+
const loginNow = await ask('Login now? [Y/n]: ');
|
|
65
|
+
if (!loginNow || loginNow.toLowerCase() !== 'n') {
|
|
66
|
+
await app.auth.login(instance);
|
|
67
|
+
console.log('Login successful!');
|
|
68
|
+
}
|
|
33
69
|
}
|
|
34
70
|
|
|
35
71
|
rl.close();
|
package/src/mutations.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Centralized mutation command registry
|
|
2
|
+
// Used by the read-only profile middleware to block mutation commands.
|
|
3
|
+
// Each entry is a command path array matching argv._ tokens.
|
|
4
|
+
|
|
5
|
+
export const MUTATION_COMMANDS = [
|
|
6
|
+
// Incidents
|
|
7
|
+
['incidents', 'create'],
|
|
8
|
+
['incidents', 'update'],
|
|
9
|
+
['incidents', 'delete'],
|
|
10
|
+
// Changes
|
|
11
|
+
['changes', 'create'],
|
|
12
|
+
['changes', 'update'],
|
|
13
|
+
['changes', 'delete'],
|
|
14
|
+
// Records
|
|
15
|
+
['records', 'create'],
|
|
16
|
+
['records', 'update'],
|
|
17
|
+
['records', 'delete'],
|
|
18
|
+
// Requests
|
|
19
|
+
['requests', 'create'],
|
|
20
|
+
['requests', 'update'],
|
|
21
|
+
['requests', 'delete'],
|
|
22
|
+
// Tasks
|
|
23
|
+
['tasks', 'create'],
|
|
24
|
+
['tasks', 'update'],
|
|
25
|
+
['tasks', 'delete'],
|
|
26
|
+
// Dev
|
|
27
|
+
['dev', 'eval'],
|
|
28
|
+
['dev', 'updatesets', 'set'],
|
|
29
|
+
['dev', 'updatesets', 'create'],
|
|
30
|
+
['dev', 'scopes', 'set'],
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check if the parsed argv matches any mutation command pattern.
|
|
35
|
+
* @param {object} argv ā yargs parsed argv with `_` array
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
export function isMutationCommand(argv) {
|
|
39
|
+
const cmd = argv._ || [];
|
|
40
|
+
for (const pattern of MUTATION_COMMANDS) {
|
|
41
|
+
if (pattern.length !== cmd.length) continue;
|
|
42
|
+
let match = true;
|
|
43
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
44
|
+
if (pattern[i] !== cmd[i]) {
|
|
45
|
+
match = false;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (match) return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
package/src/output.js
CHANGED
|
@@ -176,11 +176,31 @@ export class OutputWriter {
|
|
|
176
176
|
this.writer.write('\n');
|
|
177
177
|
for (const p of data.profiles) {
|
|
178
178
|
const prefix = p.default ? '* ' : ' ';
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
179
|
+
const authIcon = p.authenticated ? 'ā' : 'ā';
|
|
180
|
+
|
|
181
|
+
// Show verified status if we got one
|
|
182
|
+
let verifiedStr = '';
|
|
183
|
+
if (p.verified === true) {
|
|
184
|
+
verifiedStr = ' ā
';
|
|
185
|
+
} else if (p.verified === false) {
|
|
186
|
+
verifiedStr = ' ā ļø';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Show stale hint if >7 days since last seen
|
|
190
|
+
let staleStr = '';
|
|
191
|
+
if (p.stale && p.days_since_last_seen) {
|
|
192
|
+
staleStr = ` (${p.days_since_last_seen}d ago ā may have been released)`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Show lock icon for read-only profiles
|
|
196
|
+
const lockIcon = p.read_only ? ' š' : '';
|
|
197
|
+
|
|
198
|
+
if (p.name) {
|
|
199
|
+
this.writer.write(`${prefix}${authIcon} ${p.name} ā ${p.instance}${lockIcon}${verifiedStr}${staleStr}\n`);
|
|
200
|
+
} else if (p.username) {
|
|
201
|
+
this.writer.write(`${prefix}${authIcon} ${p.instance} (as ${p.username})${lockIcon}${verifiedStr}${staleStr}\n`);
|
|
182
202
|
} else {
|
|
183
|
-
this.writer.write(`${prefix}${
|
|
203
|
+
this.writer.write(`${prefix}${authIcon} ${p.instance}${lockIcon}${verifiedStr}${staleStr}\n`);
|
|
184
204
|
}
|
|
185
205
|
}
|
|
186
206
|
}
|
package/src/sdk.js
CHANGED
|
@@ -65,6 +65,13 @@ export class SDKClient {
|
|
|
65
65
|
return null;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Stamp last_seen on successful API call
|
|
69
|
+
try {
|
|
70
|
+
if (this.authProvider && typeof this.authProvider.touchLastSeen === 'function') {
|
|
71
|
+
this.authProvider.touchLastSeen(this.baseURL);
|
|
72
|
+
}
|
|
73
|
+
} catch { /* non-fatal */ }
|
|
74
|
+
|
|
68
75
|
return JSON.parse(body);
|
|
69
76
|
} catch (err) {
|
|
70
77
|
if (err.name === 'AbortError') {
|