insta 0.0.58 → 0.0.60

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.
@@ -1,15 +1,49 @@
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 exactly
15
+ // that: the NEAREST materialized hook above cwd, which is what the Codex wrapper and the Claude
16
+ // entry run. The link file is only a fallback for a directory where no hook exists yet (it is
17
+ // where the next install will land), then cwd (→ "audit log is empty"). Preferring the link file
18
+ // would break the moment the two diverge — e.g. a hook materialized below a linked parent —
19
+ // because the reader would look where the writer never writes.
20
+ export async function auditRoot(cwd = process.cwd()) {
21
+ const hook = nearestHookRoot(cwd);
22
+ if (hook)
23
+ return hook;
24
+ return (await findProjectRoot(cwd)) ?? cwd;
25
+ }
26
+ /** Nearest ancestor (or cwd) holding a materialized hook, or null. */
27
+ export function nearestHookRoot(cwd) {
28
+ let dir = resolve(cwd);
29
+ for (;;) {
30
+ if (existsSync(join(dir, '.insta', 'observe', 'hook.js')))
31
+ return dir;
32
+ const parent = dirname(dir);
33
+ if (parent === dir)
34
+ return null;
35
+ dir = parent;
36
+ }
37
+ }
38
+ /** Where `install` materializes: the linked project root when inside one (so a re-link or
39
+ * `observe install` from a subdirectory refreshes the project's hook instead of minting a second
40
+ * one the readers never look at — same rule as writeProject), else cwd. */
41
+ export async function installRoot(cwd = process.cwd()) {
42
+ return (await findProjectRoot(cwd)) ?? cwd;
43
+ }
10
44
  async function readAudit() {
11
45
  try {
12
- const txt = await readFile(join(process.cwd(), '.insta', 'audit.jsonl'), 'utf8');
46
+ const txt = await readFile(join(await auditRoot(), '.insta', 'audit.jsonl'), 'utf8');
13
47
  return txt.split('\n').filter(Boolean).map((l) => JSON.parse(l));
14
48
  }
15
49
  catch {
@@ -24,13 +58,19 @@ function* chunk(a, n) {
24
58
  yield a.slice(i, i + n);
25
59
  }
26
60
  export async function observeInstall() {
27
- const res = installObserve({ cwd: process.cwd() });
28
- info(`installed observe hook (claude: ${res.claude}, codex: ${res.codex}) → ./.insta/observe`);
61
+ const root = await installRoot();
62
+ const res = installObserve({ cwd: root });
63
+ info(`installed observe hook (claude: ${res.claude}, codex: ${res.codex}) → ${join(root, '.insta', 'observe')}`);
64
+ if (res.ignored.length)
65
+ info(` .gitignore += ${res.ignored.join(', ')}`);
66
+ const hint = untrackHint(res.tracked);
67
+ if (hint)
68
+ info(hint);
29
69
  info('it scans agent tool-use for credential exposure; findings append to ./.insta/audit.jsonl');
30
70
  info('run `insta observe report` to review, `insta observe sync` to upload to the project timeline');
31
71
  }
32
72
  export async function observeUninstall() {
33
- uninstallObserve(process.cwd());
73
+ uninstallObserve(await installRoot());
34
74
  info('uninstalled observe hook');
35
75
  }
36
76
  export async function observeReport(opts) {
@@ -3,6 +3,8 @@ 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 { installRoot } from './observe.js';
7
+ import { untrackHint } from '../gitignore.js';
6
8
  import { installSkills } from '../ensure-skills.js';
7
9
  // Generic directory names that make a useless project name ("projects", "~", "tmp", …). When the
8
10
  // cwd basename is one of these we DON'T invent a name — we guide the user to name it (or let their
@@ -13,14 +15,20 @@ const GENERIC_DIRS = new Set([
13
15
  'app', 'apps', 'users', 'user', 'bin', 'new', 'test', 'tests',
14
16
  ]);
15
17
  // Best-effort: wire the credential-audit hook into the project (no-op if assets aren't built).
18
+ // Anchored at the linked project root (writeProject just updated it), not cwd: a re-link from a
19
+ // subdirectory must refresh the project's hook, not mint a second one the readers never see.
16
20
  // quiet: with --json the install still runs, but its note moves to stderr (stdout is JSON-only).
17
- function tryInstallObserve(quiet = false) {
21
+ async function tryInstallObserve(quiet = false) {
18
22
  try {
19
- const r = installObserve({ cwd: process.cwd() });
20
- if (r.claude || r.codex) {
21
- const line = ' installed observe hook (credential audit) → ./.insta/observe';
22
- quiet ? process.stderr.write(line + '\n') : info(line);
23
- }
23
+ const r = installObserve({ cwd: await installRoot() });
24
+ const say = (line) => (quiet ? process.stderr.write(line + '\n') : info(line));
25
+ if (r.claude || r.codex)
26
+ say(' installed observe hook (credential audit) → ./.insta/observe');
27
+ if (r.ignored.length)
28
+ say(` .gitignore += ${r.ignored.join(', ')}`);
29
+ const hint = untrackHint(r.tracked);
30
+ if (hint)
31
+ say(hint);
24
32
  }
25
33
  catch { /* assets missing (dev/unbuilt) — skip silently */ }
26
34
  }
@@ -77,7 +85,7 @@ export async function projectCreate(name, opts) {
77
85
  info(` linked ./.insta/project.json (branch ${out.defaultBranch.name})`);
78
86
  renderNextActions(out.nextActions);
79
87
  }
80
- tryInstallObserve(opts.json);
88
+ await tryInstallObserve(opts.json);
81
89
  await installSkills({ cwd: process.cwd(), print: skillsPrint(opts.json) });
82
90
  }
83
91
  export async function projectList(opts) {
@@ -99,7 +107,7 @@ export async function projectLink(id, opts = {}) {
99
107
  printJson({ project, linked: { projectId: project.id, orgId: project.org_id, branch: 'main' } });
100
108
  else
101
109
  info(`linked project ${project.id} (${project.name})`);
102
- tryInstallObserve(opts.json);
110
+ await tryInstallObserve(opts.json);
103
111
  await installSkills({ cwd: process.cwd(), print: skillsPrint(opts.json) });
104
112
  }
105
113
  export async function projectDelete(opts) {
@@ -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). These are regenerable
16
- // agent context, not the developer's source — keep them out of git.
17
- const SKILL_DIRS = ['.claude/skills/', '.agents/skills/', '.github/skills/'];
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,15 +80,27 @@ 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
- const added = ensureGitignore(deps.cwd, SKILL_DIRS);
94
+ // The already-tracked hint is independent of this run: a repo that committed the skill dirs
95
+ // or the lock months ago needs the `git rm --cached` line whether or not today's adds worked.
96
+ const hint = untrackHint(alreadyTracked(deps.cwd, SKILL_DIRS));
97
+ if (hint)
98
+ print(hint);
99
+ // The ignore entries only when something was actually written: an offline run that failed
100
+ // every add has no new skill dirs or lock, and a `.gitignore +=` line would claim otherwise.
101
+ if (installed === 0)
102
+ return;
103
+ const added = ensureGitignore(deps.cwd, SKILL_DIRS, GITIGNORE_COMMENT);
88
104
  if (added.length)
89
105
  print(` .gitignore += ${added.join(', ')}`);
90
106
  }
@@ -92,18 +108,4 @@ export async function installSkills(deps) {
92
108
  /* best-effort convenience — never block the host command */
93
109
  }
94
110
  }
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
111
  //# 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
@@ -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.CLAUDE_PROJECT_DIR || event.cwd || '.';
62
+ const base = projectRootFor(process.argv[1], process.env, event.cwd);
50
63
  try {
51
64
  recordFindings(event, base);
52
65
  }
@@ -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,45 @@ function claudeEntry() {
64
71
  return { matcher: '*', hooks: [{ type: 'command',
65
72
  command: `[ ! -f ${hook} ] || node ${hook}`, timeout: 15, _insta: MARKER }] };
66
73
  }
67
- function codexEntry(cwd) {
68
- const abs = join(cwd, '.insta', 'observe', 'hook.js'); // Codex doesn't expand ${CLAUDE_PROJECT_DIR}; use an absolute path
69
- return { matcher: '*', hooks: [{ type: 'command', command: `node ${JSON.stringify(abs)}`, timeout: 15, _insta: MARKER }] };
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
+ // Exit code: the hook's own status when it ran; 0 when it could not be started or was killed
88
+ // (status null) — every other arm of this install is silent best-effort, and Codex reports any
89
+ // non-zero exit as a failed hook after EVERY tool call, which is worse than a missed audit line.
90
+ const CODEX_HOOK_SCRIPT = [
91
+ "const f=require('fs'),p=require('path'),c=require('child_process');",
92
+ 'let d=process.cwd();',
93
+ 'for(;;){',
94
+ "const h=p.join(d,'.insta/observe/hook.js');",
95
+ "if(f.existsSync(h)){const r=c.spawnSync(process.execPath,[h],{stdio:'inherit'});process.exitCode=r.status===null?0:r.status;break}",
96
+ 'const u=p.dirname(d);if(u===d)break;d=u}',
97
+ ].join('');
98
+ function codexEntry() {
99
+ return { matcher: '*', hooks: [{ type: 'command',
100
+ command: `node -e "${CODEX_HOOK_SCRIPT}"`, timeout: 15, _insta: MARKER }] };
70
101
  }
71
102
  export function installObserve(opts) {
72
103
  materialize(opts.cwd, opts.assetDir ?? DEFAULT_ASSET_DIR); // hook required → let a missing asset throw to the caller
104
+ // Ignore the local state in the same step that creates it, so nobody discovers it via
105
+ // `git status`. Best-effort: an unwritable .gitignore must not fail the hook install.
106
+ // (Uninstall deliberately leaves the entries: a stale audit.jsonl should stay ignored.)
107
+ let ignored = [];
108
+ try {
109
+ ignored = ensureGitignore(opts.cwd, LOCAL_PATHS, GITIGNORE_COMMENT);
110
+ }
111
+ catch { /* keep going */ }
112
+ const tracked = alreadyTracked(opts.cwd, LOCAL_PATHS);
73
113
  let claude = false;
74
114
  let codex = false;
75
115
  try {
@@ -78,11 +118,11 @@ export function installObserve(opts) {
78
118
  }
79
119
  catch { /* skip malformed */ }
80
120
  try {
81
- registerHarness(join(opts.cwd, '.codex', 'hooks.json'), codexEntry(opts.cwd));
121
+ registerHarness(join(opts.cwd, '.codex', 'hooks.json'), codexEntry());
82
122
  codex = true;
83
123
  }
84
124
  catch { /* skip malformed */ }
85
- return { claude, codex };
125
+ return { claude, codex, ignored, tracked };
86
126
  }
87
127
  export function uninstallObserve(cwd) {
88
128
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insta",
3
- "version": "0.0.58",
3
+ "version": "0.0.60",
4
4
  "type": "module",
5
5
  "description": "InstaCloud CLI — a thin client of the platform control-plane API.",
6
6
  "keywords": [