insta 0.0.57 → 0.0.59
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 +1 -1
- package/dist/commands/observe.js +29 -2
- package/dist/commands/project.js +9 -4
- package/dist/ensure-skills.js +20 -20
- package/dist/gitignore.js +45 -0
- package/dist/observe/hook.js +15 -2
- package/dist/observe/install.js +42 -5
- package/dist/telemetry.js +22 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -233,7 +233,7 @@ build never reaches a production installer.
|
|
|
233
233
|
| `INSTA_PROJECT_ID` · `INSTA_ORG_ID` · `INSTA_BRANCH` | Target a project, org or branch without linking |
|
|
234
234
|
| `INSTA_PASSWORD` | Password for non-interactive login |
|
|
235
235
|
| `INSTA_NO_AUTOUPDATE` | Disable self-update |
|
|
236
|
-
| `INSTA_NO_TELEMETRY` · `DO_NOT_TRACK` | Disable usage analytics. Each command sends one event (command, flags, outcome, version, OS) to the same PostHog project as the console. Only ids, enums and numbers among the arguments are kept — names, branches, keys, paths, secret values, free text and error messages never leave the machine; custom API hosts report nothing |
|
|
236
|
+
| `INSTA_NO_TELEMETRY` · `DO_NOT_TRACK` | Disable usage analytics. Each command sends one event (command, flags, outcome, version, OS) to the same PostHog project as the console. Only ids, enums and numbers among the arguments are kept — names, branches, keys, paths, secret values, free text and error messages never leave the machine; custom API hosts report nothing. Before you sign in, events carry a random id stored in `~/.insta/telemetry.json`; the first command after you sign in sends one extra event merging that id into your account, and the first command after the session ends (logout, `env use`) retires it |
|
|
237
237
|
|
|
238
238
|
## Agent skills
|
|
239
239
|
|
package/dist/commands/observe.js
CHANGED
|
@@ -1,15 +1,37 @@
|
|
|
1
1
|
// `insta observe` — the local credential-audit hook. install wires a PostToolUse hook into the
|
|
2
2
|
// agent harness; report renders the local audit; sync uploads findings into the project timeline
|
|
3
3
|
// (idempotent via a stable dedup key, matching the platform's audit-event ingest).
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
4
5
|
import { readFile } from 'node:fs/promises';
|
|
5
|
-
import { join } from 'node:path';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
7
|
import { installObserve, uninstallObserve } from '../observe/install.js';
|
|
8
|
+
import { untrackHint } from '../gitignore.js';
|
|
9
|
+
import { findProjectRoot } from '../config.js';
|
|
7
10
|
import { renderReport } from '../observe/report.js';
|
|
8
11
|
import { ApiClient, requireProject } from '../api.js';
|
|
9
12
|
import { info, printJson } from '../util.js';
|
|
13
|
+
// Where the audit lives: the hook records at the directory it is materialized in
|
|
14
|
+
// (<root>/.insta/observe/hook.js — see projectRootFor), so report/sync must anchor on the same
|
|
15
|
+
// thing. The link file is the usual root, but a standalone `insta observe install` in an
|
|
16
|
+
// unlinked directory has no project.json — climb for the hook itself then, so both halves agree
|
|
17
|
+
// from any subdirectory. Falls back to cwd when neither exists (→ "audit log is empty").
|
|
18
|
+
export async function auditRoot(cwd = process.cwd()) {
|
|
19
|
+
const linked = await findProjectRoot(cwd);
|
|
20
|
+
if (linked)
|
|
21
|
+
return linked;
|
|
22
|
+
let dir = resolve(cwd);
|
|
23
|
+
for (;;) {
|
|
24
|
+
if (existsSync(join(dir, '.insta', 'observe', 'hook.js')))
|
|
25
|
+
return dir;
|
|
26
|
+
const parent = dirname(dir);
|
|
27
|
+
if (parent === dir)
|
|
28
|
+
return cwd;
|
|
29
|
+
dir = parent;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
10
32
|
async function readAudit() {
|
|
11
33
|
try {
|
|
12
|
-
const txt = await readFile(join(
|
|
34
|
+
const txt = await readFile(join(await auditRoot(), '.insta', 'audit.jsonl'), 'utf8');
|
|
13
35
|
return txt.split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
14
36
|
}
|
|
15
37
|
catch {
|
|
@@ -26,6 +48,11 @@ function* chunk(a, n) {
|
|
|
26
48
|
export async function observeInstall() {
|
|
27
49
|
const res = installObserve({ cwd: process.cwd() });
|
|
28
50
|
info(`installed observe hook (claude: ${res.claude}, codex: ${res.codex}) → ./.insta/observe`);
|
|
51
|
+
if (res.ignored.length)
|
|
52
|
+
info(` .gitignore += ${res.ignored.join(', ')}`);
|
|
53
|
+
const hint = untrackHint(res.tracked);
|
|
54
|
+
if (hint)
|
|
55
|
+
info(hint);
|
|
29
56
|
info('it scans agent tool-use for credential exposure; findings append to ./.insta/audit.jsonl');
|
|
30
57
|
info('run `insta observe report` to review, `insta observe sync` to upload to the project timeline');
|
|
31
58
|
}
|
package/dist/commands/project.js
CHANGED
|
@@ -3,6 +3,7 @@ import { ApiClient, requireProject } from '../api.js';
|
|
|
3
3
|
import { writeProject } from '../config.js';
|
|
4
4
|
import { info, die, printJson, handleApproval, renderNextActions } from '../util.js';
|
|
5
5
|
import { installObserve } from '../observe/install.js';
|
|
6
|
+
import { untrackHint } from '../gitignore.js';
|
|
6
7
|
import { installSkills } from '../ensure-skills.js';
|
|
7
8
|
// Generic directory names that make a useless project name ("projects", "~", "tmp", …). When the
|
|
8
9
|
// cwd basename is one of these we DON'T invent a name — we guide the user to name it (or let their
|
|
@@ -17,10 +18,14 @@ const GENERIC_DIRS = new Set([
|
|
|
17
18
|
function tryInstallObserve(quiet = false) {
|
|
18
19
|
try {
|
|
19
20
|
const r = installObserve({ cwd: process.cwd() });
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
const say = (line) => (quiet ? process.stderr.write(line + '\n') : info(line));
|
|
22
|
+
if (r.claude || r.codex)
|
|
23
|
+
say(' installed observe hook (credential audit) → ./.insta/observe');
|
|
24
|
+
if (r.ignored.length)
|
|
25
|
+
say(` .gitignore += ${r.ignored.join(', ')}`);
|
|
26
|
+
const hint = untrackHint(r.tracked);
|
|
27
|
+
if (hint)
|
|
28
|
+
say(hint);
|
|
24
29
|
}
|
|
25
30
|
catch { /* assets missing (dev/unbuilt) — skip silently */ }
|
|
26
31
|
}
|
package/dist/ensure-skills.js
CHANGED
|
@@ -6,15 +6,19 @@
|
|
|
6
6
|
// repo moved) prints a manual fallback and never blocks or fails the host command — same contract
|
|
7
7
|
// as the observe-hook install.
|
|
8
8
|
import { spawn } from 'node:child_process';
|
|
9
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
-
import { join } from 'node:path';
|
|
11
9
|
import { resolveEnv } from './config.js';
|
|
12
10
|
import { resolveSpawnable } from './commands/setup.js';
|
|
13
11
|
import { DEFAULT_ENV, ENVS } from './env.js';
|
|
12
|
+
import { alreadyTracked, ensureGitignore, untrackHint } from './gitignore.js';
|
|
13
|
+
export { ensureGitignore } from './gitignore.js';
|
|
14
14
|
// Where `npx skills add` drops skills for the agents we pin below: Claude Code → .claude/skills/,
|
|
15
|
-
// Codex → .agents/skills/ (.github/skills/ is the third well-known dir)
|
|
16
|
-
// agent context, not the developer's source — keep
|
|
17
|
-
|
|
15
|
+
// Codex → .agents/skills/ (.github/skills/ is the third well-known dir), plus the skills-lock.json
|
|
16
|
+
// it writes at the project root. All regenerable agent context, not the developer's source — keep
|
|
17
|
+
// them out of git. The lock goes too: its payload is already ignored, it pins only a content hash
|
|
18
|
+
// (not the prod/staging source this CLI resolves per environment), and `insta project link` is
|
|
19
|
+
// the restore path — so a committed lock would be a lockfile for nothing.
|
|
20
|
+
const SKILL_DIRS = ['.claude/skills/', '.agents/skills/', '.github/skills/', 'skills-lock.json'];
|
|
21
|
+
const GITIGNORE_COMMENT = '# InstaCloud: agent skills installed by `npx skills add` (regenerable, not source)';
|
|
18
22
|
// `skills` prints a full-screen ASCII banner at the top of every `add`, so our 3 invocations
|
|
19
23
|
// would stack 3 banners. It skips the banner when it detects an agent driving it rather than a
|
|
20
24
|
// human — AI_AGENT is its first-checked signal (any non-empty value ⇒ agent mode). Setting it is
|
|
@@ -76,34 +80,30 @@ export async function installSkills(deps) {
|
|
|
76
80
|
}
|
|
77
81
|
catch { /* keep production defaults */ }
|
|
78
82
|
print(' installing related agent skills (insta, tigris, better-auth) …');
|
|
83
|
+
let installed = 0;
|
|
79
84
|
for (const s of targets) {
|
|
80
85
|
// Don't stream: the `skills` tool's clack UI (clone spinner, banners) is noise. Run it
|
|
81
86
|
// silent (stdio 'ignore') and let the per-skill ✓/failed line below be the clean output —
|
|
82
87
|
// it appears as each skill finishes, so there's still live progress. (Also avoids the
|
|
83
88
|
// child inheriting a piped stdin.)
|
|
84
89
|
const r = await run('npx', s.args);
|
|
90
|
+
if (r.ok)
|
|
91
|
+
installed++;
|
|
85
92
|
print(r.ok ? ` ${s.label} ✓` : ` ${s.label} failed — add manually: npx ${s.args.join(' ')}`);
|
|
86
93
|
}
|
|
87
|
-
|
|
94
|
+
// Only when something was actually written: an offline run that failed every add has no
|
|
95
|
+
// skill dirs or lock to ignore, and a `.gitignore +=` line there would claim otherwise.
|
|
96
|
+
if (installed === 0)
|
|
97
|
+
return;
|
|
98
|
+
const added = ensureGitignore(deps.cwd, SKILL_DIRS, GITIGNORE_COMMENT);
|
|
88
99
|
if (added.length)
|
|
89
100
|
print(` .gitignore += ${added.join(', ')}`);
|
|
101
|
+
const hint = untrackHint(alreadyTracked(deps.cwd, SKILL_DIRS));
|
|
102
|
+
if (hint)
|
|
103
|
+
print(hint);
|
|
90
104
|
}
|
|
91
105
|
catch {
|
|
92
106
|
/* best-effort convenience — never block the host command */
|
|
93
107
|
}
|
|
94
108
|
}
|
|
95
|
-
// Append any missing entries to the project's ./.gitignore (creating it if absent). Idempotent:
|
|
96
|
-
// entries already present are left alone. Returns the entries it added.
|
|
97
|
-
export function ensureGitignore(cwd, entries) {
|
|
98
|
-
const p = join(cwd, '.gitignore');
|
|
99
|
-
const existing = existsSync(p) ? readFileSync(p, 'utf8') : '';
|
|
100
|
-
const have = new Set(existing.split('\n').map((l) => l.trim()));
|
|
101
|
-
const missing = entries.filter((e) => !have.has(e));
|
|
102
|
-
if (missing.length === 0)
|
|
103
|
-
return [];
|
|
104
|
-
const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
|
|
105
|
-
const comment = '# InstaCloud: agent skills installed by `npx skills add` (regenerable, not source)';
|
|
106
|
-
writeFileSync(p, existing + `${prefix}\n${comment}\n${missing.join('\n')}\n`);
|
|
107
|
-
return missing;
|
|
108
|
-
}
|
|
109
109
|
//# sourceMappingURL=ensure-skills.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Shared `.gitignore` maintenance for files the CLI writes into a project that are not the
|
|
2
|
+
// developer's source (installed skills, observe-hook state). The rule: whatever writes a
|
|
3
|
+
// regenerable or machine-local file adds its ignore entry in the same step, so `git status` never
|
|
4
|
+
// surfaces it as a surprise — the same convention as `insta secrets` for .env.
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
// Append any missing entries to the project's ./.gitignore (creating it if absent). Idempotent:
|
|
9
|
+
// entries already present (exact line match) are left alone; a block gets one `comment` header
|
|
10
|
+
// the first time it contributes. Returns the entries it added.
|
|
11
|
+
export function ensureGitignore(cwd, entries, comment) {
|
|
12
|
+
const p = join(cwd, '.gitignore');
|
|
13
|
+
const existing = existsSync(p) ? readFileSync(p, 'utf8') : '';
|
|
14
|
+
const have = new Set(existing.split('\n').map((l) => l.trim()));
|
|
15
|
+
const missing = entries.filter((e) => !have.has(e));
|
|
16
|
+
if (missing.length === 0)
|
|
17
|
+
return [];
|
|
18
|
+
const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
|
|
19
|
+
const header = comment && !have.has(comment) ? `${comment}\n` : '';
|
|
20
|
+
writeFileSync(p, existing + `${prefix}\n${header}${missing.join('\n')}\n`);
|
|
21
|
+
return missing;
|
|
22
|
+
}
|
|
23
|
+
// An ignore entry does nothing for a path git already tracks — and the repos that most need these
|
|
24
|
+
// entries are the ones where the files were committed before the CLI ignored them. Returns the
|
|
25
|
+
// entries (as given) that have tracked files under them, so the caller can print the one hint
|
|
26
|
+
// that fixes it (`git rm -r --cached …`). Empty when git is absent or cwd is not a repo.
|
|
27
|
+
export function alreadyTracked(cwd, entries) {
|
|
28
|
+
if (entries.length === 0)
|
|
29
|
+
return [];
|
|
30
|
+
try {
|
|
31
|
+
const r = spawnSync('git', ['ls-files', '-z', '--', ...entries], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
32
|
+
if (r.status !== 0 || !r.stdout)
|
|
33
|
+
return [];
|
|
34
|
+
const tracked = r.stdout.split('\0').filter(Boolean);
|
|
35
|
+
return entries.filter((e) => tracked.some((t) => t === e || t.startsWith(e.endsWith('/') ? e : `${e}/`)));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** The one-line hint for `alreadyTracked` hits, or null when there are none. */
|
|
42
|
+
export function untrackHint(tracked) {
|
|
43
|
+
return tracked.length ? ` already tracked by git — to stop committing: git rm -r --cached ${tracked.join(' ')}` : null;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=gitignore.js.map
|
package/dist/observe/hook.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// PostToolUse hook: reads a tool-use event on stdin (Claude Code / Codex), scans every string
|
|
2
2
|
// surface for credential exposure, and appends findings to ./.insta/audit.jsonl. Ported from firth.
|
|
3
3
|
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
4
|
-
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
6
|
import { scanEvent } from './scanner.js';
|
|
7
7
|
export function recordFindings(event, baseDir) {
|
|
@@ -38,6 +38,19 @@ async function readStdin() {
|
|
|
38
38
|
chunks.push(c);
|
|
39
39
|
return Buffer.concat(chunks).toString('utf8');
|
|
40
40
|
}
|
|
41
|
+
// Where findings go. The materialized hook lives at <project root>/.insta/observe/hook.js, so its
|
|
42
|
+
// own entry path names the linked project root — the one directory whose .insta/audit.jsonl is
|
|
43
|
+
// gitignored and that `insta observe report` reads. Anything else (the harness's project-dir env,
|
|
44
|
+
// the event cwd) is only a guess: Codex passes the SESSION cwd, which in a monorepo can be a
|
|
45
|
+
// subdirectory of the project, and writing there would leave an unignored audit log behind.
|
|
46
|
+
export function projectRootFor(entry, env, eventCwd) {
|
|
47
|
+
if (entry) {
|
|
48
|
+
const dir = resolve(dirname(entry));
|
|
49
|
+
if (basename(dir) === 'observe' && basename(dirname(dir)) === '.insta')
|
|
50
|
+
return dirname(dirname(dir));
|
|
51
|
+
}
|
|
52
|
+
return env.CLAUDE_PROJECT_DIR || eventCwd || '.';
|
|
53
|
+
}
|
|
41
54
|
export async function main() {
|
|
42
55
|
let event;
|
|
43
56
|
try {
|
|
@@ -46,7 +59,7 @@ export async function main() {
|
|
|
46
59
|
catch {
|
|
47
60
|
process.exit(0);
|
|
48
61
|
}
|
|
49
|
-
const base = process.env
|
|
62
|
+
const base = projectRootFor(process.argv[1], process.env, event.cwd);
|
|
50
63
|
try {
|
|
51
64
|
recordFindings(event, base);
|
|
52
65
|
}
|
package/dist/observe/install.js
CHANGED
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { alreadyTracked, ensureGitignore } from '../gitignore.js';
|
|
6
7
|
const MARKER = 'insta-observe';
|
|
8
|
+
// What the hook leaves under ./.insta that is machine-local, not project source: observe/ is a
|
|
9
|
+
// copy of this CLI version's hook + scanner (regenerated by every `project link`), and
|
|
10
|
+
// audit.jsonl is this machine's findings (fingerprints + redacted context — `insta observe sync`
|
|
11
|
+
// is the share path). ./.insta/project.json stays committable: it is the team's project binding.
|
|
12
|
+
const LOCAL_PATHS = ['.insta/observe/', '.insta/audit.jsonl'];
|
|
13
|
+
const GITIGNORE_COMMENT = '# InstaCloud: local observe-hook state (regenerated per machine, not source)';
|
|
7
14
|
const DEFAULT_ASSET_DIR = dirname(fileURLToPath(import.meta.url)); // built: cli/dist/observe
|
|
8
15
|
function cliVersion() {
|
|
9
16
|
try {
|
|
@@ -64,12 +71,42 @@ function claudeEntry() {
|
|
|
64
71
|
return { matcher: '*', hooks: [{ type: 'command',
|
|
65
72
|
command: `[ ! -f ${hook} ] || node ${hook}`, timeout: 15, _insta: MARKER }] };
|
|
66
73
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
// The Codex hook command. Constraints that rule out every simpler form:
|
|
75
|
+
// - no absolute path: it made .codex/hooks.json machine-specific (a teammate committing it shipped
|
|
76
|
+
// /Users/<author>/… to every clone, where node failed after each tool call);
|
|
77
|
+
// - not `git rev-parse --show-toplevel`: the insta project root is wherever `project link` ran,
|
|
78
|
+
// which in a monorepo is below the git root (`findProjectRoot` climbs for .insta/project.json
|
|
79
|
+
// for exactly that reason), so the hook would silently never fire there;
|
|
80
|
+
// - no POSIX shell syntax: with no `commandWindows` override Codex hands `command` verbatim to
|
|
81
|
+
// cmd.exe on Windows, where `[ ! -f … ]` / `$(…)` are parse errors — after every tool call.
|
|
82
|
+
// So: one shell-neutral `node -e` that climbs from the session cwd (Codex runs project hooks with
|
|
83
|
+
// the project as cwd) to the nearest .insta/observe/hook.js and runs it with stdin passed through;
|
|
84
|
+
// a fresh clone with no ./.insta anywhere above is a silent no-op. The script must stay free of
|
|
85
|
+
// characters either shell rewrites inside double quotes: `$` and backticks (sh), `%` and `!`
|
|
86
|
+
// (cmd.exe), and `"` (both). Codex has each user trust the entry before it runs, so shareable is safe.
|
|
87
|
+
const CODEX_HOOK_SCRIPT = [
|
|
88
|
+
"const f=require('fs'),p=require('path'),c=require('child_process');",
|
|
89
|
+
'let d=process.cwd();',
|
|
90
|
+
'for(;;){',
|
|
91
|
+
"const h=p.join(d,'.insta/observe/hook.js');",
|
|
92
|
+
"if(f.existsSync(h)){const r=c.spawnSync(process.execPath,[h],{stdio:'inherit'});process.exitCode=r.status===null?1:r.status;break}",
|
|
93
|
+
'const u=p.dirname(d);if(u===d)break;d=u}',
|
|
94
|
+
].join('');
|
|
95
|
+
function codexEntry() {
|
|
96
|
+
return { matcher: '*', hooks: [{ type: 'command',
|
|
97
|
+
command: `node -e "${CODEX_HOOK_SCRIPT}"`, timeout: 15, _insta: MARKER }] };
|
|
70
98
|
}
|
|
71
99
|
export function installObserve(opts) {
|
|
72
100
|
materialize(opts.cwd, opts.assetDir ?? DEFAULT_ASSET_DIR); // hook required → let a missing asset throw to the caller
|
|
101
|
+
// Ignore the local state in the same step that creates it, so nobody discovers it via
|
|
102
|
+
// `git status`. Best-effort: an unwritable .gitignore must not fail the hook install.
|
|
103
|
+
// (Uninstall deliberately leaves the entries: a stale audit.jsonl should stay ignored.)
|
|
104
|
+
let ignored = [];
|
|
105
|
+
try {
|
|
106
|
+
ignored = ensureGitignore(opts.cwd, LOCAL_PATHS, GITIGNORE_COMMENT);
|
|
107
|
+
}
|
|
108
|
+
catch { /* keep going */ }
|
|
109
|
+
const tracked = alreadyTracked(opts.cwd, LOCAL_PATHS);
|
|
73
110
|
let claude = false;
|
|
74
111
|
let codex = false;
|
|
75
112
|
try {
|
|
@@ -78,11 +115,11 @@ export function installObserve(opts) {
|
|
|
78
115
|
}
|
|
79
116
|
catch { /* skip malformed */ }
|
|
80
117
|
try {
|
|
81
|
-
registerHarness(join(opts.cwd, '.codex', 'hooks.json'), codexEntry(
|
|
118
|
+
registerHarness(join(opts.cwd, '.codex', 'hooks.json'), codexEntry());
|
|
82
119
|
codex = true;
|
|
83
120
|
}
|
|
84
121
|
catch { /* skip malformed */ }
|
|
85
|
-
return { claude, codex };
|
|
122
|
+
return { claude, codex, ignored, tracked };
|
|
86
123
|
}
|
|
87
124
|
export function uninstallObserve(cwd) {
|
|
88
125
|
try {
|
package/dist/telemetry.js
CHANGED
|
@@ -18,6 +18,7 @@ const PROJECT_KEYS = {
|
|
|
18
18
|
};
|
|
19
19
|
// The send is awaited before the process exits, so it gets one bounded attempt and no retry.
|
|
20
20
|
const SEND_TIMEOUT_MS = 1500;
|
|
21
|
+
const ID_FILE = join(os.homedir(), '.insta', 'telemetry.json');
|
|
21
22
|
const REDACTED = '[REDACTED]';
|
|
22
23
|
const oneOf = (values) => (v) => values.includes(v);
|
|
23
24
|
const ID = (v) => /^(?:[0-9a-f]{8}-[0-9a-f-]{27}|[a-z]+_[\w-]{1,64}|(?=.*\d)[\w-]{1,64})$/i.test(v);
|
|
@@ -144,6 +145,7 @@ export function buildCommandEvent(command, args, options, outcome, ctx) {
|
|
|
144
145
|
auth_kind: token ? (token.startsWith('insta_') ? 'api_key' : 'session') : null,
|
|
145
146
|
project_id: ctx.project?.projectId ?? null,
|
|
146
147
|
org_id: ctx.project?.orgId ?? null,
|
|
148
|
+
...(ctx.project ? { $groups: { project: ctx.project.projectId, ...(ID(ctx.project.orgId) ? { org: ctx.project.orgId } : {}) } } : {}),
|
|
147
149
|
tty: ctx.tty,
|
|
148
150
|
ci: !!ctx.env.CI,
|
|
149
151
|
agent: detectAgent(ctx.env),
|
|
@@ -154,20 +156,22 @@ export function buildCommandEvent(command, args, options, outcome, ctx) {
|
|
|
154
156
|
},
|
|
155
157
|
};
|
|
156
158
|
}
|
|
157
|
-
export async function
|
|
159
|
+
export async function readState(file = ID_FILE) {
|
|
158
160
|
try {
|
|
159
161
|
const parsed = JSON.parse(await readFile(file, 'utf8'));
|
|
160
162
|
if (typeof parsed.anonymousId === 'string' && parsed.anonymousId)
|
|
161
|
-
return parsed
|
|
163
|
+
return parsed;
|
|
162
164
|
}
|
|
163
165
|
catch { }
|
|
164
|
-
|
|
166
|
+
return writeState({ anonymousId: randomUUID() }, file);
|
|
167
|
+
}
|
|
168
|
+
async function writeState(state, file = ID_FILE) {
|
|
165
169
|
try {
|
|
166
170
|
await mkdir(dirname(file), { recursive: true });
|
|
167
|
-
await writeFile(file, JSON.stringify(
|
|
171
|
+
await writeFile(file, JSON.stringify(state, null, 2));
|
|
168
172
|
}
|
|
169
173
|
catch { }
|
|
170
|
-
return
|
|
174
|
+
return state;
|
|
171
175
|
}
|
|
172
176
|
export async function sendBatch(key, batch, fetchImpl = fetch) {
|
|
173
177
|
try {
|
|
@@ -201,16 +205,27 @@ export async function trackCommand(cmd, args, outcome, cliVersion, deps = {}) {
|
|
|
201
205
|
if (!key)
|
|
202
206
|
return;
|
|
203
207
|
const project = await (deps.loadProject ?? readProject)();
|
|
208
|
+
const userId = config.user?.id;
|
|
209
|
+
let state = await readState(deps.idFile);
|
|
210
|
+
// Session gone or switched: the id belongs to the account that left, so later commands get a fresh one.
|
|
211
|
+
if (state.identifiedAs && state.identifiedAs !== userId)
|
|
212
|
+
state = await writeState({ anonymousId: randomUUID() }, deps.idFile);
|
|
204
213
|
const event = buildCommandEvent(command, args.flat(), cmd.opts(), outcome, {
|
|
205
214
|
cliVersion,
|
|
206
215
|
channel: deps.channel ?? detectChannel(),
|
|
207
216
|
config,
|
|
208
217
|
project,
|
|
209
|
-
anonymousId:
|
|
218
|
+
anonymousId: state.anonymousId,
|
|
210
219
|
env,
|
|
211
220
|
tty: deps.tty ?? !!process.stdout.isTTY,
|
|
212
221
|
});
|
|
213
|
-
|
|
222
|
+
const batch = [event];
|
|
223
|
+
const merge = userId !== undefined && state.identifiedAs !== userId;
|
|
224
|
+
if (merge)
|
|
225
|
+
batch.push({ event: '$identify', distinct_id: userId, timestamp: event.timestamp, properties: { $anon_distinct_id: state.anonymousId } });
|
|
226
|
+
const sent = await sendBatch(key, batch, deps.fetchImpl);
|
|
227
|
+
if (merge && sent)
|
|
228
|
+
await writeState({ ...state, identifiedAs: userId }, deps.idFile);
|
|
214
229
|
}
|
|
215
230
|
catch { }
|
|
216
231
|
}
|