@eyeauras/insight-cli 0.3.1 → 0.3.2

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.
Files changed (65) hide show
  1. package/.cli-package-build.json +1 -1
  2. package/SKILL.md +1 -1
  3. package/dist/src/analysis-default.d.ts +26 -0
  4. package/dist/src/analysis-default.js +149 -0
  5. package/dist/src/analysis-storage.d.ts +39 -20
  6. package/dist/src/analysis-storage.js +112 -16
  7. package/package.json +1 -1
  8. package/references/storage.md +35 -7
  9. package/skill-bundle/insight-artifacts/SKILL.md +1 -1
  10. package/skill-bundle/insight-artifacts/references/storage.md +35 -7
  11. package/skill-bundle/insight-binary/SKILL.md +1 -1
  12. package/skill-bundle/insight-capture/SKILL.md +1 -1
  13. package/skill-bundle/insight-cli/SKILL.md +1 -1
  14. package/skill-bundle/insight-cli/references/storage.md +35 -7
  15. package/skill-bundle/insight-die/SKILL.md +1 -1
  16. package/skill-bundle/insight-dnspy/SKILL.md +1 -1
  17. package/skill-bundle/insight-dotnet-dump/SKILL.md +1 -1
  18. package/skill-bundle/insight-dotnet-stack/SKILL.md +1 -1
  19. package/skill-bundle/insight-dotnet-trace/SKILL.md +1 -1
  20. package/skill-bundle/insight-frida/SKILL.md +1 -1
  21. package/skill-bundle/insight-ghidra/SKILL.md +1 -1
  22. package/skill-bundle/insight-ghidra/references/storage.md +35 -7
  23. package/skill-bundle/insight-ilspy/SKILL.md +1 -1
  24. package/skill-bundle/insight-interop/SKILL.md +1 -1
  25. package/skill-bundle/insight-lldb/SKILL.md +1 -1
  26. package/skill-bundle/insight-llvm/SKILL.md +1 -1
  27. package/skill-bundle/insight-managed-heap/SKILL.md +1 -1
  28. package/skill-bundle/insight-managed-metadata/SKILL.md +1 -1
  29. package/skill-bundle/insight-mitmproxy/SKILL.md +1 -1
  30. package/skill-bundle/insight-overview/SKILL.md +1 -1
  31. package/skill-bundle/insight-procmon/SKILL.md +1 -1
  32. package/skill-bundle/insight-re/SKILL.md +1 -1
  33. package/skill-bundle/insight-re/references/storage.md +35 -7
  34. package/skill-bundle/insight-sevenzip/SKILL.md +1 -1
  35. package/skill-bundle/insight-strace/SKILL.md +1 -1
  36. package/skill-bundle/insight-tshark/SKILL.md +1 -1
  37. package/skill-bundle/insight-windbg/SKILL.md +1 -1
  38. package/skill-bundle/manifest.json +1 -1
  39. package/skills/insight-artifacts/SKILL.md +1 -1
  40. package/skills/insight-artifacts/references/storage.md +35 -7
  41. package/skills/insight-binary/SKILL.md +1 -1
  42. package/skills/insight-capture/SKILL.md +1 -1
  43. package/skills/insight-die/SKILL.md +1 -1
  44. package/skills/insight-dnspy/SKILL.md +1 -1
  45. package/skills/insight-dotnet-dump/SKILL.md +1 -1
  46. package/skills/insight-dotnet-stack/SKILL.md +1 -1
  47. package/skills/insight-dotnet-trace/SKILL.md +1 -1
  48. package/skills/insight-frida/SKILL.md +1 -1
  49. package/skills/insight-ghidra/SKILL.md +1 -1
  50. package/skills/insight-ghidra/references/storage.md +35 -7
  51. package/skills/insight-ilspy/SKILL.md +1 -1
  52. package/skills/insight-interop/SKILL.md +1 -1
  53. package/skills/insight-lldb/SKILL.md +1 -1
  54. package/skills/insight-llvm/SKILL.md +1 -1
  55. package/skills/insight-managed-heap/SKILL.md +1 -1
  56. package/skills/insight-managed-metadata/SKILL.md +1 -1
  57. package/skills/insight-mitmproxy/SKILL.md +1 -1
  58. package/skills/insight-overview/SKILL.md +1 -1
  59. package/skills/insight-procmon/SKILL.md +1 -1
  60. package/skills/insight-re/SKILL.md +1 -1
  61. package/skills/insight-re/references/storage.md +35 -7
  62. package/skills/insight-sevenzip/SKILL.md +1 -1
  63. package/skills/insight-strace/SKILL.md +1 -1
  64. package/skills/insight-tshark/SKILL.md +1 -1
  65. package/skills/insight-windbg/SKILL.md +1 -1
@@ -1 +1 @@
1
- {"version":1,"artifact":"197a094cb768427c71cf9790f11ce42ba4863f3892cc24e05c58e091347ffc3b"}
1
+ {"version":1,"artifact":"0e495563091ce0b79877fc3050a2c51dca9631c45e07010a464f315bb3d56b58"}
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: insight-cli
3
3
  description: Choose and prepare reverse-engineering capabilities from the insight-cli toolbox for binary, managed-code and runtime investigations. Use to discover the appropriate tool or its packaged skill before installing native runtimes.
4
4
  ---
5
5
 
6
- # insight-cli 0.3.1
6
+ # insight-cli 0.3.2
7
7
 
8
8
  If local help/status reports AppData or IPC access denied, retry that **read-only probe**
9
9
  with the agent host's normal permission escalation, then use that route for later authorized
@@ -0,0 +1,26 @@
1
+ import { type CommandContext } from '@eyeauras/cli-factory';
2
+ type Context = Pick<CommandContext, 'profile' | 'appArguments'>;
3
+ export declare const analysisTrees: string[];
4
+ export interface StorageDefault {
5
+ schema: 1;
6
+ base: string;
7
+ application: string;
8
+ binding: string;
9
+ id: string;
10
+ baseInode: string;
11
+ revision: string;
12
+ }
13
+ /** Storage control metadata, independent of Factory's profile/settings document. */
14
+ export declare function storageDefault(c: Context): StorageDefault | undefined;
15
+ export declare function validateStorageDefault(c: Context, value: StorageDefault): void;
16
+ /** Preserve pre-existing local analysis until it is explicitly migrated. */
17
+ export declare function legacyAnalysis(c: Context): boolean;
18
+ export declare function inheritedStorage(c: Context): StorageDefault | undefined;
19
+ export declare function checkStorageDefaultBase(c: Context, base: string): Promise<void>;
20
+ export declare function writeStorageDefault(c: Context, base: string, revision?: string, expected?: string | null): Promise<{
21
+ state: 'configured' | 'superseded';
22
+ base: string | null;
23
+ revision: string | null;
24
+ binding?: StorageDefault;
25
+ }>;
26
+ export {};
@@ -0,0 +1,149 @@
1
+ import { CliError } from '@eyeauras/cli-factory';
2
+ import { lstatSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { link, lstat, open, realpath, unlink } from 'node:fs/promises';
4
+ import { isAbsolute, join, resolve } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { digest } from './model.js';
7
+ import { atomicWrite, withToolLock } from './tool-runtime/store.js';
8
+ export const analysisTrees = ['workspace', 'materials', 'temp', 'runtime'];
9
+ const uuid = (value) => typeof value === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(value);
10
+ const absent = (error) => error.code === 'ENOENT';
11
+ function fail(code, message) { throw new CliError(message, { code: 'storage.' + code }); }
12
+ const policyPath = (c) => join(c.appArguments.RoamingAppDataDirectory, '.analysis-storage-default.json');
13
+ const markerPath = (base) => join(base, '.insight-storage-root.json');
14
+ const binding = (c) => digest(realpathSync.native(c.appArguments.RoamingAppDataDirectory));
15
+ function readOwnedJson(path) {
16
+ if (!lstatSync(path).isFile())
17
+ fail('configuration', 'Storage default metadata must be an ordinary file.');
18
+ return JSON.parse(readFileSync(path, 'utf8'));
19
+ }
20
+ function valid(c, value) {
21
+ if (!value || typeof value !== 'object' || Array.isArray(value))
22
+ return false;
23
+ const v = value;
24
+ return v.schema === 1 && v.application === c.appArguments.AppName && v.binding === binding(c) && uuid(v.id) &&
25
+ typeof v.base === 'string' && isAbsolute(v.base) && v.base === resolve(v.base) && !/[\0\r\n]/.test(v.base) &&
26
+ typeof v.baseInode === 'string' && /^\d+$/.test(v.baseInode);
27
+ }
28
+ /** Storage control metadata, independent of Factory's profile/settings document. */
29
+ export function storageDefault(c) {
30
+ let value;
31
+ try {
32
+ value = readOwnedJson(policyPath(c));
33
+ }
34
+ catch (error) {
35
+ if (absent(error))
36
+ return;
37
+ fail('configuration', 'Installation storage default is unreadable; preserve its control metadata.');
38
+ }
39
+ if (!valid(c, value) || !uuid(value.revision))
40
+ fail('configuration', 'Installation storage default failed its application binding.');
41
+ return value;
42
+ }
43
+ export function validateStorageDefault(c, value) {
44
+ try {
45
+ if (!valid(c, value) || !uuid(value.revision))
46
+ throw Error('binding');
47
+ const st = lstatSync(value.base, { bigint: true }), marker = readOwnedJson(markerPath(value.base));
48
+ if (!st.isDirectory() || realpathSync.native(value.base) !== value.base || String(st.ino) !== value.baseInode ||
49
+ !valid(c, marker) || marker.id !== value.id || marker.base !== value.base || marker.baseInode !== value.baseInode)
50
+ throw Error('binding');
51
+ }
52
+ catch {
53
+ fail('unavailable', 'Installation analysis storage is missing or changed; mount its original volume. No fallback was created.');
54
+ }
55
+ }
56
+ /** Preserve pre-existing local analysis until it is explicitly migrated. */
57
+ export function legacyAnalysis(c) {
58
+ return analysisTrees.some(tree => { try {
59
+ return !lstatSync(join(c.appArguments.AppDataDirectory, tree)).isSymbolicLink();
60
+ }
61
+ catch (error) {
62
+ if (absent(error))
63
+ return false;
64
+ throw error;
65
+ } });
66
+ }
67
+ export function inheritedStorage(c) {
68
+ if (c.profile.values.analysisDirectory !== undefined || legacyAnalysis(c))
69
+ return;
70
+ return storageDefault(c);
71
+ }
72
+ export async function checkStorageDefaultBase(c, base) {
73
+ let marker;
74
+ try {
75
+ marker = readOwnedJson(markerPath(base));
76
+ }
77
+ catch (error) {
78
+ if (absent(error))
79
+ return;
80
+ fail('binding', 'Storage base ownership metadata is unreadable; preserve it.');
81
+ }
82
+ if (!valid(c, marker) || marker.base !== base)
83
+ fail('binding', 'Storage base belongs to another application/control root; no data was adopted.');
84
+ const st = await lstat(base, { bigint: true });
85
+ if (!st.isDirectory() || await realpath(base) !== base || String(st.ino) !== marker.baseInode)
86
+ fail('binding', 'Storage base directory was replaced.');
87
+ }
88
+ export async function writeStorageDefault(c, base, revision = randomUUID(), expected) {
89
+ return withToolLock(c.appArguments.RoamingAppDataDirectory, 'analysis-storage-default', async () => {
90
+ const current = storageDefault(c);
91
+ if (current?.revision === revision) {
92
+ if (current.base !== base)
93
+ fail('binding', 'Storage default intent changed.');
94
+ validateStorageDefault(c, current);
95
+ return { state: 'configured', base, revision, binding: current };
96
+ }
97
+ // A resumed migration must never overwrite a later user's default selection.
98
+ if (expected !== undefined && (current?.revision ?? null) !== expected)
99
+ return { state: 'superseded', base: current?.base ?? null, revision: current?.revision ?? null };
100
+ const st = await lstat(base, { bigint: true });
101
+ if (!st.isDirectory() || await realpath(base) !== base)
102
+ fail('path', 'Choose an existing ordinary storage base directory.');
103
+ await checkStorageDefaultBase(c, base);
104
+ let marker;
105
+ try {
106
+ marker = readOwnedJson(markerPath(base));
107
+ }
108
+ catch (error) {
109
+ if (!absent(error))
110
+ throw error;
111
+ marker = { schema: 1, base, application: c.appArguments.AppName, binding: binding(c), id: randomUUID(), baseInode: String(st.ino) };
112
+ const temporary = markerPath(base) + '.new-' + randomUUID(), file = await open(temporary, 'wx', 0o600);
113
+ try {
114
+ try {
115
+ await file.writeFile(JSON.stringify(marker) + '\n');
116
+ await file.sync();
117
+ }
118
+ finally {
119
+ await file.close();
120
+ }
121
+ try {
122
+ await link(temporary, markerPath(base));
123
+ }
124
+ catch (e) {
125
+ if (e.code !== 'EEXIST')
126
+ throw e;
127
+ }
128
+ }
129
+ finally {
130
+ await unlink(temporary);
131
+ }
132
+ await checkStorageDefaultBase(c, base);
133
+ marker = readOwnedJson(markerPath(base));
134
+ }
135
+ const next = { ...marker, revision };
136
+ validateStorageDefault(c, next);
137
+ if (process.platform !== 'win32') {
138
+ const dir = await open(base, 'r');
139
+ try {
140
+ await dir.sync();
141
+ }
142
+ finally {
143
+ await dir.close();
144
+ }
145
+ }
146
+ await atomicWrite(policyPath(c), next);
147
+ return { state: 'configured', base, revision, binding: next };
148
+ }, c.signal);
149
+ }
@@ -1,5 +1,6 @@
1
1
  import { type CommandContext, type CommandDefinition, type ProfileStoreContract } from '@eyeauras/cli-factory';
2
- export declare const analysisTrees: string[];
2
+ import { type StorageDefault } from './analysis-default.js';
3
+ export { analysisTrees } from './analysis-default.js';
3
4
  export declare function analysisDirectory(c: Pick<CommandContext, 'profile' | 'appArguments'>): string;
4
5
  /** Factory remains the only owner of profiles, settings, keyring and IPC admission. */
5
6
  export declare class AnalysisStorage {
@@ -16,6 +17,16 @@ export declare class AnalysisStorage {
16
17
  private save;
17
18
  private validate;
18
19
  wrap(entry: CommandDefinition): CommandDefinition;
20
+ private prepare;
21
+ private defaultBase;
22
+ setDefault(c: CommandContext, directory: unknown): Promise<{
23
+ defaultDirectory: string;
24
+ appliesTo: string;
25
+ state: "configured" | "superseded";
26
+ base: string | null;
27
+ revision: string | null;
28
+ binding?: StorageDefault;
29
+ }>;
19
30
  busy(directory: string): boolean;
20
31
  private exclusive;
21
32
  private preflight;
@@ -23,6 +34,7 @@ export declare class AnalysisStorage {
23
34
  profile: string;
24
35
  directory: string;
25
36
  configuration: string;
37
+ defaultBaseDirectory: string | null;
26
38
  defaultDirectory: string;
27
39
  availability: string;
28
40
  usage: unknown;
@@ -43,10 +55,20 @@ export declare class AnalysisStorage {
43
55
  progress: unknown;
44
56
  } | null;
45
57
  }>;
46
- migrate(c: CommandContext, directory: unknown, dryRun?: boolean, emptyOnly?: boolean): Promise<{
58
+ migrate(c: CommandContext, directory: unknown, dryRun?: boolean, emptyOnly?: boolean, profileOnly?: boolean, automatic?: StorageDefault): Promise<{
59
+ state: "rolled_back";
60
+ id: string;
61
+ directory: string;
62
+ destinationRetained: string;
63
+ } | {
64
+ state: "cleaned";
65
+ id: string;
66
+ compatibilityPathsRetained: boolean;
67
+ } | {
47
68
  profile: string;
48
69
  directory: string;
49
70
  configuration: string;
71
+ defaultBaseDirectory: string | null;
50
72
  defaultDirectory: string;
51
73
  availability: string;
52
74
  usage: unknown;
@@ -66,21 +88,14 @@ export declare class AnalysisStorage {
66
88
  retainedSourceBytes: number;
67
89
  progress: unknown;
68
90
  } | null;
69
- } | {
70
- state: "rolled_back";
71
- id: string;
72
- directory: string;
73
- destinationRetained: string;
74
- } | {
75
- state: "cleaned";
76
- id: string;
77
- compatibilityPathsRetained: boolean;
78
91
  } | {
79
92
  state: "committed" | "rolled_back" | "cleaned";
80
93
  id: string;
81
94
  directory: string;
82
95
  manifest: string;
83
96
  sourceRetained: boolean;
97
+ defaultBaseDirectory: string | null;
98
+ installationDefault: string;
84
99
  rollback: string;
85
100
  cleanup: string;
86
101
  } | {
@@ -96,13 +111,24 @@ export declare class AnalysisStorage {
96
111
  sourceRetained: boolean;
97
112
  compatibilityPathsRetained: boolean;
98
113
  state: string;
114
+ setsInstallationDefault: boolean;
99
115
  }>;
100
116
  private checkCopy;
101
117
  private continue;
102
118
  resume(c: CommandContext): Promise<{
119
+ state: "rolled_back";
120
+ id: string;
121
+ directory: string;
122
+ destinationRetained: string;
123
+ } | {
124
+ state: "cleaned";
125
+ id: string;
126
+ compatibilityPathsRetained: boolean;
127
+ } | {
103
128
  profile: string;
104
129
  directory: string;
105
130
  configuration: string;
131
+ defaultBaseDirectory: string | null;
106
132
  defaultDirectory: string;
107
133
  availability: string;
108
134
  usage: unknown;
@@ -122,21 +148,14 @@ export declare class AnalysisStorage {
122
148
  retainedSourceBytes: number;
123
149
  progress: unknown;
124
150
  } | null;
125
- } | {
126
- state: "rolled_back";
127
- id: string;
128
- directory: string;
129
- destinationRetained: string;
130
- } | {
131
- state: "cleaned";
132
- id: string;
133
- compatibilityPathsRetained: boolean;
134
151
  } | {
135
152
  state: "committed" | "rolled_back" | "cleaned";
136
153
  id: string;
137
154
  directory: string;
138
155
  manifest: string;
139
156
  sourceRetained: boolean;
157
+ defaultBaseDirectory: string | null;
158
+ installationDefault: string;
140
159
  rollback: string;
141
160
  cleanup: string;
142
161
  }>;
@@ -7,10 +7,11 @@ import { atomicWrite, readJson, toolProfileBusy } from './tool-runtime/store.js'
7
7
  import { listProcesses } from './tool-runtime/supervisor.js';
8
8
  import { runtimeProcesses } from './runtime-manager.js';
9
9
  import { digest } from './model.js';
10
+ import { analysisTrees, inheritedStorage, legacyAnalysis, storageDefault, validateStorageDefault, checkStorageDefaultBase, writeStorageDefault } from './analysis-default.js';
11
+ export { analysisTrees } from './analysis-default.js';
10
12
  // Move the complete runtime tree too: installation stages must share a filesystem with
11
13
  // installations, and both native checks and process runs can contain large generated DBs.
12
14
  // Stable aliases preserve references in immutable native journals, selections and results.
13
- export const analysisTrees = ['workspace', 'materials', 'temp', 'runtime'];
14
15
  const terminal = (state) => ['committed', 'rolled_back', 'cleaned'].includes(state);
15
16
  const inside = (parent, child) => { const r = relative(parent, child); return r === '' || !isAbsolute(r) && r !== '..' && !r.startsWith('..' + sep); };
16
17
  function fail(code, message) { throw new CliError(message, { code: 'storage.' + code }); }
@@ -37,8 +38,12 @@ async function sync(path, directory = false) {
37
38
  }
38
39
  export function analysisDirectory(c) {
39
40
  const value = c.profile.values.analysisDirectory;
40
- if (value === undefined || value === null)
41
+ if (value === null)
41
42
  return c.appArguments.AppDataDirectory;
43
+ if (value === undefined) {
44
+ const shared = storageDefault(c);
45
+ return shared && !legacyAnalysis(c) ? join(shared.base, c.appArguments.AppName, c.profile.name) : c.appArguments.AppDataDirectory;
46
+ }
42
47
  if (typeof value !== 'string' || !isAbsolute(value) || value !== resolve(value))
43
48
  fail('configuration', 'Invalid analysis directory in the Factory profile.');
44
49
  return value;
@@ -140,6 +145,9 @@ export class AnalysisStorage {
140
145
  !isAbsolute(j.source) || !isAbsolute(j.destination) || inside(j.source, j.destination) || inside(j.destination, j.source) ||
141
146
  !isAbsolute(j.destinationAnchor) || !inside(j.destinationAnchor, j.destination) || typeof j.sourceIdentity !== 'string' || typeof j.destinationIdentity !== 'string' ||
142
147
  !['copying', 'verified', 'activating', 'committed', 'rolling_back', 'rolled_back', 'cleaning', 'cleaned'].includes(j.state) ||
148
+ ['automatic', 'makeDefault', 'defaultApplied', 'defaultSuperseded'].some(k => { const v = j[k]; return v !== undefined && typeof v !== 'boolean'; }) ||
149
+ j.defaultPreviousRevision !== undefined && j.defaultPreviousRevision !== null && !/^[-a-f0-9]{36}$/.test(j.defaultPreviousRevision) ||
150
+ j.automatic && (!j.defaultBinding || j.makeDefault || j.files !== 0 || j.bytes !== 0 || loadEntries && j.entries.length !== 0) ||
143
151
  !Array.isArray(j.trees) || j.trees.some(t => !analysisTrees.includes(t)) || !Array.isArray(j.entries) ||
144
152
  j.entries.some(e => !j.trees.some(t => e.path === t || e.path.startsWith(t + '/')) || e.path.split('/').some(p => p === '..' || p === '.' || p === '')) ||
145
153
  !/^[a-f0-9]{64}$/.test(j.manifest) || loadEntries && j.manifest !== digest(content(j.entries)))
@@ -153,7 +161,7 @@ export class AnalysisStorage {
153
161
  const { entries, ...record } = j;
154
162
  await atomicWrite(join(this.control(c), 'migration.json'), record);
155
163
  }
156
- async validate(c) {
164
+ async validate(c, initializing = false) {
157
165
  const root = analysisDirectory(c), j = await this.journal(c);
158
166
  if (j && !terminal(j.state))
159
167
  fail('pending', 'Migration is incomplete. Use storage show, resume or rollback before research.');
@@ -161,6 +169,12 @@ export class AnalysisStorage {
161
169
  fail('configuration', 'Factory profile and committed migration differ; preserve both and inspect storage show.');
162
170
  if (root === c.appArguments.AppDataDirectory)
163
171
  return;
172
+ const shared = inheritedStorage(c);
173
+ if (shared && !j) {
174
+ if (!initializing)
175
+ validateStorageDefault(c, shared);
176
+ return;
177
+ }
164
178
  const marker = await readJson(join(root, '.insight-analysis.json'));
165
179
  if (!marker || marker.schema !== 1 || marker.binding !== await this.binding(c) || await realpath(root) !== root)
166
180
  fail('unavailable', 'Configured analysis storage is missing or changed; mount its volume. No fallback was created.');
@@ -175,12 +189,15 @@ export class AnalysisStorage {
175
189
  const run = entry.run;
176
190
  return { ...entry, ...(entry.children ? { children: entry.children.map(e => this.wrap(e)) } : {}), ...(run ? { run: async (input, c) => {
177
191
  const key = c.appArguments.AppDataDirectory;
192
+ if (this.changing.has(key))
193
+ fail('busy', 'Storage migration is in progress.');
194
+ const prepared = await this.prepare(c);
178
195
  if (this.changing.has(key))
179
196
  fail('busy', 'Storage migration is in progress.');
180
197
  this.active.set(key, (this.active.get(key) ?? 0) + 1);
181
198
  try {
182
- await this.validate(c);
183
- return await run(input, c);
199
+ await this.validate(prepared);
200
+ return await run(input, prepared);
184
201
  }
185
202
  finally {
186
203
  const n = this.active.get(key) - 1;
@@ -191,6 +208,42 @@ export class AnalysisStorage {
191
208
  }
192
209
  } } : {}) };
193
210
  }
211
+ async prepare(c) {
212
+ const j = await this.journal(c), shared = inheritedStorage(c);
213
+ if (j?.automatic && !['cleaned', 'rolled_back'].includes(j.state)) {
214
+ await this.resume(c);
215
+ c = { ...c, profile: await this.store(c).get(c.profile.name) };
216
+ }
217
+ else if (!j && shared) {
218
+ validateStorageDefault(c, shared);
219
+ await this.migrate(c, shared.base, false, true, true, shared);
220
+ c = { ...c, profile: await this.store(c).get(c.profile.name) };
221
+ }
222
+ // One command keeps its selected root even if another profile changes the default.
223
+ return { ...c, profile: { ...c.profile, values: { ...c.profile.values, analysisDirectory: analysisDirectory(c) } } };
224
+ }
225
+ async defaultBase(c, directory, existingOnly = false) {
226
+ if (typeof directory !== 'string' || !isAbsolute(directory) || /[\0\r\n]/.test(directory))
227
+ fail('path', 'Supply an absolute destination base directory.');
228
+ const base = await canonicalFuture(resolve(directory)), app = await canonicalFuture(c.appArguments.RoamingAppDataDirectory);
229
+ if (inside(app, base) || inside(base, app))
230
+ fail('path', 'The installation storage base and Factory configuration root must be separate, non-nested directories.');
231
+ for (const profile of (await this.store(c).list()).profiles) {
232
+ if (typeof profile.values.analysisDirectory !== 'string')
233
+ continue;
234
+ const root = await canonicalFuture(profile.values.analysisDirectory);
235
+ if (inside(root, base))
236
+ fail('path', 'The installation storage base cannot be nested inside a profile analysis directory.');
237
+ }
238
+ if (existingOnly && !await present(base))
239
+ fail('path', 'Choose an existing mounted storage base directory.');
240
+ await checkStorageDefaultBase(c, base);
241
+ return base;
242
+ }
243
+ async setDefault(c, directory) {
244
+ const base = await this.defaultBase(c, directory, true), result = await writeStorageDefault(c, base);
245
+ return { ...result, defaultDirectory: join(base, c.appArguments.AppName, c.profile.name), appliesTo: 'new and empty profiles; initialized profile locations are preserved' };
246
+ }
194
247
  busy(directory) { return this.changing.has(directory); }
195
248
  async exclusive(c, action) {
196
249
  const key = c.appArguments.AppDataDirectory;
@@ -212,7 +265,7 @@ export class AnalysisStorage {
212
265
  async preflight(c, directory) {
213
266
  if (typeof directory !== 'string' || !isAbsolute(directory) || /[\0\r\n]/.test(directory))
214
267
  fail('path', 'Supply an absolute destination base directory.');
215
- const source = await canonicalFuture(analysisDirectory(c)), destination = await canonicalFuture(join(resolve(directory), c.appArguments.AppName, c.profile.name));
268
+ const source = await canonicalFuture(inheritedStorage(c) ? c.appArguments.AppDataDirectory : analysisDirectory(c)), destination = await canonicalFuture(join(resolve(directory), c.appArguments.AppName, c.profile.name));
216
269
  const app = await canonicalFuture(c.appArguments.RoamingAppDataDirectory);
217
270
  if (inside(source, destination) || inside(destination, source) || inside(app, destination) || inside(destination, app))
218
271
  fail('path', 'Source, destination and profile configuration roots must be separate, non-nested directories.');
@@ -235,7 +288,7 @@ export class AnalysisStorage {
235
288
  return { source, destination, destinationAnchor: parent, destinationIdentity: await directoryIdentity(parent), bytes, entries: entries.length, availableBytes: available.toString(), requiredBytes: (BigInt(bytes) + reserve).toString(), trees: analysisTrees, sourceRetained: true, compatibilityPathsRetained: true };
236
289
  }
237
290
  async show(c) {
238
- const j = await this.journal(c), root = analysisDirectory(c);
291
+ const j = await this.journal(c), root = analysisDirectory(c), shared = storageDefault(c);
239
292
  let usage, availability = 'available';
240
293
  try {
241
294
  await this.validate(c);
@@ -245,23 +298,34 @@ export class AnalysisStorage {
245
298
  catch (error) {
246
299
  availability = error instanceof CliError ? error.code : (error.code ?? 'unavailable');
247
300
  }
248
- return { profile: c.profile.name, directory: root, configuration: 'Factory profile analysisDirectory', defaultDirectory: c.appArguments.AppDataDirectory, availability, usage,
301
+ return { profile: c.profile.name, directory: root, configuration: c.profile.values.analysisDirectory !== undefined ? 'Factory profile analysisDirectory' : shared && root !== c.appArguments.AppDataDirectory ? 'Installation storage default' : 'Factory profile AppData', defaultBaseDirectory: shared?.base ?? null, defaultDirectory: shared ? join(shared.base, c.appArguments.AppName, c.profile.name) : c.appArguments.AppDataDirectory, availability, usage,
249
302
  trees: analysisTrees, appData: c.appArguments.AppDataDirectory, included: ['snapshots, immutable inputs, native projects and local server repositories', 'tool execution/verification working data', 'runtime installations, acquisition caches, selections and process receipts'], excluded: ['profiles, permissions, IPC, logs and OS keyring secrets'],
250
303
  migration: j ? { id: j.id, state: j.state, source: j.source, destination: j.destination, manifest: j.manifest, bytes: j.bytes, files: j.files, retainedSource: join(j.source, '.storage-retained', j.id), retainedSourceBytes: j.state === 'committed' ? j.bytes : 0, progress: await readJson(join(this.control(c), 'progress.json')) } : null };
251
304
  }
252
- async migrate(c, directory, dryRun = false, emptyOnly = false) {
305
+ async migrate(c, directory, dryRun = false, emptyOnly = false, profileOnly = false, automatic) {
253
306
  return this.exclusive(c, async () => {
254
- await this.validate(c);
307
+ await this.validate(c, true);
308
+ if (automatic)
309
+ validateStorageDefault(c, automatic);
255
310
  const plan = await this.preflight(c, directory);
311
+ if (automatic) {
312
+ validateStorageDefault(c, automatic);
313
+ if (plan.destination !== join(automatic.base, c.appArguments.AppName, c.profile.name))
314
+ fail('binding', 'Automatic initialization changed its destination.');
315
+ }
316
+ if (!profileOnly)
317
+ await this.defaultBase(c, directory);
256
318
  if (dryRun)
257
- return { state: 'preflight', ...plan };
319
+ return { state: 'preflight', setsInstallationDefault: !profileOnly, ...plan };
258
320
  if (emptyOnly && plan.entries)
259
321
  fail('data', 'This profile already has analysis data. Use storage migrate --directory to preserve it.');
260
322
  const entries = await inventory(plan.source, analysisTrees, true, c.signal), id = randomUUID();
323
+ if (emptyOnly && entries.length)
324
+ fail('data', 'Analysis appeared during empty-profile initialization; no migration was started.');
261
325
  const binding = await this.binding(c);
262
326
  const j = { schema: 1, id, profile: c.profile.name, binding, source: plan.source, destination: plan.destination,
263
327
  sourceIdentity: await directoryIdentity(plan.source), destinationAnchor: plan.destinationAnchor, destinationIdentity: plan.destinationIdentity,
264
- previous: typeof c.profile.values.analysisDirectory === 'string' ? c.profile.values.analysisDirectory : null, state: 'copying', entries, trees: [...analysisTrees], manifest: digest(content(entries)), createdAt: new Date().toISOString(), bytes: entries.reduce((n, e) => n + e.size, 0), files: entries.filter(e => e.kind === 'file').length };
328
+ previous: typeof c.profile.values.analysisDirectory === 'string' ? c.profile.values.analysisDirectory : null, automatic: Boolean(automatic), defaultBinding: automatic, makeDefault: !profileOnly, defaultPreviousRevision: storageDefault(c)?.revision ?? null, state: 'copying', entries, trees: [...analysisTrees], manifest: digest(content(entries)), createdAt: new Date().toISOString(), bytes: entries.reduce((n, e) => n + e.size, 0), files: entries.filter(e => e.kind === 'file').length };
265
329
  // Reserve durably before any destination or source changes. All ordinary commands now freeze.
266
330
  await this.save(c, j);
267
331
  await this.checkpoint?.('reserved');
@@ -282,12 +346,35 @@ export class AnalysisStorage {
282
346
  fail('volume', 'A migration volume changed or disappeared. Mount the original volume before resuming; no fallback was created.');
283
347
  if (await canonicalFuture(j.destination) !== j.destination || j.targetIdentity && await directoryIdentity(j.destination) !== j.targetIdentity)
284
348
  fail('binding', 'Destination directory was replaced; no migration write was made.');
349
+ if (j.defaultBinding)
350
+ validateStorageDefault(c, j.defaultBinding);
351
+ if (j.defaultBaseIdentity && await directoryIdentity(dirname(dirname(j.destination))) !== j.defaultBaseIdentity)
352
+ fail('volume', 'The selected storage base directory was replaced.');
285
353
  if (j.state === 'rolling_back')
286
354
  return this.restore(c, j);
287
355
  if (j.state === 'cleaning')
288
356
  return this.finishCleanup(c, j);
289
- if (terminal(j.state))
357
+ if (terminal(j.state) && !(j.automatic && j.state === 'committed'))
290
358
  return this.show(c);
359
+ if (j.makeDefault && !j.defaultApplied) {
360
+ const base = dirname(dirname(j.destination));
361
+ if (await canonicalFuture(base) !== base)
362
+ fail('binding', 'Storage default base was replaced.');
363
+ await mkdir(base, { recursive: true, mode: 0o700 });
364
+ if (await directoryIdentity(j.destinationAnchor) !== j.destinationIdentity)
365
+ fail('volume', 'The default storage volume changed.');
366
+ if (!j.defaultBaseIdentity) {
367
+ j.defaultBaseIdentity = await directoryIdentity(base);
368
+ await this.save(c, j);
369
+ }
370
+ const result = await writeStorageDefault(c, base, j.id, j.defaultPreviousRevision ?? null);
371
+ await this.checkpoint?.('default-selected');
372
+ j.defaultApplied = true;
373
+ j.defaultSuperseded = result.state === 'superseded';
374
+ if (!j.defaultSuperseded)
375
+ j.defaultBinding = result.binding;
376
+ await this.save(c, j);
377
+ }
291
378
  if (j.state === 'copying') {
292
379
  await privateDirectory(j.destination);
293
380
  if (!j.targetIdentity) {
@@ -439,7 +526,14 @@ export class AnalysisStorage {
439
526
  await this.save(c, j);
440
527
  await atomicWrite(join(this.control(c), 'progress.json'), { completed: j.entries.length, total: j.entries.length, phase: 'committed' });
441
528
  }
442
- return { state: j.state, id: j.id, directory: j.destination, manifest: j.manifest, sourceRetained: true, rollback: 'storage rollback', cleanup: 'storage cleanup' };
529
+ if (j.automatic && j.state === 'committed') {
530
+ if (j.entries.length)
531
+ fail('binding', 'Automatic profile initialization cannot delete existing analysis.');
532
+ j.state = 'cleaning';
533
+ await this.save(c, j);
534
+ return this.finishCleanup(c, j);
535
+ }
536
+ return { state: j.state, id: j.id, directory: j.destination, manifest: j.manifest, sourceRetained: true, defaultBaseDirectory: storageDefault(c)?.base ?? null, installationDefault: j.makeDefault ? (j.defaultSuperseded ? 'superseded' : 'configured') : 'unchanged', rollback: 'storage rollback', cleanup: 'storage cleanup' };
443
537
  }
444
538
  async resume(c) { return this.exclusive(c, async () => { const j = await this.journal(c, true); if (!j)
445
539
  fail('missing', 'No migration journal exists.'); return this.continue(c, j); }); }
@@ -549,11 +643,13 @@ export class AnalysisStorage {
549
643
  fail('binding', 'Retained source directory was replaced; no deletion or restore was attempted.');
550
644
  }
551
645
  commands() {
646
+ const profileOnly = { flags: '--profile-only', description: 'Keep the installation default unchanged; move only this profile' };
552
647
  const directory = { flags: '--directory <path>', description: 'Absolute base directory; app name and profile are appended', required: true };
553
648
  return command('storage', 'Inspect and migrate profile analysis data', [
554
649
  command('show', 'Show effective analysis storage, usage and retained migration', (_i, c) => this.show(c), { permission: Permission.ReadOnly }),
555
- command('configure', 'Choose storage for a profile with no analysis data', ({ options: o }, c) => this.migrate(c, o.directory, false, true), { permission: Permission.Update, options: [directory] }),
556
- command('migrate', 'Copy and verify all analysis data, retain source, then switch storage', ({ options: o }, c) => this.migrate(c, o.directory, Boolean(o.dryRun)), { permission: Permission.Update, options: [directory, { flags: '--dry-run', description: 'Read-only path, capacity and quiescence preflight; create no migration' }] }),
650
+ command('default', 'Set the installation storage base for new profiles; preserve existing data locations', ({ options: o }, c) => this.setDefault(c, o.directory), { permission: Permission.Update, options: [directory] }),
651
+ command('configure', 'Choose storage for an empty profile and subsequent new profiles', ({ options: o }, c) => this.migrate(c, o.directory, false, true, Boolean(o.profileOnly)), { permission: Permission.Update, options: [directory, profileOnly] }),
652
+ command('migrate', 'Copy and verify all analysis data, retain source, then switch storage', ({ options: o }, c) => this.migrate(c, o.directory, Boolean(o.dryRun), false, Boolean(o.profileOnly)), { permission: Permission.Update, options: [directory, profileOnly, { flags: '--dry-run', description: 'Read-only path, capacity and quiescence preflight; create no migration' }] }),
557
653
  command('resume', 'Resume the retained copy/activation or rollback without replaying research', (_i, c) => this.resume(c), { permission: Permission.Update }),
558
654
  command('rollback', 'Restore retained source; refuse after any destination content changes', (_i, c) => this.rollback(c), { permission: Permission.Update }),
559
655
  command('cleanup', 'Delete verified retained source copies; retain compatibility paths permanently', (_i, c) => this.cleanup(c), { permission: 'Destructive' }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eyeauras/insight-cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "Agent-facing durable sessions and isolated Ghidra research contexts",
6
6
  "main": "./dist/src/index.js",
@@ -1,10 +1,19 @@
1
1
  # Analysis storage and migration
2
2
 
3
3
  `storage show --json` reports effective profile storage, availability, active file/byte usage,
4
- exact included trees and retained migration. The default is Factory profile AppData. No
5
- checkout, environment or automatic disk fallback changes it. The non-secret setting is
6
- `analysisDirectory` in Factory's profile (`profile show` also displays it). Use storage commands
7
- to change it safely.
4
+ exact included trees, retained migration and installation default base. Before selecting a
5
+ base, storage starts in Factory profile AppData. `configure` and `migrate` select that base
6
+ for subsequent new/empty profiles as well as moving the current profile. Add `--profile-only`
7
+ to keep the installation default unchanged. The effective root appends `insight-cli/<profile>`.
8
+ Initialized profiles keep their existing locations when the default changes; profiles with
9
+ ordinary AppData analysis trees stay there until explicitly migrated.
10
+
11
+ The per-profile non-secret `analysisDirectory` setting remains in Factory's profile. The
12
+ installation default is bound storage-control metadata beside Factory's profile store, paired
13
+ with ownership metadata on the chosen base. It is not an environment or checkout setting.
14
+ Missing/replaced storage fails before creating analysis data; there is no system-disk fallback.
15
+ New profiles initialize on their first ordinary command, durably bind their own empty directory,
16
+ and keep permanent compatibility paths. `storage show` remains passive.
8
17
 
9
18
  ```sh
10
19
  insight-cli permissions grant Update
@@ -29,7 +38,20 @@ user-selected outputs outside these trees keep their locations.
29
38
 
30
39
  Factory keeps profiles, permissions, IPC election/socket, migration-control metadata and
31
40
  application logs in current-user AppData. Secrets stay in the OS keyring. Credential/profile
32
- identities do not change. Updating/installing npm never migrates a profile automatically.
41
+ identities do not change. Updating/installing npm preserves existing profile locations and does not select a disk on its own.
42
+
43
+ ## Select a default after an earlier migration
44
+
45
+ For installations migrated with an older CLI, select their existing mounted base once:
46
+
47
+ ```sh
48
+ insight-cli storage default --directory "/mnt/analysis volume" --json
49
+ ```
50
+
51
+ This Update operation changes the installation default without moving existing data. Future
52
+ profiles inherit it without repeating configuration. The base cannot be inside a profile data
53
+ root or overlap Factory control storage. A base owned by another installation is rejected.
54
+ A missing volume or replaced base/ownership marker is never adopted automatically.
33
55
 
34
56
  ## Move an existing profile
35
57
 
@@ -44,7 +66,7 @@ insight-cli storage migrate --directory "/mnt/analysis volume" --json
44
66
  insight-cli storage show --json
45
67
  ```
46
68
 
47
- Dry-run checks separation, destination contents, access, conservative capacity and quiescence
69
+ Dry-run reports whether it would select the installation default and checks separation, destination contents, access, conservative capacity and quiescence
48
70
  without reserving/copying a migration. Source/destination/configuration roots cannot be equal
49
71
  or nested. Destination symlink roots, escaping source links, special files, unresolved links
50
72
  and inventories above one million entries or 128 directory levels are rejected. Internal
@@ -72,7 +94,9 @@ file, rechecks capacity and finishes the retained transaction. Source and destin
72
94
  anchor identities are bound durably. Missing/replaced volumes fail without creating a system-disk
73
95
  fallback; mount the original volume before resuming.
74
96
 
75
- Rollback restores retained source and the prior Factory setting. After activation it requires
97
+ Rollback restores retained source and the prior Factory setting. The installation default stays
98
+ selected because subsequent profiles may already use it; a resumed older migration cannot
99
+ overwrite a newer default selection. After activation it requires
76
100
  an unchanged destination and a verified retained source. Native reopening or later findings can
77
101
  make rollback unsafe, in which case it refuses and preserves both copies. Use forward migration
78
102
  to another empty base after using the new location. A rolled-back destination remains for manual
@@ -97,3 +121,7 @@ before starting another. This release provides no nonempty-destination adoption,
97
121
  synchronization, live-DB snapshotting or network-filesystem guarantees. POSIX directory fsync
98
122
  is used. Windows has file fsync and rename/junction semantics; Node cannot fsync directories
99
123
  there, and internal symlinks can require OS privileges.
124
+
125
+ Empty-profile initialization resumes automatically on its next command after interruption. Its
126
+ retained source contains no analysis entries, so the CLI cleans only that owned empty backup
127
+ automatically. Migration of existing data always retains its source for explicit cleanup.