instoryview 0.0.0-stage → 0.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/README.md CHANGED
@@ -1,3 +1,28 @@
1
- # Temporary Holding Version
1
+ # Instoryview CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Requires Node.js 20 or newer.
4
+
5
+ ```bash
6
+ npm install --global instoryview
7
+ instoryview --version
8
+ instoryview auth login
9
+ instoryview tracking list
10
+ instoryview followers alice --json
11
+ ```
12
+
13
+ `auth login` stores a verified Key in the macOS System Keychain. Linux, Windows, containers, and CI use `INSTORYVIEW_API_KEY` from their environment or secret manager. No plaintext credential file is created. `auth status` validates an existing credential through the tracking read endpoint. `auth logout` deletes the local Keychain credential; unset environment credentials separately and revoke the Key on the website to disable it everywhere.
14
+
15
+ Read commands: `tracking list`, `account USERNAME`, `followers USERNAME`, `following USERNAME`, and `changes USERNAME`. These only read existing data. Missing data does not trigger a paid collection. Lists return one page; pass `--cursor` explicitly to request another. `tracking list --include-archived` includes retained archived records. Changes default to the last seven calendar days in UTC; use paired `--from` / `--to`, `--timezone`, and `--list following` when needed.
16
+
17
+ ```bash
18
+ instoryview changes alice --from 2026-10-01 --to 2026-10-07 --timezone UTC --json
19
+ instoryview followers bob --live --max-credits 2 --json
20
+ ```
21
+
22
+ An online command requires both `--live` and a positive `--max-credits`, plus a Key with online-query permission. It never starts continuous tracking. Each page is a separate budgeted request. The CLI prints a recovery command and saves only its parameters and idempotency key under `${XDG_STATE_HOME:-~/.local/state}/instoryview/`, with private permissions. `request_in_progress` recovery uses the same body and key for at most 60 seconds. Unknown results and network failures stop with recovery instructions; never retry them with a new key unless you explicitly intend a new paid query.
23
+
24
+ Successful `--json` output is the API's unmodified `{data, meta}` envelope on stdout. Progress and errors go to stderr. Exit codes: `0` success, `1` network/service, `2` invalid arguments, `3` auth/permission, `4` unavailable data, `5` budget or pending result. Data timestamps and partial coverage matter: returned page size is not the full Instagram follower count, and change detection time is not the exact action time.
25
+
26
+ Set `INSTORYVIEW_API_URL` only when deliberately connecting to another environment. It defaults to `https://instoryview.com/open-api/v1`; HTTPS is required except for loopback development URLs. Stored Keychain credentials are scoped to the API URL. Redirects are refused. Run `instoryview --help` for all options.
27
+
28
+ To update, run `npm install --global instoryview@latest`. To uninstall, run `npm uninstall --global instoryview`.
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/cli.mjs';
3
+ process.exitCode = await run(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "instoryview",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Read Instagram tracking snapshots and run explicitly budgeted public queries.",
5
+ "type": "module",
6
+ "bin": {
7
+ "instoryview": "bin/instoryview.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test test/*.test.mjs",
19
+ "typecheck": "node --check src/cli.mjs"
20
+ },
21
+ "homepage": "https://instoryview.com/docs/cli",
22
+ "license": "UNLICENSED",
23
+ "keywords": ["instagram", "follower-tracker", "instoryview", "cli"],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ }
27
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,453 @@
1
+ import { randomUUID, createHash } from 'node:crypto';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { readCredential, saveCredential, removeCredential } from './credentials.mjs';
6
+
7
+ const VERSION = '0.1.0';
8
+ const DEFAULT_URL = 'https://instoryview.com/open-api/v1';
9
+ const HELP = `Instoryview ${VERSION} — existing data first; online queries require a budget.
10
+
11
+ Usage:
12
+ instoryview auth login|status|logout
13
+ instoryview tracking list [--include-archived] [--limit N] [--cursor CURSOR]
14
+ instoryview account USERNAME
15
+ instoryview followers USERNAME [--limit N] [--cursor CURSOR]
16
+ instoryview following USERNAME [--limit N] [--cursor CURSOR]
17
+ instoryview changes USERNAME [--list followers|following]
18
+ [--from YYYY-MM-DD --to YYYY-MM-DD] [--timezone TIMEZONE] [--limit N] [--cursor CURSOR]
19
+ instoryview account|followers|following USERNAME --live --max-credits N
20
+ [--cursor CURSOR] [--idempotency-key KEY]
21
+
22
+ All commands accept --json. Reads never start collection or consume new collection credits.
23
+ Online queries return one page; no automatic paging. Recover the same request using its original key.
24
+ Credentials: INSTORYVIEW_API_KEY, or macOS System Keychain via auth login.
25
+ Exit codes: 0 success, 1 service/network, 2 arguments, 3 auth/permission,
26
+ 4 unavailable data, 5 budget or pending result.
27
+ `;
28
+
29
+ class CliError extends Error {
30
+ constructor(message, exitCode = 2, payload) {
31
+ super(message);
32
+ this.exitCode = exitCode;
33
+ this.payload = payload;
34
+ }
35
+ }
36
+
37
+ const VALUE_FLAGS = new Set([
38
+ 'limit',
39
+ 'cursor',
40
+ 'list',
41
+ 'from',
42
+ 'to',
43
+ 'timezone',
44
+ 'max-credits',
45
+ 'idempotency-key',
46
+ ]);
47
+ const BOOLEAN_FLAGS = new Set(['json', 'live', 'include-archived', 'help', 'version']);
48
+
49
+ function parse(args) {
50
+ const flags = {};
51
+ const positionals = [];
52
+ for (let index = 0; index < args.length; index++) {
53
+ const arg = args[index];
54
+ if (!arg.startsWith('--')) {
55
+ positionals.push(arg);
56
+ continue;
57
+ }
58
+ const equal = arg.indexOf('=');
59
+ const name = arg.slice(2, equal < 0 ? undefined : equal);
60
+ if (Object.hasOwn(flags, name)) throw new CliError(`Duplicate --${name}.`);
61
+ if (BOOLEAN_FLAGS.has(name)) {
62
+ if (equal >= 0) throw new CliError(`--${name} does not take a value.`);
63
+ flags[name] = true;
64
+ } else if (VALUE_FLAGS.has(name)) {
65
+ const value = equal < 0 ? args[++index] : arg.slice(equal + 1);
66
+ if (!value || value.startsWith('--')) throw new CliError(`--${name} requires a value.`);
67
+ flags[name] = value;
68
+ } else throw new CliError(`Unknown option --${name}.`);
69
+ }
70
+ return { flags, positionals };
71
+ }
72
+
73
+ function positiveInteger(value, name, maximum = Number.MAX_SAFE_INTEGER) {
74
+ if (!/^\d+$/.test(value ?? '') || Number(value) < 1 || Number(value) > maximum)
75
+ throw new CliError(`--${name} must be an integer from 1 to ${maximum}.`);
76
+ return Number(value);
77
+ }
78
+
79
+ function baseUrl(value) {
80
+ let url;
81
+ try {
82
+ url = new URL(value);
83
+ } catch {
84
+ throw new CliError('INSTORYVIEW_API_URL must be an absolute HTTPS URL.');
85
+ }
86
+ const local = ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname);
87
+ if (
88
+ (url.protocol !== 'https:' && !(local && url.protocol === 'http:')) ||
89
+ url.username ||
90
+ url.password ||
91
+ url.search ||
92
+ url.hash
93
+ )
94
+ throw new CliError(
95
+ 'Use HTTPS without URL credentials, query, or fragment (HTTP is allowed only for localhost).',
96
+ );
97
+ return url.href.replace(/\/$/, '');
98
+ }
99
+
100
+ function buildRequest(positionals, flags) {
101
+ const [command, operand] = positionals;
102
+ if (positionals.length !== 2)
103
+ throw new CliError('Provide a command and username (or tracking list). See --help.');
104
+ const username = operand.replace(/^@/, '').toLowerCase();
105
+ if (command !== 'tracking' && !/^[a-z0-9._]{1,30}$/.test(username))
106
+ throw new CliError('Provide an Instagram username, not a URL.');
107
+ const reads = ['account', 'followers', 'following', 'changes'];
108
+ if (!(command === 'tracking' && operand === 'list') && !reads.includes(command))
109
+ throw new CliError('Unknown command. See --help.');
110
+ const allowed = new Set(['json', 'help', 'version']);
111
+ if (flags.live) {
112
+ if (!['account', 'followers', 'following'].includes(command))
113
+ throw new CliError('--live is supported only for account, followers, and following.');
114
+ for (const flag of ['live', 'max-credits', 'idempotency-key']) allowed.add(flag);
115
+ if (command !== 'account') allowed.add('cursor');
116
+ } else {
117
+ if (command !== 'account') {
118
+ allowed.add('limit');
119
+ allowed.add('cursor');
120
+ }
121
+ if (command === 'tracking') allowed.add('include-archived');
122
+ if (command === 'changes')
123
+ for (const flag of ['list', 'from', 'to', 'timezone']) allowed.add(flag);
124
+ }
125
+ for (const flag of Object.keys(flags))
126
+ if (!allowed.has(flag))
127
+ throw new CliError(
128
+ `--${flag} is not supported for this command${flag === 'max-credits' ? '; add --live to explicitly request collection' : ''}.`,
129
+ );
130
+ if (flags.live) {
131
+ const body = {
132
+ username,
133
+ type: command === 'account' ? 'profile' : command,
134
+ maxCredits: positiveInteger(flags['max-credits'], 'max-credits'),
135
+ };
136
+ if (flags.cursor) body.cursor = flags.cursor;
137
+ const key = flags['idempotency-key'] ?? randomUUID();
138
+ if (!/^[A-Za-z0-9._:-]{8,128}$/.test(key))
139
+ throw new CliError(
140
+ '--idempotency-key must contain 8–128 letters, numbers, dots, underscores, colons, or hyphens.',
141
+ );
142
+ return { path: '/queries', method: 'POST', body, key };
143
+ }
144
+ const query = new URLSearchParams();
145
+ if (flags.limit) query.set('limit', String(positiveInteger(flags.limit, 'limit', 100)));
146
+ if (flags.cursor) query.set('cursor', flags.cursor);
147
+ if (flags['include-archived']) query.set('includeArchived', 'true');
148
+ if (command === 'changes') {
149
+ if (flags.list && !['followers', 'following'].includes(flags.list))
150
+ throw new CliError('--list must be followers or following.');
151
+ if (Boolean(flags.from) !== Boolean(flags.to))
152
+ throw new CliError('--from and --to must be provided together.');
153
+ for (const name of ['from', 'to']) {
154
+ if (!flags[name]) continue;
155
+ const date = new Date(`${flags[name]}T00:00:00Z`);
156
+ if (
157
+ !/^\d{4}-\d{2}-\d{2}$/.test(flags[name]) ||
158
+ Number.isNaN(date.valueOf()) ||
159
+ date.toISOString().slice(0, 10) !== flags[name]
160
+ )
161
+ throw new CliError(`--${name} must be a valid YYYY-MM-DD date.`);
162
+ }
163
+ if (flags.from && flags.from > flags.to) throw new CliError('--from must not be after --to.');
164
+ if (flags.timezone)
165
+ try {
166
+ new Intl.DateTimeFormat('en', { timeZone: flags.timezone });
167
+ } catch {
168
+ throw new CliError('Invalid --timezone.');
169
+ }
170
+ for (const name of ['list', 'from', 'to', 'timezone'])
171
+ if (flags[name]) query.set(name, flags[name]);
172
+ }
173
+ const path =
174
+ command === 'tracking'
175
+ ? '/tracking'
176
+ : `/accounts/${encodeURIComponent(username)}${command === 'account' ? '' : `/${command}`}`;
177
+ return { path: `${path}${query.size ? `?${query}` : ''}`, method: 'GET' };
178
+ }
179
+
180
+ function apiExitCode(status, code) {
181
+ if (status === 401 || status === 403) return 3;
182
+ if (
183
+ status === 402 ||
184
+ ['request_in_progress', 'query_outcome_unknown', 'result_expired'].includes(code)
185
+ )
186
+ return 5;
187
+ if (status === 404 || status === 410) return 4;
188
+ if (status === 400 || code === 'idempotency_conflict') return 2;
189
+ return 1;
190
+ }
191
+
192
+ async function rememberQuery(request, url, env) {
193
+ const root = join(env.XDG_STATE_HOME || join(homedir(), '.local', 'state'), 'instoryview');
194
+ await mkdir(root, { recursive: true, mode: 0o700 });
195
+ const name = createHash('sha256').update(`${url}\0${request.key}`).digest('hex');
196
+ // Contains recovery parameters only. No credential or server response is stored.
197
+ const path = join(root, `query-${name}.json`);
198
+ try {
199
+ await writeFile(
200
+ path,
201
+ `${JSON.stringify({ apiUrl: url, idempotencyKey: request.key, body: request.body, createdAt: new Date().toISOString() }, null, 2)}\n`,
202
+ { mode: 0o600, flag: 'wx' },
203
+ );
204
+ } catch (error) {
205
+ if (error.code !== 'EEXIST') throw error;
206
+ const original = JSON.parse(await readFile(path, 'utf8'));
207
+ if (JSON.stringify(original.body) !== JSON.stringify(request.body))
208
+ throw new CliError(
209
+ 'This idempotency key was already used with different parameters. Restore the original request.',
210
+ 2,
211
+ );
212
+ }
213
+ }
214
+
215
+ function retryDelay(header, now) {
216
+ if (!header) return 1000;
217
+ const seconds = Number(header);
218
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.max(100, seconds * 1000);
219
+ const date = Date.parse(header);
220
+ return Number.isNaN(date) ? 1000 : Math.max(100, date - now);
221
+ }
222
+
223
+ async function requestApi(request, url, key, io) {
224
+ const started = io.now();
225
+ const deadline = started + 60_000;
226
+ let lastPayload;
227
+ do {
228
+ let response;
229
+ try {
230
+ response = await io.fetch(`${url}${request.path}`, {
231
+ method: request.method,
232
+ redirect: 'error',
233
+ headers: {
234
+ Authorization: `Bearer ${key}`,
235
+ Accept: 'application/json',
236
+ ...(request.method === 'POST'
237
+ ? { 'Content-Type': 'application/json', 'Idempotency-Key': request.key }
238
+ : {}),
239
+ },
240
+ ...(request.body ? { body: JSON.stringify(request.body) } : {}),
241
+ signal: AbortSignal.timeout(Math.min(15_000, Math.max(1, deadline - io.now()))),
242
+ });
243
+ } catch {
244
+ throw new CliError(
245
+ request.method === 'POST'
246
+ ? 'Network response unavailable. The query may have run; preserve the original idempotency key and restore the same request.'
247
+ : 'Network request failed. Check the connection and API URL.',
248
+ request.method === 'POST' ? 5 : 1,
249
+ );
250
+ }
251
+ let payload;
252
+ try {
253
+ payload = await response.json();
254
+ } catch {
255
+ throw new CliError(
256
+ 'The API returned a non-JSON response. Preserve the query key if this was an online request.',
257
+ request.method === 'POST' ? 5 : 1,
258
+ );
259
+ }
260
+ if (response.ok) {
261
+ if (!payload || !Object.hasOwn(payload, 'data') || !Object.hasOwn(payload, 'meta'))
262
+ throw new CliError('The API returned an unsupported response structure.', 1);
263
+ return payload;
264
+ }
265
+ const code = payload?.error?.code;
266
+ lastPayload = payload;
267
+ if (request.method === 'POST' && response.status === 409 && code === 'request_in_progress') {
268
+ const delay = retryDelay(response.headers.get('Retry-After'), io.now());
269
+ if (io.now() + delay >= deadline) break;
270
+ io.stderr('Query is still in progress; restoring the same request.\n');
271
+ await io.sleep(delay);
272
+ continue;
273
+ }
274
+ throw new CliError(
275
+ payload?.error?.message || `API request failed (${response.status}).`,
276
+ apiExitCode(response.status, code),
277
+ payload,
278
+ );
279
+ } while (io.now() < deadline);
280
+ throw new CliError(
281
+ 'Query is still pending after the 60-second recovery window. Restore manually using the original idempotency key.',
282
+ 5,
283
+ lastPayload,
284
+ );
285
+ }
286
+
287
+ async function hiddenPrompt(io) {
288
+ if (!process.stdin.isTTY || !process.stdin.setRawMode)
289
+ throw new CliError('Use INSTORYVIEW_API_KEY in a non-interactive terminal.', 3);
290
+ io.stderr('Create an API Key at https://instoryview.com/developers/keys\nAPI Key (hidden): ');
291
+ const wasRaw = process.stdin.isRaw;
292
+ process.stdin.setRawMode(true);
293
+ process.stdin.resume();
294
+ return new Promise((resolve, reject) => {
295
+ let value = '';
296
+ const cleanup = () => {
297
+ process.stdin.off('data', onData);
298
+ process.stdin.setRawMode(Boolean(wasRaw));
299
+ process.stdin.pause();
300
+ io.stderr('\n');
301
+ };
302
+ const onData = (chunk) => {
303
+ for (const character of chunk.toString()) {
304
+ if (character === '\u0003') {
305
+ cleanup();
306
+ reject(new CliError('Login cancelled.', 3));
307
+ return;
308
+ }
309
+ if (character === '\r' || character === '\n') {
310
+ cleanup();
311
+ resolve(value.trim());
312
+ return;
313
+ }
314
+ if (character === '\u007f' || character === '\b') value = value.slice(0, -1);
315
+ else if (character >= ' ') value += character;
316
+ }
317
+ };
318
+ process.stdin.on('data', onData);
319
+ });
320
+ }
321
+
322
+ function render(payload) {
323
+ const { data, meta } = payload;
324
+ const rows = data?.items;
325
+ let answer;
326
+ if (Array.isArray(rows)) {
327
+ answer = rows.length
328
+ ? rows
329
+ .map((item) => {
330
+ if (item.kind)
331
+ return `${item.detectedAt ?? 'unknown time'} ${item.kind} @${item.account?.username ?? item.account?.id ?? 'unknown'}`;
332
+ return `@${item.username ?? item.id ?? 'unknown'}${item.fullName ? ` ${item.fullName}` : ''}${item.status ? ` ${item.status} followers: ${item.followerCount ?? 'unknown'} profile as of: ${item.profileAsOf ?? 'unknown'} followers snapshot: ${item.listSnapshots?.followers ?? 'unavailable'} following snapshot: ${item.listSnapshots?.following ?? 'unavailable'}` : ''}`;
333
+ })
334
+ .join('\n')
335
+ : 'No items in this page.';
336
+ if (Object.hasOwn(data, 'returnedCount'))
337
+ answer += `\nReturned: ${data.returnedCount}; available in this data source: ${data.availableCount ?? 'unknown'}.`;
338
+ if (data.summary)
339
+ answer += `\nDetected: +${data.summary.added ?? 'unknown'} / -${data.summary.removed ?? 'unknown'}; count change: ${data.summary.countNetChange ?? 'unknown'}.`;
340
+ } else
341
+ answer = `@${data?.username ?? 'unknown'}${data?.fullName ? ` ${data.fullName}` : ''}\nFollowers: ${data?.followerCount ?? 'unknown'}; following: ${data?.followingCount ?? 'unknown'}.`;
342
+ answer += `\nSource: ${meta.source ?? 'unknown'}; as of: ${meta.asOf ?? 'see item timestamps'}; credits: ${meta.creditsUsed ?? 'pending'}.`;
343
+ if (meta.coverage)
344
+ answer += `\nCoverage: ${meta.coverage}. Page size is not the Instagram follower total.`;
345
+ if (meta.warnings?.length) answer += `\nWarnings: ${meta.warnings.join(', ')}.`;
346
+ if (meta.nextCursor) answer += `\nNext cursor: ${meta.nextCursor}`;
347
+ return `${answer}\n`;
348
+ }
349
+
350
+ export async function run(args, options = {}) {
351
+ const io = {
352
+ env: options.env ?? process.env,
353
+ stdout: options.stdout ?? ((text) => process.stdout.write(text)),
354
+ stderr: options.stderr ?? ((text) => process.stderr.write(text)),
355
+ fetch: options.fetch ?? globalThis.fetch,
356
+ sleep: options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
357
+ now: options.now ?? Date.now,
358
+ credentials: options.credentials ?? {
359
+ read: readCredential,
360
+ save: saveCredential,
361
+ remove: removeCredential,
362
+ },
363
+ remember: options.remember ?? rememberQuery,
364
+ prompt: options.prompt ?? hiddenPrompt,
365
+ platform: options.platform ?? process.platform,
366
+ };
367
+ let json = args.includes('--json');
368
+ try {
369
+ const { flags, positionals } = parse(args);
370
+ json = Boolean(flags.json);
371
+ if (flags.version) {
372
+ io.stdout(`${VERSION}\n`);
373
+ return 0;
374
+ }
375
+ if (flags.help || !args.length) {
376
+ io.stdout(HELP);
377
+ return 0;
378
+ }
379
+ const url = baseUrl(io.env.INSTORYVIEW_API_URL ?? DEFAULT_URL);
380
+ if (positionals[0] === 'auth') {
381
+ if (
382
+ positionals.length !== 2 ||
383
+ !['login', 'status', 'logout'].includes(positionals[1]) ||
384
+ Object.keys(flags).some((flag) => flag !== 'json')
385
+ )
386
+ throw new CliError('Use auth login, auth status, or auth logout, optionally with --json.');
387
+ const operation = positionals[1];
388
+ if (operation === 'logout') {
389
+ await io.credentials.remove(url);
390
+ const result = {
391
+ authenticated: Boolean(io.env.INSTORYVIEW_API_KEY),
392
+ environmentCredential: Boolean(io.env.INSTORYVIEW_API_KEY),
393
+ message: io.env.INSTORYVIEW_API_KEY
394
+ ? 'Local Keychain credential removed. Unset INSTORYVIEW_API_KEY to remove the active environment credential.'
395
+ : 'Local credential removed. Revoke the Key on the website to disable it on other devices.',
396
+ };
397
+ io.stdout(json ? `${JSON.stringify(result)}\n` : `${result.message}\n`);
398
+ return 0;
399
+ }
400
+ let key = await io.credentials.read(url, io.env);
401
+ if (!key && operation === 'login') {
402
+ if (json || io.platform !== 'darwin')
403
+ throw new CliError(
404
+ 'Set INSTORYVIEW_API_KEY using your environment or secret manager. Interactive Keychain login requires macOS.',
405
+ 3,
406
+ );
407
+ key = await io.prompt(io);
408
+ }
409
+ if (!key)
410
+ throw new CliError(
411
+ 'No credential found. Run auth login on macOS or set INSTORYVIEW_API_KEY using your secret manager.',
412
+ 3,
413
+ );
414
+ await requestApi({ path: '/tracking?limit=1', method: 'GET' }, url, key, io);
415
+ if (operation === 'login' && !io.env.INSTORYVIEW_API_KEY) await io.credentials.save(url, key);
416
+ const result = {
417
+ authenticated: true,
418
+ apiUrl: url,
419
+ credentialStorage: io.env.INSTORYVIEW_API_KEY ? 'environment' : 'system_keychain',
420
+ };
421
+ io.stdout(
422
+ json ? `${JSON.stringify(result)}\n` : `Authenticated (${result.credentialStorage}).\n`,
423
+ );
424
+ return 0;
425
+ }
426
+ // Validate commands and budget before reading a credential or making any request.
427
+ const request = buildRequest(positionals, flags);
428
+ const key = await io.credentials.read(url, io.env);
429
+ if (!key)
430
+ throw new CliError('No credential found. Run auth login or set INSTORYVIEW_API_KEY.', 3);
431
+ if (request.method === 'POST') {
432
+ await io.remember(request, url, io.env);
433
+ const command = positionals[0];
434
+ io.stderr(
435
+ `Online query: max ${request.body.maxCredits} credits; idempotency key: ${request.key}\nRestore: instoryview ${command} ${request.body.username} --live --max-credits ${request.body.maxCredits} --idempotency-key ${request.key}${request.body.cursor ? ` --cursor '${request.body.cursor.replaceAll("'", "'\\''")}'` : ''} --json\n`,
436
+ );
437
+ }
438
+ const payload = await requestApi(request, url, key, io);
439
+ io.stdout(json ? `${JSON.stringify(payload)}\n` : render(payload));
440
+ return 0;
441
+ } catch (error) {
442
+ const exitCode = error instanceof CliError ? error.exitCode : 1;
443
+ if (json)
444
+ io.stderr(
445
+ `${JSON.stringify(error.payload ?? { error: { code: exitCode === 2 ? 'invalid_arguments' : 'cli_error', message: error.message }, meta: {} })}\n`,
446
+ );
447
+ else
448
+ io.stderr(
449
+ `${error.payload?.error?.code ? `${error.payload.error.code}: ` : ''}${error.message}${error.payload?.meta?.requestId ? ` (request ${error.payload.meta.requestId})` : ''}\n`,
450
+ );
451
+ return exitCode;
452
+ }
453
+ }
@@ -0,0 +1,64 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+
4
+ function service(baseUrl) {
5
+ return `com.instoryview.cli.${createHash('sha256').update(baseUrl).digest('hex').slice(0, 24)}`;
6
+ }
7
+
8
+ function security(args, input) {
9
+ return new Promise((resolve, reject) => {
10
+ const child = spawn('/usr/bin/security', args, { stdio: ['pipe', 'pipe', 'pipe'] });
11
+ const output = [];
12
+ child.stdout.on('data', (chunk) => output.push(chunk));
13
+ // Do not surface security diagnostics: interactive commands can include secrets.
14
+ child.stderr.resume();
15
+ child.on('error', () =>
16
+ reject(new Error('System Keychain is unavailable. Use INSTORYVIEW_API_KEY.')),
17
+ );
18
+ child.on('close', (code) => {
19
+ if (code === 0) resolve(Buffer.concat(output).toString().trim());
20
+ else {
21
+ const error = new Error('System Keychain operation failed. Use INSTORYVIEW_API_KEY.');
22
+ error.keychainExitCode = code;
23
+ reject(error);
24
+ }
25
+ });
26
+ child.stdin.end(input);
27
+ });
28
+ }
29
+
30
+ export async function readCredential(baseUrl, env = process.env, platform = process.platform) {
31
+ if (env.INSTORYVIEW_API_KEY?.trim()) return env.INSTORYVIEW_API_KEY.trim();
32
+ if (platform !== 'darwin') return null;
33
+ try {
34
+ return await security(['find-generic-password', '-s', service(baseUrl), '-a', 'api-key', '-w']);
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ export async function saveCredential(baseUrl, key, platform = process.platform) {
41
+ if (platform !== 'darwin')
42
+ throw new Error(
43
+ 'This platform uses INSTORYVIEW_API_KEY from your environment or secret manager. No plaintext credential is saved.',
44
+ );
45
+ if (!key || /[\r\n\0]/.test(key)) throw new Error('Invalid API Key.');
46
+ const quoted = `"${key.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
47
+ // Pass the secret through stdin, never through process arguments or a shell.
48
+ await security(
49
+ ['-i'],
50
+ `add-generic-password -U -s ${service(baseUrl)} -a api-key -w ${quoted}\n`,
51
+ );
52
+ const stored = await readCredential(baseUrl, {}, platform);
53
+ if (stored !== key)
54
+ throw new Error('Could not verify Keychain storage. Use INSTORYVIEW_API_KEY.');
55
+ }
56
+
57
+ export async function removeCredential(baseUrl, platform = process.platform) {
58
+ if (platform !== 'darwin') return;
59
+ try {
60
+ await security(['delete-generic-password', '-s', service(baseUrl), '-a', 'api-key']);
61
+ } catch (error) {
62
+ if (error.keychainExitCode !== 44) throw error;
63
+ }
64
+ }