sfora-cli 0.14.0 → 0.16.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 +128 -0
- package/dist/SforaFs.js +68 -0
- package/dist/api-client.d.ts +23 -0
- package/dist/api-client.js +29 -3
- package/dist/block-commands.d.ts +1 -0
- package/dist/block-commands.js +1 -0
- package/dist/cli-args.d.ts +4 -0
- package/dist/cli-args.js +8 -0
- package/dist/cli.js +39 -33
- package/dist/config.d.ts +2 -0
- package/dist/config.js +15 -6
- package/dist/format/cardMarkdown.d.ts +1 -0
- package/dist/format/cardMarkdown.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/local/workspace.d.ts +0 -16
- package/dist/local/workspace.js +34 -28
- package/dist/local-core/files.d.ts +23 -0
- package/dist/local-core/files.js +110 -0
- package/dist/local-core/index.d.ts +16 -0
- package/dist/local-core/index.js +15 -0
- package/dist/local-core/skill-adapters.d.ts +21 -0
- package/dist/local-core/skill-adapters.js +19 -0
- package/dist/local-core/skill-discovery.d.ts +22 -0
- package/dist/local-core/skill-discovery.js +79 -0
- package/dist/local-core/skill-domain.d.ts +74 -0
- package/dist/local-core/skill-executor.d.ts +23 -0
- package/dist/local-core/skill-executor.js +51 -0
- package/dist/local-core/skill-index.d.ts +54 -0
- package/dist/local-core/skill-index.js +115 -0
- package/dist/local-core/skill-local-executor.d.ts +18 -0
- package/dist/local-core/skill-local-executor.js +249 -0
- package/dist/local-core/skill-operations.d.ts +61 -0
- package/dist/local-core/skill-operations.js +268 -0
- package/dist/local-core/skill-review.d.ts +46 -0
- package/dist/local-core/skill-review.js +132 -0
- package/dist/local-core/skill-service.d.ts +96 -0
- package/dist/local-core/skill-service.js +157 -0
- package/dist/local-core/skill-store.d.ts +34 -0
- package/dist/local-core/skill-store.js +187 -0
- package/dist/local-core/skill-sync.d.ts +132 -0
- package/dist/local-core/skill-sync.js +111 -0
- package/dist/local-core/skills.d.ts +55 -0
- package/dist/local-core/skills.js +251 -0
- package/dist/skills-client.d.ts +32 -0
- package/dist/skills-client.js +88 -0
- package/dist/skills-command.d.ts +4 -0
- package/dist/skills-command.js +223 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/package.json +21 -3
- package/dist/config.test.js +0 -94
- /package/dist/{config.test.d.ts → local-core/skill-domain.js} +0 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { basename, relative, isAbsolute } from 'node:path';
|
|
4
|
+
import { discoverSkills, defaultSkillSources } from './skill-discovery.js';
|
|
5
|
+
import { projectSkillSources } from './skill-adapters.js';
|
|
6
|
+
import { SQLiteSkillStore } from './skill-store.js';
|
|
7
|
+
import { readSkillBundle } from './skills.js';
|
|
8
|
+
import { LocalConflictError } from './files.js';
|
|
9
|
+
export const pathWithin = (root, path) => { const rel = relative(root, path); return rel === '' || (rel !== '..' && !rel.startsWith('../') && !rel.startsWith('..\\') && !isAbsolute(rel)); };
|
|
10
|
+
const filesystemPorts = {
|
|
11
|
+
discover: discoverSkills,
|
|
12
|
+
resolveDirectory: async (path) => { const canonical = await realpath(path); if (!(await stat(canonical)).isDirectory())
|
|
13
|
+
throw new Error('Choose a directory for skill discovery.'); return canonical; },
|
|
14
|
+
inspect: readSkillBundle,
|
|
15
|
+
};
|
|
16
|
+
function rootSources(roots) {
|
|
17
|
+
return roots.flatMap(root => root.scope === 'project'
|
|
18
|
+
? projectSkillSources(root.path).map(source => ({ ...source, id: `${root.id}:${source.id}`, label: `${root.label} · ${source.label}` }))
|
|
19
|
+
: [{ ...root, kind: 'custom' }]);
|
|
20
|
+
}
|
|
21
|
+
/** Node service shared by CLI and desktop. Its IDs are identity, not renderer grants. */
|
|
22
|
+
export class SkillService {
|
|
23
|
+
store;
|
|
24
|
+
sources;
|
|
25
|
+
ports;
|
|
26
|
+
constructor(store = new SQLiteSkillStore(), sources = defaultSkillSources, ports = filesystemPorts) {
|
|
27
|
+
this.store = store;
|
|
28
|
+
this.sources = sources;
|
|
29
|
+
this.ports = ports;
|
|
30
|
+
}
|
|
31
|
+
async registerRoot(path, label, scope = "custom") {
|
|
32
|
+
const canonical = await this.ports.resolveDirectory(path);
|
|
33
|
+
const name = label?.trim() || basename(canonical) || canonical;
|
|
34
|
+
if (name.length > 200)
|
|
35
|
+
throw new Error('Folder label must be at most 200 characters.');
|
|
36
|
+
return this.store.transaction(c => {
|
|
37
|
+
const found = c.roots.find(r => r.path === canonical);
|
|
38
|
+
if (found) {
|
|
39
|
+
found.label = name;
|
|
40
|
+
found.scope = scope;
|
|
41
|
+
return { ...found };
|
|
42
|
+
}
|
|
43
|
+
const root = { id: randomUUID(), path: canonical, label: name, depth: 4, scope };
|
|
44
|
+
c.roots.push(root);
|
|
45
|
+
return root;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async roots() { return (await this.store.read()).roots; }
|
|
49
|
+
async removeRoot(id) {
|
|
50
|
+
return this.store.transaction(c => { if (!c.roots.some(r => r.id === id))
|
|
51
|
+
throw new Error('Registered folder not found.'); c.roots = c.roots.filter(r => r.id !== id); });
|
|
52
|
+
}
|
|
53
|
+
async inventory() {
|
|
54
|
+
const before = await this.store.read();
|
|
55
|
+
const sources = [...this.sources(), ...rootSources(before.roots)];
|
|
56
|
+
const result = await this.ports.discover(sources);
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
return this.store.transaction(c => {
|
|
59
|
+
// Scanning happens outside the lock. Do not commit results for a changed root set,
|
|
60
|
+
// or let an older scan overwrite observations from a concurrent newer transaction.
|
|
61
|
+
if (c.revision !== before.revision)
|
|
62
|
+
throw new LocalConflictError('Skill catalog changed during discovery. Refresh to use the latest inventory.');
|
|
63
|
+
const active = new Set(sources.map(s => s.id ?? s.path));
|
|
64
|
+
for (const found of result.skills) {
|
|
65
|
+
let location = c.locations.find(l => l.path === found.path);
|
|
66
|
+
if (!location) {
|
|
67
|
+
const identity = { id: randomUUID(), createdAt: now };
|
|
68
|
+
c.identities.push(identity);
|
|
69
|
+
location = { id: randomUUID(), skillId: identity.id, path: found.path, aliases: [], createdAt: now };
|
|
70
|
+
c.locations.push(location);
|
|
71
|
+
}
|
|
72
|
+
// Retain provenance aliases, but only active roots contribute to the displayed view.
|
|
73
|
+
for (const alias of found.aliases) {
|
|
74
|
+
const old = location.aliases.find(a => a.sourceId === alias.sourceId && a.path === alias.path);
|
|
75
|
+
if (old)
|
|
76
|
+
Object.assign(old, alias);
|
|
77
|
+
else
|
|
78
|
+
location.aliases.push(alias);
|
|
79
|
+
}
|
|
80
|
+
c.observations = c.observations.filter(o => o.locationId !== location.id);
|
|
81
|
+
c.observations.push({ locationId: location.id, name: found.name, observedAt: now, hash: found.hash, fileCount: found.fileCount, error: found.error, availability: 'available' });
|
|
82
|
+
}
|
|
83
|
+
const observed = new Set(result.skills.map(s => s.path));
|
|
84
|
+
const locations = c.locations.filter(l => l.aliases.some(a => active.has(a.sourceId)));
|
|
85
|
+
for (const location of locations) {
|
|
86
|
+
if (observed.has(location.path))
|
|
87
|
+
continue;
|
|
88
|
+
const old = c.observations.find(o => o.locationId === location.id);
|
|
89
|
+
if (old) {
|
|
90
|
+
old.availability = 'unavailable';
|
|
91
|
+
old.error = 'Not observed in this scan. The folder may be missing or inaccessible; its last known metadata is retained.';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const skills = locations.map(l => {
|
|
95
|
+
const o = c.observations.find(o => o.locationId === l.id);
|
|
96
|
+
const aliases = l.aliases.filter(a => active.has(a.sourceId));
|
|
97
|
+
return { skillId: l.skillId, locationId: l.id, path: l.path, name: o?.name ?? basename(l.path), sources: [...new Set(aliases.map(a => a.label))], aliases, hash: o?.hash, fileCount: o?.fileCount, error: o?.error ?? (!o ? 'Observation needs refresh.' : undefined), availability: o?.availability ?? 'unavailable' };
|
|
98
|
+
}).sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path));
|
|
99
|
+
return { skills, warnings: result.warnings };
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/** Warm reads use persisted observations; explicit inventory remains a full reconciliation. */
|
|
103
|
+
async cachedInventory() {
|
|
104
|
+
const c = await this.store.read();
|
|
105
|
+
const active = new Set([...this.sources(), ...rootSources(c.roots)].map(s => s.id ?? s.path));
|
|
106
|
+
const skills = c.locations.filter(l => l.aliases.some(a => active.has(a.sourceId))).map(l => {
|
|
107
|
+
const o = c.observations.find(o => o.locationId === l.id);
|
|
108
|
+
const aliases = l.aliases.filter(a => active.has(a.sourceId));
|
|
109
|
+
return { skillId: l.skillId, locationId: l.id, path: l.path, name: o?.name ?? basename(l.path), sources: [...new Set(aliases.map(a => a.label))], aliases, hash: o?.hash, fileCount: o?.fileCount, error: o?.error ?? (!o ? 'Observation needs refresh.' : undefined), availability: o?.availability ?? 'unavailable' };
|
|
110
|
+
}).sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path));
|
|
111
|
+
return { skills, warnings: [] };
|
|
112
|
+
}
|
|
113
|
+
async discoverySources() { return [...this.sources(), ...rootSources((await this.store.read()).roots)]; }
|
|
114
|
+
/** Re-read only affected known locations, preserving identity and last known bytes on failure. */
|
|
115
|
+
async refreshLocations(ids) {
|
|
116
|
+
const before = await this.store.read();
|
|
117
|
+
const updates = await Promise.all(before.locations.filter(l => ids.includes(l.id)).map(async (l) => {
|
|
118
|
+
try {
|
|
119
|
+
const bundle = await this.ports.inspect(l.path);
|
|
120
|
+
return { locationId: l.id, name: bundle.name, hash: bundle.hash, fileCount: bundle.files.length, observedAt: Date.now(), availability: 'available' };
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
return { ...before.observations.find(o => o.locationId === l.id), locationId: l.id, name: basename(l.path), observedAt: Date.now(), availability: 'unavailable', error: error.message };
|
|
124
|
+
}
|
|
125
|
+
}));
|
|
126
|
+
await this.store.transaction(c => {
|
|
127
|
+
if (c.revision !== before.revision)
|
|
128
|
+
throw new LocalConflictError('Skill catalog changed during refresh.');
|
|
129
|
+
for (const update of updates) {
|
|
130
|
+
c.observations = c.observations.filter(o => o.locationId !== update.locationId);
|
|
131
|
+
c.observations.push(update);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
return this.cachedInventory();
|
|
135
|
+
}
|
|
136
|
+
/** Metadata-only explicit relocation. Never guesses identity from a name/hash or moves files. */
|
|
137
|
+
async relocateLocation(id, path) {
|
|
138
|
+
const canonical = await this.ports.resolveDirectory(path);
|
|
139
|
+
await this.ports.inspect(canonical);
|
|
140
|
+
return this.store.transaction(c => {
|
|
141
|
+
const location = c.locations.find(l => l.id === id);
|
|
142
|
+
if (!location)
|
|
143
|
+
throw new Error('Skill location not found.');
|
|
144
|
+
if (c.locations.some(l => l.path === canonical && l.id !== id))
|
|
145
|
+
throw new Error('Destination is already associated with another skill location.');
|
|
146
|
+
const sources = [...this.sources(), ...rootSources(c.roots)];
|
|
147
|
+
const scopes = sources.filter(s => pathWithin(s.path, canonical));
|
|
148
|
+
if (!scopes.length)
|
|
149
|
+
throw new Error('Register the destination folder before relocating this skill.');
|
|
150
|
+
location.path = canonical;
|
|
151
|
+
location.aliases = scopes.map(s => ({ sourceId: ('id' in s && s.id) || s.path, path: canonical, label: s.label, kind: ('kind' in s && s.kind) || 'custom' }));
|
|
152
|
+
c.observations = c.observations.filter(o => o.locationId !== id);
|
|
153
|
+
return { ...location };
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
async rebuild() { await this.store.transaction(c => { c.observations = []; }); return this.inventory(); }
|
|
157
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { SkillCloudBinding, SkillIdentity, SkillLocation, SkillObservation } from './skill-domain.js';
|
|
2
|
+
export interface RegisteredSkillRoot {
|
|
3
|
+
id: string;
|
|
4
|
+
path: string;
|
|
5
|
+
label: string;
|
|
6
|
+
depth: number;
|
|
7
|
+
scope?: "custom" | "project";
|
|
8
|
+
}
|
|
9
|
+
export interface SkillCatalog {
|
|
10
|
+
schemaVersion: 2;
|
|
11
|
+
revision: number;
|
|
12
|
+
roots: RegisteredSkillRoot[];
|
|
13
|
+
identities: SkillIdentity[];
|
|
14
|
+
locations: SkillLocation[];
|
|
15
|
+
observations: SkillObservation[];
|
|
16
|
+
bindings: SkillCloudBinding[];
|
|
17
|
+
}
|
|
18
|
+
export interface SkillStore {
|
|
19
|
+
read(): Promise<SkillCatalog>;
|
|
20
|
+
transaction<T>(change: (catalog: SkillCatalog) => T): Promise<T>;
|
|
21
|
+
}
|
|
22
|
+
export declare const defaultSkillCatalogPath: () => string;
|
|
23
|
+
/** Fail closed rather than silently dropping durable records from a malformed/future store. */
|
|
24
|
+
export declare function validateSkillCatalog(value: unknown): asserts value is SkillCatalog;
|
|
25
|
+
/** SQLite owns cross-process locking and crash rollback; no persistent lock directories. */
|
|
26
|
+
export declare class SQLiteSkillStore implements SkillStore {
|
|
27
|
+
readonly path: string;
|
|
28
|
+
constructor(path?: string);
|
|
29
|
+
private exists;
|
|
30
|
+
private version;
|
|
31
|
+
private readDatabase;
|
|
32
|
+
read(): Promise<SkillCatalog>;
|
|
33
|
+
transaction<T>(change: (catalog: SkillCatalog) => T): Promise<T>;
|
|
34
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { lstat, mkdir, open } from 'node:fs/promises';
|
|
5
|
+
import { dirname, join, isAbsolute } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
export const defaultSkillCatalogPath = () => join(homedir(), '.sfora/skills/catalog.sqlite');
|
|
8
|
+
const empty = () => ({ schemaVersion: 2, revision: 0, roots: [], identities: [], locations: [], observations: [], bindings: [] });
|
|
9
|
+
const string = (value) => typeof value === 'string' && value.length > 0 && value.length <= 8192;
|
|
10
|
+
const time = (value) => Number.isSafeInteger(value) && Number(value) >= 0;
|
|
11
|
+
/** Fail closed rather than silently dropping durable records from a malformed/future store. */
|
|
12
|
+
export function validateSkillCatalog(value) {
|
|
13
|
+
const c = value;
|
|
14
|
+
if (!c || c.schemaVersion !== 2)
|
|
15
|
+
throw new Error('Unsupported skill catalog schema. Keep this file and use a compatible Sfora version.');
|
|
16
|
+
if (!time(c.revision) || ![c.roots, c.identities, c.locations, c.observations, c.bindings].every(v => Array.isArray(v) && v.length <= 10000))
|
|
17
|
+
throw new Error('Invalid skill catalog structure. Original catalog was preserved.');
|
|
18
|
+
const unique = (values) => new Set(values).size === values.length;
|
|
19
|
+
if (!c.roots.every(r => r && string(r.id) && string(r.path) && isAbsolute(r.path) && string(r.label) && Number.isInteger(r.depth) && r.depth >= 0 && r.depth <= 6 && (r.scope === undefined || ["custom", "project"].includes(r.scope))) || !unique(c.roots.map(r => r.id)) || !unique(c.roots.map(r => r.path)))
|
|
20
|
+
throw new Error('Invalid registered skill roots.');
|
|
21
|
+
if (!c.identities.every(i => i && string(i.id) && time(i.createdAt)) || !unique(c.identities.map(i => i.id)))
|
|
22
|
+
throw new Error('Invalid skill identities.');
|
|
23
|
+
const identities = new Set(c.identities.map(i => i.id));
|
|
24
|
+
if (!c.locations.every(l => l && string(l.id) && identities.has(l.skillId) && string(l.path) && isAbsolute(l.path) && time(l.createdAt) && Array.isArray(l.aliases) && l.aliases.length <= 1000 && l.aliases.every(a => a && string(a.sourceId) && string(a.path) && isAbsolute(a.path) && string(a.label) && ['agent', 'cache', 'custom'].includes(a.kind))) || !unique(c.locations.map(l => l.id)) || !unique(c.locations.map(l => l.path)))
|
|
25
|
+
throw new Error('Invalid skill locations.');
|
|
26
|
+
const deployment = (value) => {
|
|
27
|
+
if (!string(value))
|
|
28
|
+
return false;
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(value);
|
|
31
|
+
return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password && url.origin === value;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
if (!c.bindings.every(b => b && string(b.id) && identities.has(b.localSkillId) && string(b.accountId)
|
|
38
|
+
&& b.remote && deployment(b.remote.deployment) && string(b.remote.organizationId) && string(b.remote.projectId) && string(b.remote.skillId)
|
|
39
|
+
&& b.locator && string(b.locator.project) && string(b.locator.name)
|
|
40
|
+
&& (b.baseline === undefined || (b.baseline && b.baseline.contentPolicy === 'bundle-v1' && /^[a-f0-9]{64}$/.test(b.baseline.hash)
|
|
41
|
+
&& Number.isSafeInteger(b.baseline.cloudVersion) && Number(b.baseline.cloudVersion) > 0)))
|
|
42
|
+
|| !unique(c.bindings.map(b => b.id))
|
|
43
|
+
|| !unique(c.bindings.map(b => JSON.stringify([b.localSkillId, b.accountId, b.remote.deployment, b.remote.organizationId, b.remote.projectId]))))
|
|
44
|
+
throw new Error('Invalid skill cloud bindings.');
|
|
45
|
+
const locations = new Set(c.locations.map(l => l.id));
|
|
46
|
+
if (!c.observations.every(o => o && locations.has(o.locationId) && string(o.name) && time(o.observedAt) && ['available', 'unavailable'].includes(o.availability) && (o.hash === undefined || /^[a-f0-9]{64}$/.test(o.hash)) && (o.fileCount === undefined || time(o.fileCount)) && (o.error === undefined || typeof o.error === 'string')) || !unique(c.observations.map(o => o.locationId)))
|
|
47
|
+
throw new Error('Invalid skill observations.');
|
|
48
|
+
}
|
|
49
|
+
/** SQLite owns cross-process locking and crash rollback; no persistent lock directories. */
|
|
50
|
+
export class SQLiteSkillStore {
|
|
51
|
+
path;
|
|
52
|
+
constructor(path = defaultSkillCatalogPath()) {
|
|
53
|
+
this.path = path;
|
|
54
|
+
}
|
|
55
|
+
async exists() {
|
|
56
|
+
const info = await lstat(this.path).catch(error => { if (error.code === 'ENOENT')
|
|
57
|
+
return null; throw error; });
|
|
58
|
+
if (!info)
|
|
59
|
+
return false;
|
|
60
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
61
|
+
throw new Error('Skill catalog must be a regular file, not a symbolic link.');
|
|
62
|
+
if (info.size > 64 * 1024 * 1024)
|
|
63
|
+
throw new Error('Skill catalog exceeds supported size.');
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
version(db) { return Number(db.prepare('PRAGMA user_version').get().user_version); }
|
|
67
|
+
readDatabase(db) {
|
|
68
|
+
if (this.version(db) === 0 && db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().length === 0)
|
|
69
|
+
return empty();
|
|
70
|
+
if (![1, 2].includes(this.version(db)))
|
|
71
|
+
throw new Error('Unsupported skill catalog schema. Keep this file and use a compatible Sfora version.');
|
|
72
|
+
const rows = (table) => db.prepare(`SELECT data FROM ${table} ORDER BY rowid`).all().map(row => JSON.parse(String(row.data)));
|
|
73
|
+
const catalog = { schemaVersion: 2, revision: Number(db.prepare("SELECT value FROM metadata WHERE key='revision'").get().value), roots: rows('roots'), identities: rows('identities'), locations: rows('locations'), observations: rows('observations'), bindings: this.version(db) === 1 ? [] : rows('bindings') };
|
|
74
|
+
validateSkillCatalog(catalog);
|
|
75
|
+
return catalog;
|
|
76
|
+
}
|
|
77
|
+
async read() {
|
|
78
|
+
if (!await this.exists())
|
|
79
|
+
return empty();
|
|
80
|
+
const db = new DatabaseSync(this.path, { readOnly: true });
|
|
81
|
+
try {
|
|
82
|
+
db.exec('PRAGMA busy_timeout=5000; BEGIN');
|
|
83
|
+
const catalog = this.readDatabase(db);
|
|
84
|
+
db.exec('COMMIT');
|
|
85
|
+
return catalog;
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
db.close();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async transaction(change) {
|
|
92
|
+
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
93
|
+
const exists = await this.exists();
|
|
94
|
+
if (!exists) {
|
|
95
|
+
const handle = await open(this.path, 'ax', 0o600).catch(error => { if (error.code === 'EEXIST')
|
|
96
|
+
return null; throw error; });
|
|
97
|
+
await handle?.close();
|
|
98
|
+
}
|
|
99
|
+
await this.exists();
|
|
100
|
+
const db = new DatabaseSync(this.path);
|
|
101
|
+
try {
|
|
102
|
+
db.exec('PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;');
|
|
103
|
+
// Changing journal mode on first open can return SQLITE_BUSY without using
|
|
104
|
+
// busy_timeout. Retry only before any user mutation or transaction has begun.
|
|
105
|
+
const deadline = Date.now() + 5000;
|
|
106
|
+
for (;;) {
|
|
107
|
+
try {
|
|
108
|
+
const version = this.version(db);
|
|
109
|
+
if (![0, 1, 2].includes(version))
|
|
110
|
+
throw new Error('Unsupported skill catalog schema. Keep this file and use a compatible Sfora version.');
|
|
111
|
+
// VACUUM INTO includes committed WAL pages. Reserve private permissions
|
|
112
|
+
// before SQLite writes the backup; never copy a live database file.
|
|
113
|
+
if (version === 1) {
|
|
114
|
+
this.readDatabase(db);
|
|
115
|
+
const backupPath = `${this.path}.v1-${randomUUID()}.sqlite`;
|
|
116
|
+
const backup = await open(backupPath, 'ax', 0o600);
|
|
117
|
+
await backup.close();
|
|
118
|
+
db.prepare('VACUUM INTO ?').run(backupPath);
|
|
119
|
+
}
|
|
120
|
+
db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; BEGIN IMMEDIATE;');
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (![5, 6].includes(Number(error.errcode)) || Date.now() >= deadline)
|
|
125
|
+
throw error;
|
|
126
|
+
await delay(25);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (this.version(db) === 0) {
|
|
130
|
+
if (db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().length)
|
|
131
|
+
throw new Error('Unrecognized skill catalog; existing tables were preserved.');
|
|
132
|
+
db.exec(`CREATE TABLE metadata (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
|
|
133
|
+
INSERT INTO metadata VALUES ('revision',0);
|
|
134
|
+
CREATE TABLE roots (id TEXT PRIMARY KEY, data TEXT NOT NULL);
|
|
135
|
+
CREATE TABLE identities (id TEXT PRIMARY KEY, data TEXT NOT NULL);
|
|
136
|
+
CREATE TABLE locations (id TEXT PRIMARY KEY, skill_id TEXT NOT NULL REFERENCES identities(id), data TEXT NOT NULL);
|
|
137
|
+
CREATE TABLE observations (id TEXT PRIMARY KEY REFERENCES locations(id), data TEXT NOT NULL);
|
|
138
|
+
PRAGMA user_version=1;`);
|
|
139
|
+
}
|
|
140
|
+
if (this.version(db) === 1) {
|
|
141
|
+
db.exec(`CREATE TABLE bindings (id TEXT PRIMARY KEY, skill_id TEXT NOT NULL REFERENCES identities(id), data TEXT NOT NULL);
|
|
142
|
+
PRAGMA user_version=2;`);
|
|
143
|
+
}
|
|
144
|
+
const catalog = this.readDatabase(db);
|
|
145
|
+
const result = change(catalog);
|
|
146
|
+
if (result && typeof result.then === "function")
|
|
147
|
+
throw new Error("Skill catalog transactions must be synchronous.");
|
|
148
|
+
catalog.revision++;
|
|
149
|
+
validateSkillCatalog(catalog);
|
|
150
|
+
// Metadata tables are small; synchronize changed rows under one transaction.
|
|
151
|
+
// The service never removes durable identities during observation rebuilds.
|
|
152
|
+
const sync = (table, values) => {
|
|
153
|
+
const ids = new Set(values.map(v => v.id));
|
|
154
|
+
const remove = db.prepare(`DELETE FROM ${table} WHERE id=?`);
|
|
155
|
+
for (const row of db.prepare(`SELECT id FROM ${table}`).all())
|
|
156
|
+
if (!ids.has(String(row.id)))
|
|
157
|
+
remove.run(String(row.id));
|
|
158
|
+
const statement = db.prepare((table === 'locations' || table === 'bindings')
|
|
159
|
+
? `INSERT INTO ${table}(id,skill_id,data) VALUES (?,?,?) ON CONFLICT(id) DO UPDATE SET skill_id=excluded.skill_id,data=excluded.data WHERE data!=excluded.data`
|
|
160
|
+
: `INSERT INTO ${table}(id,data) VALUES (?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data WHERE data!=excluded.data`);
|
|
161
|
+
for (const row of values)
|
|
162
|
+
if ((table === 'locations' || table === 'bindings'))
|
|
163
|
+
statement.run(row.id, row.skillId, row.data);
|
|
164
|
+
else
|
|
165
|
+
statement.run(row.id, row.data);
|
|
166
|
+
};
|
|
167
|
+
sync('roots', catalog.roots.map(r => ({ id: r.id, data: JSON.stringify(r) })));
|
|
168
|
+
sync('identities', catalog.identities.map(r => ({ id: r.id, data: JSON.stringify(r) })));
|
|
169
|
+
sync('locations', catalog.locations.map(r => ({ id: r.id, skillId: r.skillId, data: JSON.stringify(r) })));
|
|
170
|
+
sync('observations', catalog.observations.map(r => ({ id: r.locationId, data: JSON.stringify(r) })));
|
|
171
|
+
sync('bindings', catalog.bindings.map(r => ({ id: r.id, skillId: r.localSkillId, data: JSON.stringify(r) })));
|
|
172
|
+
db.prepare("UPDATE metadata SET value=? WHERE key='revision'").run(catalog.revision);
|
|
173
|
+
db.exec('COMMIT');
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
try {
|
|
178
|
+
db.exec('ROLLBACK');
|
|
179
|
+
}
|
|
180
|
+
catch { }
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
db.close();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { SkillCloudBinding, SkillRevision } from './skill-domain.js';
|
|
2
|
+
import type { SkillStore } from './skill-store.js';
|
|
3
|
+
import { type SkillBundle, type inspectSkillTarget } from './skills.js';
|
|
4
|
+
export interface SkillCloudScope {
|
|
5
|
+
deployment: string;
|
|
6
|
+
accountId: string;
|
|
7
|
+
organizationId: string;
|
|
8
|
+
}
|
|
9
|
+
export interface PublishedSkillSnapshot {
|
|
10
|
+
remote: SkillCloudBinding['remote'];
|
|
11
|
+
accountId: string;
|
|
12
|
+
locator: SkillCloudBinding['locator'];
|
|
13
|
+
revision: SkillRevision;
|
|
14
|
+
draftRevision: number;
|
|
15
|
+
hasDraft: boolean;
|
|
16
|
+
draftHash?: string;
|
|
17
|
+
supportsIdentityGuards?: boolean;
|
|
18
|
+
bundle: SkillBundle;
|
|
19
|
+
}
|
|
20
|
+
export type SkillTransferPlan = ReturnType<typeof planSkillTransfer>;
|
|
21
|
+
/** A new installation needs an explicitly selected empty destination, not a guessed name match. */
|
|
22
|
+
export declare function planSkillInstallation(remote: PublishedSkillSnapshot, destination: string, target: Awaited<ReturnType<typeof inspectSkillTarget>>): {
|
|
23
|
+
schemaVersion: 1;
|
|
24
|
+
kind: "skill-install-preview";
|
|
25
|
+
source: {
|
|
26
|
+
accountId: string;
|
|
27
|
+
deployment: string;
|
|
28
|
+
organizationId: string;
|
|
29
|
+
skillId: string;
|
|
30
|
+
projectId: string;
|
|
31
|
+
};
|
|
32
|
+
locator: {
|
|
33
|
+
project: string;
|
|
34
|
+
name: string;
|
|
35
|
+
};
|
|
36
|
+
destination: string;
|
|
37
|
+
expected: {
|
|
38
|
+
remote: {
|
|
39
|
+
hash: string;
|
|
40
|
+
contentPolicy: "bundle-v1";
|
|
41
|
+
cloudVersion?: number;
|
|
42
|
+
};
|
|
43
|
+
targetState: "file" | "link" | "unavailable" | "managed" | "unmanaged" | "modified" | "missing";
|
|
44
|
+
localHash: string | null;
|
|
45
|
+
};
|
|
46
|
+
ownership: "unknown" | "managed" | "unmanaged";
|
|
47
|
+
outcome: string;
|
|
48
|
+
blockers: string[];
|
|
49
|
+
files: {
|
|
50
|
+
path: string;
|
|
51
|
+
kind: "added";
|
|
52
|
+
after: {
|
|
53
|
+
hash: string;
|
|
54
|
+
size: number;
|
|
55
|
+
executable: boolean;
|
|
56
|
+
};
|
|
57
|
+
}[];
|
|
58
|
+
};
|
|
59
|
+
export type SkillContentState = {
|
|
60
|
+
state: 'available';
|
|
61
|
+
bundle: SkillBundle;
|
|
62
|
+
} | {
|
|
63
|
+
state: 'missing';
|
|
64
|
+
reason: string;
|
|
65
|
+
} | {
|
|
66
|
+
state: 'unavailable';
|
|
67
|
+
reason: string;
|
|
68
|
+
};
|
|
69
|
+
export type SkillSyncStatus = 'local-only' | 'synced' | 'local-changed' | 'remote-changed' | 'conflict' | 'missing' | 'unknown' | 'unavailable';
|
|
70
|
+
export declare function assertSkillBindingScope(binding: SkillCloudBinding, scope: SkillCloudScope): void;
|
|
71
|
+
export declare function validatePublishedSkillSnapshot(snapshot: PublishedSkillSnapshot): void;
|
|
72
|
+
export declare function assertSkillBindingSnapshot(binding: SkillCloudBinding, snapshot: PublishedSkillSnapshot): void;
|
|
73
|
+
/** Adoption is explicit. Matching names, install receipts and stale observations cannot establish a baseline. */
|
|
74
|
+
export declare class SkillBindingService {
|
|
75
|
+
readonly store: SkillStore;
|
|
76
|
+
constructor(store: SkillStore);
|
|
77
|
+
list(): Promise<SkillCloudBinding[]>;
|
|
78
|
+
adopt(localSkillId: string, snapshot: PublishedSkillSnapshot, verifiedLocal: SkillBundle): Promise<SkillCloudBinding>;
|
|
79
|
+
}
|
|
80
|
+
/** Pure three-way comparison. Unavailable input is never interpreted as deletion or synchronization. */
|
|
81
|
+
export declare function compareSkillStates(local: SkillContentState, remote: SkillContentState | undefined, baseline?: SkillRevision): SkillSyncStatus;
|
|
82
|
+
/** Review artifact only. A future executor must revalidate every token and record a durable operation. */
|
|
83
|
+
export declare function planSkillTransfer(input: {
|
|
84
|
+
direction: 'push' | 'pull';
|
|
85
|
+
binding: SkillCloudBinding;
|
|
86
|
+
location: {
|
|
87
|
+
id: string;
|
|
88
|
+
path: string;
|
|
89
|
+
skillId: string;
|
|
90
|
+
};
|
|
91
|
+
local: SkillContentState;
|
|
92
|
+
remote: PublishedSkillSnapshot;
|
|
93
|
+
ownership: 'managed' | 'unmanaged' | 'unknown';
|
|
94
|
+
}): {
|
|
95
|
+
schemaVersion: 1;
|
|
96
|
+
kind: "skill-transfer-preview";
|
|
97
|
+
direction: "push" | "pull";
|
|
98
|
+
status: SkillSyncStatus;
|
|
99
|
+
outcome: string;
|
|
100
|
+
blockers: string[];
|
|
101
|
+
binding: SkillCloudBinding;
|
|
102
|
+
location: {
|
|
103
|
+
id: string;
|
|
104
|
+
path: string;
|
|
105
|
+
skillId: string;
|
|
106
|
+
};
|
|
107
|
+
ownership: "unknown" | "managed" | "unmanaged";
|
|
108
|
+
expected: {
|
|
109
|
+
localHash: string | null;
|
|
110
|
+
remote: {
|
|
111
|
+
hash: string;
|
|
112
|
+
contentPolicy: "bundle-v1";
|
|
113
|
+
cloudVersion?: number;
|
|
114
|
+
};
|
|
115
|
+
draftRevision: number;
|
|
116
|
+
};
|
|
117
|
+
files: {
|
|
118
|
+
before: {
|
|
119
|
+
hash: string;
|
|
120
|
+
size: number;
|
|
121
|
+
executable: boolean;
|
|
122
|
+
} | null;
|
|
123
|
+
after: {
|
|
124
|
+
hash: string;
|
|
125
|
+
size: number;
|
|
126
|
+
executable: boolean;
|
|
127
|
+
} | null;
|
|
128
|
+
path: string;
|
|
129
|
+
kind: "added" | "removed" | "modified";
|
|
130
|
+
}[];
|
|
131
|
+
};
|
|
132
|
+
export type SkillInstallPlan = ReturnType<typeof planSkillInstallation>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { diffSkillBundles, validateSkillBundle } from './skills.js';
|
|
4
|
+
/** A new installation needs an explicitly selected empty destination, not a guessed name match. */
|
|
5
|
+
export function planSkillInstallation(remote, destination, target) {
|
|
6
|
+
validatePublishedSkillSnapshot(remote);
|
|
7
|
+
if (!isAbsolute(destination))
|
|
8
|
+
throw new Error('Installation destination must be absolute.');
|
|
9
|
+
const blockers = target.state === 'missing' ? [] : ['Destination is occupied or unavailable. Choose a new folder, or explicitly bind and review an update.'];
|
|
10
|
+
return { schemaVersion: 1, kind: 'skill-install-preview', source: { ...remote.remote, accountId: remote.accountId }, locator: { ...remote.locator }, destination,
|
|
11
|
+
expected: { remote: { ...remote.revision }, targetState: target.state, localHash: target.hash ?? null }, ownership: target.ownership,
|
|
12
|
+
outcome: blockers.length ? 'blocked' : 'reviewable', blockers,
|
|
13
|
+
files: remote.bundle.files.map(f => ({ path: f.path, kind: 'added', after: { hash: f.sha256, size: f.size, executable: f.executable } })) };
|
|
14
|
+
}
|
|
15
|
+
export function assertSkillBindingScope(binding, scope) {
|
|
16
|
+
if (binding.accountId !== scope.accountId || binding.remote.deployment !== scope.deployment || binding.remote.organizationId !== scope.organizationId) {
|
|
17
|
+
throw new Error('Skill binding belongs to a different account, organization or deployment. Explicitly link the intended destination.');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function validatePublishedSkillSnapshot(snapshot) {
|
|
21
|
+
validateSkillBundle(snapshot.bundle);
|
|
22
|
+
if (snapshot.revision.contentPolicy !== 'bundle-v1' || snapshot.revision.hash !== snapshot.bundle.hash
|
|
23
|
+
|| !Number.isSafeInteger(snapshot.revision.cloudVersion) || Number(snapshot.revision.cloudVersion) < 1
|
|
24
|
+
|| !Number.isSafeInteger(snapshot.draftRevision) || snapshot.draftRevision < 0 || typeof snapshot.hasDraft !== 'boolean') {
|
|
25
|
+
throw new Error('Published skill revision does not match its verified bundle.');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function assertSkillBindingSnapshot(binding, snapshot) {
|
|
29
|
+
validatePublishedSkillSnapshot(snapshot);
|
|
30
|
+
assertSkillBindingScope(binding, { ...snapshot.remote, accountId: snapshot.accountId });
|
|
31
|
+
if (binding.remote.skillId !== snapshot.remote.skillId || binding.remote.projectId !== snapshot.remote.projectId) {
|
|
32
|
+
throw new Error('Cloud lookup resolved to a different skill identity. No binding was changed.');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Adoption is explicit. Matching names, install receipts and stale observations cannot establish a baseline. */
|
|
36
|
+
export class SkillBindingService {
|
|
37
|
+
store;
|
|
38
|
+
constructor(store) {
|
|
39
|
+
this.store = store;
|
|
40
|
+
}
|
|
41
|
+
async list() { return (await this.store.read()).bindings; }
|
|
42
|
+
async adopt(localSkillId, snapshot, verifiedLocal) {
|
|
43
|
+
validatePublishedSkillSnapshot(snapshot);
|
|
44
|
+
validateSkillBundle(verifiedLocal);
|
|
45
|
+
return this.store.transaction(c => {
|
|
46
|
+
if (!c.identities.some(i => i.id === localSkillId))
|
|
47
|
+
throw new Error('Local skill identity not found. Refresh the inventory first.');
|
|
48
|
+
const existing = c.bindings.find(b => b.localSkillId === localSkillId && b.accountId === snapshot.accountId
|
|
49
|
+
&& b.remote.deployment === snapshot.remote.deployment && b.remote.organizationId === snapshot.remote.organizationId && b.remote.projectId === snapshot.remote.projectId);
|
|
50
|
+
if (existing)
|
|
51
|
+
assertSkillBindingSnapshot(existing, snapshot);
|
|
52
|
+
const binding = existing ?? { id: randomUUID(), localSkillId, remote: { ...snapshot.remote }, accountId: snapshot.accountId, locator: { ...snapshot.locator } };
|
|
53
|
+
binding.locator = { ...snapshot.locator };
|
|
54
|
+
// Differing bytes retain the last confirmed baseline, or remain unknown.
|
|
55
|
+
if (verifiedLocal.hash === snapshot.revision.hash)
|
|
56
|
+
binding.baseline = { ...snapshot.revision };
|
|
57
|
+
if (!existing)
|
|
58
|
+
c.bindings.push(binding);
|
|
59
|
+
return structuredClone(binding);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Pure three-way comparison. Unavailable input is never interpreted as deletion or synchronization. */
|
|
64
|
+
export function compareSkillStates(local, remote, baseline) {
|
|
65
|
+
if (local.state === 'available')
|
|
66
|
+
validateSkillBundle(local.bundle);
|
|
67
|
+
if (remote?.state === 'available')
|
|
68
|
+
validateSkillBundle(remote.bundle);
|
|
69
|
+
if (local.state === 'unavailable' || remote?.state === 'unavailable')
|
|
70
|
+
return 'unavailable';
|
|
71
|
+
if (local.state === 'missing' || remote?.state === 'missing')
|
|
72
|
+
return 'missing';
|
|
73
|
+
if (!remote)
|
|
74
|
+
return 'local-only';
|
|
75
|
+
if (!baseline || baseline.contentPolicy !== 'bundle-v1' || !/^[a-f0-9]{64}$/.test(baseline.hash))
|
|
76
|
+
return 'unknown';
|
|
77
|
+
if (local.bundle.hash === remote.bundle.hash)
|
|
78
|
+
return 'synced';
|
|
79
|
+
if (local.bundle.hash === baseline.hash)
|
|
80
|
+
return 'remote-changed';
|
|
81
|
+
if (remote.bundle.hash === baseline.hash)
|
|
82
|
+
return 'local-changed';
|
|
83
|
+
return 'conflict';
|
|
84
|
+
}
|
|
85
|
+
/** Review artifact only. A future executor must revalidate every token and record a durable operation. */
|
|
86
|
+
export function planSkillTransfer(input) {
|
|
87
|
+
const { binding, local, remote, direction, location, ownership } = input;
|
|
88
|
+
assertSkillBindingSnapshot(binding, remote);
|
|
89
|
+
if (location.skillId !== binding.localSkillId)
|
|
90
|
+
throw new Error('Location does not belong to the bound local skill.');
|
|
91
|
+
const status = compareSkillStates(local, { state: 'available', bundle: remote.bundle }, binding.baseline);
|
|
92
|
+
const blockers = [];
|
|
93
|
+
if (!['synced', direction === 'push' ? 'local-changed' : 'remote-changed'].includes(status))
|
|
94
|
+
blockers.push(`Cannot ${direction} automatically from ${status}; review the divergence first.`);
|
|
95
|
+
if (status !== 'synced' && direction === 'pull' && ownership !== 'managed')
|
|
96
|
+
blockers.push('Replacing local files requires verified Sfora ownership or an explicitly chosen new destination.');
|
|
97
|
+
if (status !== 'synced' && direction === 'push' && remote.hasDraft)
|
|
98
|
+
blockers.push('The cloud contains an unpublished draft; review it before replacing it.');
|
|
99
|
+
const before = direction === 'push' ? remote.bundle : local.state === 'available' ? local.bundle : undefined;
|
|
100
|
+
const after = direction === 'pull' ? remote.bundle : local.state === 'available' ? local.bundle : undefined;
|
|
101
|
+
const files = before && after ? diffSkillBundles(before, after).map(change => {
|
|
102
|
+
const metadata = (bundle) => { const f = bundle.files.find(f => f.path === change.path); return f ? { hash: f.sha256, size: f.size, executable: f.executable } : null; };
|
|
103
|
+
return { ...change, before: metadata(before), after: metadata(after) };
|
|
104
|
+
}) : [];
|
|
105
|
+
return {
|
|
106
|
+
schemaVersion: 1, kind: 'skill-transfer-preview', direction, status,
|
|
107
|
+
outcome: blockers.length ? 'blocked' : status === 'synced' ? 'no-op' : 'reviewable', blockers,
|
|
108
|
+
binding: structuredClone(binding), location: { ...location }, ownership,
|
|
109
|
+
expected: { localHash: local.state === 'available' ? local.bundle.hash : null, remote: { ...remote.revision }, draftRevision: remote.draftRevision }, files,
|
|
110
|
+
};
|
|
111
|
+
}
|