@hasna/instructions 0.3.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/LICENSE +191 -0
- package/README.md +180 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +8080 -0
- package/dist/cli/output.test.d.ts +2 -0
- package/dist/cli/output.test.d.ts.map +1 -0
- package/dist/cli/session.test.d.ts +2 -0
- package/dist/cli/session.test.d.ts.map +1 -0
- package/dist/cli/storage.d.ts +3 -0
- package/dist/cli/storage.d.ts.map +1 -0
- package/dist/cli/storage.test.d.ts +2 -0
- package/dist/cli/storage.test.d.ts.map +1 -0
- package/dist/db/configs.d.ts +10 -0
- package/dist/db/configs.d.ts.map +1 -0
- package/dist/db/configs.test.d.ts +2 -0
- package/dist/db/configs.test.d.ts.map +1 -0
- package/dist/db/database.d.ts +7 -0
- package/dist/db/database.d.ts.map +1 -0
- package/dist/db/database.test.d.ts +2 -0
- package/dist/db/database.test.d.ts.map +1 -0
- package/dist/db/machines.d.ts +9 -0
- package/dist/db/machines.d.ts.map +1 -0
- package/dist/db/machines.test.d.ts +2 -0
- package/dist/db/machines.test.d.ts.map +1 -0
- package/dist/db/pg-migrations.d.ts +7 -0
- package/dist/db/pg-migrations.d.ts.map +1 -0
- package/dist/db/profiles.d.ts +14 -0
- package/dist/db/profiles.d.ts.map +1 -0
- package/dist/db/profiles.test.d.ts +2 -0
- package/dist/db/profiles.test.d.ts.map +1 -0
- package/dist/db/remote-storage.d.ts +13 -0
- package/dist/db/remote-storage.d.ts.map +1 -0
- package/dist/db/snapshots.d.ts +8 -0
- package/dist/db/snapshots.d.ts.map +1 -0
- package/dist/db/snapshots.test.d.ts +2 -0
- package/dist/db/snapshots.test.d.ts.map +1 -0
- package/dist/db/storage-sync.d.ts +53 -0
- package/dist/db/storage-sync.d.ts.map +1 -0
- package/dist/db/storage-sync.test.d.ts +2 -0
- package/dist/db/storage-sync.test.d.ts.map +1 -0
- package/dist/generated/storage-kit/health.d.ts +20 -0
- package/dist/generated/storage-kit/health.d.ts.map +1 -0
- package/dist/generated/storage-kit/index.d.ts +8 -0
- package/dist/generated/storage-kit/index.d.ts.map +1 -0
- package/dist/generated/storage-kit/migrations.d.ts +48 -0
- package/dist/generated/storage-kit/migrations.d.ts.map +1 -0
- package/dist/generated/storage-kit/mode.d.ts +48 -0
- package/dist/generated/storage-kit/mode.d.ts.map +1 -0
- package/dist/generated/storage-kit/pool.d.ts +34 -0
- package/dist/generated/storage-kit/pool.d.ts.map +1 -0
- package/dist/generated/storage-kit/query.d.ts +36 -0
- package/dist/generated/storage-kit/query.d.ts.map +1 -0
- package/dist/generated/storage-kit/tls.d.ts +26 -0
- package/dist/generated/storage-kit/tls.d.ts.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3749 -0
- package/dist/lib/apply-batch.test.d.ts +2 -0
- package/dist/lib/apply-batch.test.d.ts.map +1 -0
- package/dist/lib/apply.d.ts +16 -0
- package/dist/lib/apply.d.ts.map +1 -0
- package/dist/lib/apply.test.d.ts +2 -0
- package/dist/lib/apply.test.d.ts.map +1 -0
- package/dist/lib/compact-output.d.ts +78 -0
- package/dist/lib/compact-output.d.ts.map +1 -0
- package/dist/lib/compact-output.test.d.ts +2 -0
- package/dist/lib/compact-output.test.d.ts.map +1 -0
- package/dist/lib/export-import.test.d.ts +2 -0
- package/dist/lib/export-import.test.d.ts.map +1 -0
- package/dist/lib/export.d.ts +12 -0
- package/dist/lib/export.d.ts.map +1 -0
- package/dist/lib/import.d.ts +14 -0
- package/dist/lib/import.d.ts.map +1 -0
- package/dist/lib/machine.d.ts +21 -0
- package/dist/lib/machine.d.ts.map +1 -0
- package/dist/lib/machine.test.d.ts +2 -0
- package/dist/lib/machine.test.d.ts.map +1 -0
- package/dist/lib/package-version.d.ts +2 -0
- package/dist/lib/package-version.d.ts.map +1 -0
- package/dist/lib/platform-profiles.d.ts +5 -0
- package/dist/lib/platform-profiles.d.ts.map +1 -0
- package/dist/lib/project-dashboard-standard.d.ts +7 -0
- package/dist/lib/project-dashboard-standard.d.ts.map +1 -0
- package/dist/lib/project-dashboard-standard.test.d.ts +2 -0
- package/dist/lib/project-dashboard-standard.test.d.ts.map +1 -0
- package/dist/lib/redact.d.ts +29 -0
- package/dist/lib/redact.d.ts.map +1 -0
- package/dist/lib/redact.test.d.ts +2 -0
- package/dist/lib/redact.test.d.ts.map +1 -0
- package/dist/lib/session-apply.d.ts +48 -0
- package/dist/lib/session-apply.d.ts.map +1 -0
- package/dist/lib/session-apply.test.d.ts +2 -0
- package/dist/lib/session-apply.test.d.ts.map +1 -0
- package/dist/lib/session-render.d.ts +176 -0
- package/dist/lib/session-render.d.ts.map +1 -0
- package/dist/lib/session-render.test.d.ts +2 -0
- package/dist/lib/session-render.test.d.ts.map +1 -0
- package/dist/lib/sync-dir.d.ts +13 -0
- package/dist/lib/sync-dir.d.ts.map +1 -0
- package/dist/lib/sync-dir.test.d.ts +2 -0
- package/dist/lib/sync-dir.test.d.ts.map +1 -0
- package/dist/lib/sync-known.test.d.ts +2 -0
- package/dist/lib/sync-known.test.d.ts.map +1 -0
- package/dist/lib/sync.d.ts +54 -0
- package/dist/lib/sync.d.ts.map +1 -0
- package/dist/lib/sync.test.d.ts +2 -0
- package/dist/lib/sync.test.d.ts.map +1 -0
- package/dist/lib/template.d.ts +10 -0
- package/dist/lib/template.d.ts.map +1 -0
- package/dist/lib/template.test.d.ts +2 -0
- package/dist/lib/template.test.d.ts.map +1 -0
- package/dist/lib/transforms.d.ts +11 -0
- package/dist/lib/transforms.d.ts.map +1 -0
- package/dist/lib/transforms.test.d.ts +2 -0
- package/dist/lib/transforms.test.d.ts.map +1 -0
- package/dist/mcp/http.d.ts +28 -0
- package/dist/mcp/http.d.ts.map +1 -0
- package/dist/mcp/http.test.d.ts +2 -0
- package/dist/mcp/http.test.d.ts.map +1 -0
- package/dist/mcp/index.d.ts +3 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +2591 -0
- package/dist/mcp/mcp.test.d.ts +2 -0
- package/dist/mcp/mcp.test.d.ts.map +1 -0
- package/dist/mcp/server.d.ts +4 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/server/cloud.d.ts +39 -0
- package/dist/server/cloud.d.ts.map +1 -0
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +19062 -0
- package/dist/server/openapi.d.ts +578 -0
- package/dist/server/openapi.d.ts.map +1 -0
- package/dist/server/server.test.d.ts +2 -0
- package/dist/server/server.test.d.ts.map +1 -0
- package/dist/server/v1.d.ts +9 -0
- package/dist/server/v1.d.ts.map +1 -0
- package/dist/status.d.ts +56 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.test.d.ts +2 -0
- package/dist/status.test.d.ts.map +1 -0
- package/dist/storage/cloud-store.d.ts +17 -0
- package/dist/storage/cloud-store.d.ts.map +1 -0
- package/dist/storage/schema.d.ts +12 -0
- package/dist/storage/schema.d.ts.map +1 -0
- package/dist/storage.d.ts +5 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +537 -0
- package/dist/types/index.d.ts +189 -0
- package/dist/types/index.d.ts.map +1 -0
- package/package.json +96 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3749 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/types/index.ts
|
|
3
|
+
var CONFIG_KINDS = ["file", "reference"];
|
|
4
|
+
var CONFIG_CATEGORIES = [
|
|
5
|
+
"agent",
|
|
6
|
+
"rules",
|
|
7
|
+
"mcp",
|
|
8
|
+
"shell",
|
|
9
|
+
"secrets_schema",
|
|
10
|
+
"workspace",
|
|
11
|
+
"git",
|
|
12
|
+
"tools"
|
|
13
|
+
];
|
|
14
|
+
var CONFIG_AGENTS = [
|
|
15
|
+
"claude",
|
|
16
|
+
"codex",
|
|
17
|
+
"gemini",
|
|
18
|
+
"opencode",
|
|
19
|
+
"cursor",
|
|
20
|
+
"codewith",
|
|
21
|
+
"aicopilot",
|
|
22
|
+
"zsh",
|
|
23
|
+
"git",
|
|
24
|
+
"npm",
|
|
25
|
+
"global"
|
|
26
|
+
];
|
|
27
|
+
var CONFIG_TRANSFORMS = [
|
|
28
|
+
"passthrough",
|
|
29
|
+
"claude-passthrough",
|
|
30
|
+
"codex-flat",
|
|
31
|
+
"opencode-flat",
|
|
32
|
+
"cursor-mdc",
|
|
33
|
+
"skill-neutral"
|
|
34
|
+
];
|
|
35
|
+
var CONFIG_FORMATS = [
|
|
36
|
+
"text",
|
|
37
|
+
"json",
|
|
38
|
+
"toml",
|
|
39
|
+
"yaml",
|
|
40
|
+
"markdown",
|
|
41
|
+
"ini"
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
class ConfigNotFoundError extends Error {
|
|
45
|
+
constructor(id) {
|
|
46
|
+
super(`Config not found: ${id}`);
|
|
47
|
+
this.name = "ConfigNotFoundError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class ProfileNotFoundError extends Error {
|
|
52
|
+
constructor(id) {
|
|
53
|
+
super(`Profile not found: ${id}`);
|
|
54
|
+
this.name = "ProfileNotFoundError";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class ConfigApplyError extends Error {
|
|
59
|
+
constructor(message) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = "ConfigApplyError";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class TemplateRenderError extends Error {
|
|
66
|
+
constructor(message) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = "TemplateRenderError";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// src/db/database.ts
|
|
72
|
+
import { Database } from "bun:sqlite";
|
|
73
|
+
import { cpSync, existsSync, mkdirSync, statSync } from "fs";
|
|
74
|
+
import { join } from "path";
|
|
75
|
+
import { randomUUID } from "crypto";
|
|
76
|
+
function getDbPath() {
|
|
77
|
+
if (process.env["HASNA_CONFIGS_DB_PATH"]) {
|
|
78
|
+
return process.env["HASNA_CONFIGS_DB_PATH"];
|
|
79
|
+
}
|
|
80
|
+
if (process.env["CONFIGS_DB_PATH"]) {
|
|
81
|
+
return process.env["CONFIGS_DB_PATH"];
|
|
82
|
+
}
|
|
83
|
+
migrateDotfile();
|
|
84
|
+
const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
|
|
85
|
+
const dir = join(home, ".hasna", "configs");
|
|
86
|
+
mkdirSync(dir, { recursive: true });
|
|
87
|
+
return join(dir, "configs.db");
|
|
88
|
+
}
|
|
89
|
+
function uuid() {
|
|
90
|
+
return randomUUID();
|
|
91
|
+
}
|
|
92
|
+
function now() {
|
|
93
|
+
return new Date().toISOString();
|
|
94
|
+
}
|
|
95
|
+
function slugify(name) {
|
|
96
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
97
|
+
}
|
|
98
|
+
var MIGRATIONS = [
|
|
99
|
+
`
|
|
100
|
+
CREATE TABLE IF NOT EXISTS configs (
|
|
101
|
+
id TEXT PRIMARY KEY,
|
|
102
|
+
name TEXT NOT NULL,
|
|
103
|
+
slug TEXT NOT NULL UNIQUE,
|
|
104
|
+
kind TEXT NOT NULL DEFAULT 'file',
|
|
105
|
+
category TEXT NOT NULL,
|
|
106
|
+
agent TEXT NOT NULL DEFAULT 'global',
|
|
107
|
+
target_path TEXT,
|
|
108
|
+
format TEXT NOT NULL DEFAULT 'text',
|
|
109
|
+
content TEXT NOT NULL DEFAULT '',
|
|
110
|
+
description TEXT,
|
|
111
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
112
|
+
is_template INTEGER NOT NULL DEFAULT 0,
|
|
113
|
+
version INTEGER NOT NULL DEFAULT 1,
|
|
114
|
+
created_at TEXT NOT NULL,
|
|
115
|
+
updated_at TEXT NOT NULL,
|
|
116
|
+
synced_at TEXT
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
CREATE TABLE IF NOT EXISTS config_snapshots (
|
|
120
|
+
id TEXT PRIMARY KEY,
|
|
121
|
+
config_id TEXT NOT NULL REFERENCES configs(id) ON DELETE CASCADE,
|
|
122
|
+
content TEXT NOT NULL,
|
|
123
|
+
version INTEGER NOT NULL,
|
|
124
|
+
created_at TEXT NOT NULL
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
CREATE TABLE IF NOT EXISTS profiles (
|
|
128
|
+
id TEXT PRIMARY KEY,
|
|
129
|
+
name TEXT NOT NULL,
|
|
130
|
+
slug TEXT NOT NULL UNIQUE,
|
|
131
|
+
description TEXT,
|
|
132
|
+
created_at TEXT NOT NULL,
|
|
133
|
+
updated_at TEXT NOT NULL
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
CREATE TABLE IF NOT EXISTS profile_configs (
|
|
137
|
+
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
138
|
+
config_id TEXT NOT NULL REFERENCES configs(id) ON DELETE CASCADE,
|
|
139
|
+
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
140
|
+
PRIMARY KEY (profile_id, config_id)
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
CREATE TABLE IF NOT EXISTS machines (
|
|
144
|
+
id TEXT PRIMARY KEY,
|
|
145
|
+
hostname TEXT NOT NULL UNIQUE,
|
|
146
|
+
os TEXT,
|
|
147
|
+
last_applied_at TEXT,
|
|
148
|
+
created_at TEXT NOT NULL
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
152
|
+
version INTEGER PRIMARY KEY
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
INSERT OR IGNORE INTO schema_version (version) VALUES (1);
|
|
156
|
+
`,
|
|
157
|
+
`
|
|
158
|
+
ALTER TABLE profiles ADD COLUMN selectors TEXT NOT NULL DEFAULT '{}';
|
|
159
|
+
ALTER TABLE profiles ADD COLUMN variables TEXT NOT NULL DEFAULT '{}';
|
|
160
|
+
ALTER TABLE machines ADD COLUMN arch TEXT;
|
|
161
|
+
`,
|
|
162
|
+
`
|
|
163
|
+
ALTER TABLE configs ADD COLUMN outputs TEXT NOT NULL DEFAULT '[]';
|
|
164
|
+
`
|
|
165
|
+
];
|
|
166
|
+
var _db = null;
|
|
167
|
+
function getDatabase(path) {
|
|
168
|
+
if (_db)
|
|
169
|
+
return _db;
|
|
170
|
+
const dbPath = path || getDbPath();
|
|
171
|
+
const db = new Database(dbPath);
|
|
172
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
173
|
+
db.run("PRAGMA foreign_keys = ON");
|
|
174
|
+
applyMigrations(db);
|
|
175
|
+
ensureFeedbackTable(db);
|
|
176
|
+
_db = db;
|
|
177
|
+
return db;
|
|
178
|
+
}
|
|
179
|
+
function resetDatabase() {
|
|
180
|
+
if (_db) {
|
|
181
|
+
try {
|
|
182
|
+
_db.close();
|
|
183
|
+
} catch {}
|
|
184
|
+
}
|
|
185
|
+
_db = null;
|
|
186
|
+
}
|
|
187
|
+
function applyMigrations(db) {
|
|
188
|
+
let currentVersion = 0;
|
|
189
|
+
try {
|
|
190
|
+
const row = db.query("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1").get();
|
|
191
|
+
currentVersion = row?.version ?? 0;
|
|
192
|
+
} catch {
|
|
193
|
+
currentVersion = 0;
|
|
194
|
+
}
|
|
195
|
+
for (let i = currentVersion;i < MIGRATIONS.length; i++) {
|
|
196
|
+
db.exec(MIGRATIONS[i]);
|
|
197
|
+
db.run(`INSERT OR REPLACE INTO schema_version (version) VALUES (${i + 1})`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function ensureFeedbackTable(db) {
|
|
201
|
+
db.exec(`
|
|
202
|
+
CREATE TABLE IF NOT EXISTS feedback (
|
|
203
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
204
|
+
message TEXT NOT NULL,
|
|
205
|
+
email TEXT,
|
|
206
|
+
category TEXT DEFAULT 'general',
|
|
207
|
+
version TEXT,
|
|
208
|
+
machine_id TEXT,
|
|
209
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
210
|
+
)
|
|
211
|
+
`);
|
|
212
|
+
}
|
|
213
|
+
function migrateDotfile() {
|
|
214
|
+
const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
|
|
215
|
+
const oldDirs = [join(home, ".open-configs"), join(home, ".configs")];
|
|
216
|
+
const newDir = join(home, ".hasna", "configs");
|
|
217
|
+
if (existsSync(newDir))
|
|
218
|
+
return;
|
|
219
|
+
for (const oldDir of oldDirs) {
|
|
220
|
+
if (!existsSync(oldDir))
|
|
221
|
+
continue;
|
|
222
|
+
try {
|
|
223
|
+
if (!statSync(oldDir).isDirectory())
|
|
224
|
+
continue;
|
|
225
|
+
mkdirSync(join(home, ".hasna"), { recursive: true });
|
|
226
|
+
cpSync(oldDir, newDir, { recursive: true, force: false });
|
|
227
|
+
return;
|
|
228
|
+
} catch {}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// src/db/configs.ts
|
|
233
|
+
function rowToConfig(row) {
|
|
234
|
+
let outputs = [];
|
|
235
|
+
try {
|
|
236
|
+
const parsed = JSON.parse(row.outputs || "[]");
|
|
237
|
+
outputs = Array.isArray(parsed) ? parsed : [];
|
|
238
|
+
} catch {
|
|
239
|
+
outputs = [];
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
...row,
|
|
243
|
+
tags: JSON.parse(row.tags || "[]"),
|
|
244
|
+
outputs,
|
|
245
|
+
is_template: !!row.is_template,
|
|
246
|
+
kind: row.kind,
|
|
247
|
+
category: row.category,
|
|
248
|
+
agent: row.agent,
|
|
249
|
+
format: row.format
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
function uniqueSlug(name, db, excludeId) {
|
|
253
|
+
const base = slugify(name);
|
|
254
|
+
let slug = base;
|
|
255
|
+
let i = 1;
|
|
256
|
+
while (true) {
|
|
257
|
+
const existing = db.query("SELECT id FROM configs WHERE slug = ?").get(slug);
|
|
258
|
+
if (!existing || existing.id === excludeId)
|
|
259
|
+
return slug;
|
|
260
|
+
slug = `${base}-${i++}`;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function createConfig(input, db) {
|
|
264
|
+
const d = db || getDatabase();
|
|
265
|
+
const id = uuid();
|
|
266
|
+
const ts = now();
|
|
267
|
+
const slug = uniqueSlug(input.name, d);
|
|
268
|
+
const tags = JSON.stringify(input.tags || []);
|
|
269
|
+
const outputs = JSON.stringify(input.outputs || []);
|
|
270
|
+
d.run(`INSERT INTO configs (id, name, slug, kind, category, agent, target_path, outputs, format, content, description, tags, is_template, version, created_at, updated_at, synced_at)
|
|
271
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, NULL)`, [
|
|
272
|
+
id,
|
|
273
|
+
input.name,
|
|
274
|
+
slug,
|
|
275
|
+
input.kind ?? "file",
|
|
276
|
+
input.category,
|
|
277
|
+
input.agent ?? "global",
|
|
278
|
+
input.target_path ?? null,
|
|
279
|
+
outputs,
|
|
280
|
+
input.format ?? "text",
|
|
281
|
+
input.content,
|
|
282
|
+
input.description ?? null,
|
|
283
|
+
tags,
|
|
284
|
+
input.is_template ? 1 : 0,
|
|
285
|
+
ts,
|
|
286
|
+
ts
|
|
287
|
+
]);
|
|
288
|
+
return getConfig(id, d);
|
|
289
|
+
}
|
|
290
|
+
function getConfig(idOrSlug, db) {
|
|
291
|
+
const d = db || getDatabase();
|
|
292
|
+
const row = d.query("SELECT * FROM configs WHERE id = ? OR slug = ?").get(idOrSlug, idOrSlug);
|
|
293
|
+
if (!row)
|
|
294
|
+
throw new ConfigNotFoundError(idOrSlug);
|
|
295
|
+
return rowToConfig(row);
|
|
296
|
+
}
|
|
297
|
+
function getConfigById(id, db) {
|
|
298
|
+
const d = db || getDatabase();
|
|
299
|
+
const row = d.query("SELECT * FROM configs WHERE id = ?").get(id);
|
|
300
|
+
if (!row)
|
|
301
|
+
throw new ConfigNotFoundError(id);
|
|
302
|
+
return rowToConfig(row);
|
|
303
|
+
}
|
|
304
|
+
function listConfigs(filter, db) {
|
|
305
|
+
const d = db || getDatabase();
|
|
306
|
+
const conditions = [];
|
|
307
|
+
const params = [];
|
|
308
|
+
if (filter?.category) {
|
|
309
|
+
conditions.push("category = ?");
|
|
310
|
+
params.push(filter.category);
|
|
311
|
+
}
|
|
312
|
+
if (filter?.agent) {
|
|
313
|
+
conditions.push("agent = ?");
|
|
314
|
+
params.push(filter.agent);
|
|
315
|
+
}
|
|
316
|
+
if (filter?.kind) {
|
|
317
|
+
conditions.push("kind = ?");
|
|
318
|
+
params.push(filter.kind);
|
|
319
|
+
}
|
|
320
|
+
if (filter?.is_template !== undefined) {
|
|
321
|
+
conditions.push("is_template = ?");
|
|
322
|
+
params.push(filter.is_template ? 1 : 0);
|
|
323
|
+
}
|
|
324
|
+
if (filter?.search) {
|
|
325
|
+
conditions.push("(name LIKE ? OR description LIKE ? OR content LIKE ?)");
|
|
326
|
+
const q = `%${filter.search}%`;
|
|
327
|
+
params.push(q, q, q);
|
|
328
|
+
}
|
|
329
|
+
if (filter?.tags && filter.tags.length > 0) {
|
|
330
|
+
const tagConditions = filter.tags.map(() => "tags LIKE ?").join(" OR ");
|
|
331
|
+
conditions.push(`(${tagConditions})`);
|
|
332
|
+
for (const tag of filter.tags)
|
|
333
|
+
params.push(`%"${tag}"%`);
|
|
334
|
+
}
|
|
335
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
336
|
+
const rows = d.query(`SELECT * FROM configs ${where} ORDER BY category, name`).all(...params);
|
|
337
|
+
return rows.map(rowToConfig);
|
|
338
|
+
}
|
|
339
|
+
function updateConfig(idOrSlug, input, db) {
|
|
340
|
+
const d = db || getDatabase();
|
|
341
|
+
const existing = getConfig(idOrSlug, d);
|
|
342
|
+
const ts = now();
|
|
343
|
+
const updates = ["updated_at = ?", "version = version + 1"];
|
|
344
|
+
const params = [ts];
|
|
345
|
+
if (input.name !== undefined) {
|
|
346
|
+
updates.push("name = ?", "slug = ?");
|
|
347
|
+
params.push(input.name, uniqueSlug(input.name, d, existing.id));
|
|
348
|
+
}
|
|
349
|
+
if (input.kind !== undefined) {
|
|
350
|
+
updates.push("kind = ?");
|
|
351
|
+
params.push(input.kind);
|
|
352
|
+
}
|
|
353
|
+
if (input.category !== undefined) {
|
|
354
|
+
updates.push("category = ?");
|
|
355
|
+
params.push(input.category);
|
|
356
|
+
}
|
|
357
|
+
if (input.agent !== undefined) {
|
|
358
|
+
updates.push("agent = ?");
|
|
359
|
+
params.push(input.agent);
|
|
360
|
+
}
|
|
361
|
+
if (input.target_path !== undefined) {
|
|
362
|
+
updates.push("target_path = ?");
|
|
363
|
+
params.push(input.target_path);
|
|
364
|
+
}
|
|
365
|
+
if (input.outputs !== undefined) {
|
|
366
|
+
updates.push("outputs = ?");
|
|
367
|
+
params.push(JSON.stringify(input.outputs));
|
|
368
|
+
}
|
|
369
|
+
if (input.format !== undefined) {
|
|
370
|
+
updates.push("format = ?");
|
|
371
|
+
params.push(input.format);
|
|
372
|
+
}
|
|
373
|
+
if (input.content !== undefined) {
|
|
374
|
+
updates.push("content = ?");
|
|
375
|
+
params.push(input.content);
|
|
376
|
+
}
|
|
377
|
+
if (input.description !== undefined) {
|
|
378
|
+
updates.push("description = ?");
|
|
379
|
+
params.push(input.description);
|
|
380
|
+
}
|
|
381
|
+
if (input.tags !== undefined) {
|
|
382
|
+
updates.push("tags = ?");
|
|
383
|
+
params.push(JSON.stringify(input.tags));
|
|
384
|
+
}
|
|
385
|
+
if (input.is_template !== undefined) {
|
|
386
|
+
updates.push("is_template = ?");
|
|
387
|
+
params.push(input.is_template ? 1 : 0);
|
|
388
|
+
}
|
|
389
|
+
if (input.synced_at !== undefined) {
|
|
390
|
+
updates.push("synced_at = ?");
|
|
391
|
+
params.push(input.synced_at);
|
|
392
|
+
}
|
|
393
|
+
params.push(existing.id);
|
|
394
|
+
d.run(`UPDATE configs SET ${updates.join(", ")} WHERE id = ?`, params);
|
|
395
|
+
return getConfigById(existing.id, d);
|
|
396
|
+
}
|
|
397
|
+
function deleteConfig(idOrSlug, db) {
|
|
398
|
+
const d = db || getDatabase();
|
|
399
|
+
const existing = getConfig(idOrSlug, d);
|
|
400
|
+
d.run("DELETE FROM configs WHERE id = ?", [existing.id]);
|
|
401
|
+
}
|
|
402
|
+
function getConfigStats(db) {
|
|
403
|
+
const d = db || getDatabase();
|
|
404
|
+
const rows = d.query("SELECT category, COUNT(*) as count FROM configs GROUP BY category").all();
|
|
405
|
+
const stats = { total: 0 };
|
|
406
|
+
for (const row of rows) {
|
|
407
|
+
stats[row.category] = row.count;
|
|
408
|
+
stats["total"] = (stats["total"] || 0) + row.count;
|
|
409
|
+
}
|
|
410
|
+
return stats;
|
|
411
|
+
}
|
|
412
|
+
// src/db/snapshots.ts
|
|
413
|
+
function createSnapshot(configId, content, version, db) {
|
|
414
|
+
const d = db || getDatabase();
|
|
415
|
+
const id = uuid();
|
|
416
|
+
const ts = now();
|
|
417
|
+
d.run("INSERT INTO config_snapshots (id, config_id, content, version, created_at) VALUES (?, ?, ?, ?, ?)", [id, configId, content, version, ts]);
|
|
418
|
+
return { id, config_id: configId, content, version, created_at: ts };
|
|
419
|
+
}
|
|
420
|
+
function listSnapshots(configId, db) {
|
|
421
|
+
const d = db || getDatabase();
|
|
422
|
+
return d.query("SELECT * FROM config_snapshots WHERE config_id = ? ORDER BY version DESC").all(configId);
|
|
423
|
+
}
|
|
424
|
+
function getSnapshot(id, db) {
|
|
425
|
+
const d = db || getDatabase();
|
|
426
|
+
return d.query("SELECT * FROM config_snapshots WHERE id = ?").get(id);
|
|
427
|
+
}
|
|
428
|
+
function getSnapshotByVersion(configId, version, db) {
|
|
429
|
+
const d = db || getDatabase();
|
|
430
|
+
return d.query("SELECT * FROM config_snapshots WHERE config_id = ? AND version = ?").get(configId, version);
|
|
431
|
+
}
|
|
432
|
+
function pruneSnapshots(configId, keep = 10, db) {
|
|
433
|
+
const d = db || getDatabase();
|
|
434
|
+
const result = d.run(`DELETE FROM config_snapshots WHERE config_id = ? AND id NOT IN (
|
|
435
|
+
SELECT id FROM config_snapshots WHERE config_id = ? ORDER BY version DESC LIMIT ?
|
|
436
|
+
)`, [configId, configId, keep]);
|
|
437
|
+
return result.changes;
|
|
438
|
+
}
|
|
439
|
+
// src/lib/machine.ts
|
|
440
|
+
import { arch as currentArch, homedir, hostname as currentHostname, type as currentOsType } from "os";
|
|
441
|
+
import { existsSync as existsSync2 } from "fs";
|
|
442
|
+
import { join as join2 } from "path";
|
|
443
|
+
|
|
444
|
+
// src/lib/template.ts
|
|
445
|
+
var VAR_PATTERN = /\{\{([A-Z0-9_]+)(?::([^}]*))?\}\}/g;
|
|
446
|
+
function parseTemplateVars(content) {
|
|
447
|
+
const names = new Set;
|
|
448
|
+
let match;
|
|
449
|
+
VAR_PATTERN.lastIndex = 0;
|
|
450
|
+
while ((match = VAR_PATTERN.exec(content)) !== null) {
|
|
451
|
+
names.add(match[1]);
|
|
452
|
+
}
|
|
453
|
+
return Array.from(names);
|
|
454
|
+
}
|
|
455
|
+
function extractTemplateVars(content) {
|
|
456
|
+
const vars = new Map;
|
|
457
|
+
let match;
|
|
458
|
+
VAR_PATTERN.lastIndex = 0;
|
|
459
|
+
while ((match = VAR_PATTERN.exec(content)) !== null) {
|
|
460
|
+
const name = match[1];
|
|
461
|
+
const description = match[2] ?? null;
|
|
462
|
+
if (!vars.has(name)) {
|
|
463
|
+
vars.set(name, { name, description, required: true });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return Array.from(vars.values());
|
|
467
|
+
}
|
|
468
|
+
function renderTemplate(content, vars) {
|
|
469
|
+
const missing = [];
|
|
470
|
+
VAR_PATTERN.lastIndex = 0;
|
|
471
|
+
let match;
|
|
472
|
+
while ((match = VAR_PATTERN.exec(content)) !== null) {
|
|
473
|
+
const name = match[1];
|
|
474
|
+
if (!(name in vars))
|
|
475
|
+
missing.push(name);
|
|
476
|
+
}
|
|
477
|
+
if (missing.length > 0) {
|
|
478
|
+
throw new TemplateRenderError(`Missing required template variables: ${missing.join(", ")}`);
|
|
479
|
+
}
|
|
480
|
+
VAR_PATTERN.lastIndex = 0;
|
|
481
|
+
return content.replace(VAR_PATTERN, (_match, name) => vars[name] ?? "");
|
|
482
|
+
}
|
|
483
|
+
function isTemplate(content) {
|
|
484
|
+
VAR_PATTERN.lastIndex = 0;
|
|
485
|
+
return VAR_PATTERN.test(content);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// src/lib/machine.ts
|
|
489
|
+
var BREW_BUN_PATH = "/opt/homebrew/bin/bun";
|
|
490
|
+
function normalizeOsFamily(os) {
|
|
491
|
+
const value = (os ?? "").trim().toLowerCase();
|
|
492
|
+
if (value === "darwin" || value === "macos" || value === "mac" || value === "osx")
|
|
493
|
+
return "macos";
|
|
494
|
+
if (value === "linux")
|
|
495
|
+
return "linux";
|
|
496
|
+
if (value === "windows_nt" || value === "windows" || value === "win32")
|
|
497
|
+
return "windows";
|
|
498
|
+
return value || "unknown";
|
|
499
|
+
}
|
|
500
|
+
function detectMachineContext(overrides = {}) {
|
|
501
|
+
const homeDir = overrides.home_dir ?? process.env["CONFIGS_HOME"] ?? process.env["HOME"] ?? homedir();
|
|
502
|
+
const os = overrides.os ?? currentOsType();
|
|
503
|
+
const osFamily = normalizeOsFamily(os);
|
|
504
|
+
const bunBinDir = overrides.bun_bin_dir ?? join2(homeDir, ".bun", "bin");
|
|
505
|
+
const defaultBunPath = osFamily === "macos" && existsSync2(BREW_BUN_PATH) ? BREW_BUN_PATH : join2(bunBinDir, "bun");
|
|
506
|
+
return {
|
|
507
|
+
id: "current-machine",
|
|
508
|
+
hostname: overrides.hostname ?? currentHostname(),
|
|
509
|
+
os,
|
|
510
|
+
arch: overrides.arch ?? currentArch(),
|
|
511
|
+
last_applied_at: null,
|
|
512
|
+
created_at: "",
|
|
513
|
+
os_family: osFamily,
|
|
514
|
+
home_dir: homeDir,
|
|
515
|
+
workspace_root: overrides.workspace_root ?? join2(homeDir, osFamily === "macos" ? "Workspace" : "workspace"),
|
|
516
|
+
bun_bin_dir: bunBinDir,
|
|
517
|
+
bun_path: overrides.bun_path ?? defaultBunPath,
|
|
518
|
+
path_prefix: overrides.path_prefix ?? (osFamily === "macos" ? `${join2("/opt", "homebrew", "bin")}:${bunBinDir}` : bunBinDir)
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
function machineContextToVariables(machine) {
|
|
522
|
+
return {
|
|
523
|
+
HOSTNAME: machine.hostname,
|
|
524
|
+
OS: machine.os ?? "",
|
|
525
|
+
OS_FAMILY: machine.os_family,
|
|
526
|
+
ARCH: machine.arch ?? "",
|
|
527
|
+
HOME_DIR: machine.home_dir,
|
|
528
|
+
WORKSPACE_ROOT: machine.workspace_root,
|
|
529
|
+
BUN_BIN_DIR: machine.bun_bin_dir,
|
|
530
|
+
BUN_PATH: machine.bun_path,
|
|
531
|
+
PATH_PREFIX: machine.path_prefix
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
function resolveProfileVariables(profile, machine) {
|
|
535
|
+
const base = machineContextToVariables(machine);
|
|
536
|
+
const raw = profile?.variables ?? {};
|
|
537
|
+
const resolved = { ...base };
|
|
538
|
+
const pending = new Map(Object.entries(raw));
|
|
539
|
+
const maxPasses = Math.max(4, pending.size * 2);
|
|
540
|
+
for (let pass = 0;pass < maxPasses && pending.size > 0; pass++) {
|
|
541
|
+
let progress = false;
|
|
542
|
+
for (const [key, value] of [...pending.entries()]) {
|
|
543
|
+
try {
|
|
544
|
+
resolved[key] = isTemplate(value) ? renderTemplate(value, resolved) : value;
|
|
545
|
+
pending.delete(key);
|
|
546
|
+
progress = true;
|
|
547
|
+
} catch {}
|
|
548
|
+
}
|
|
549
|
+
if (!progress)
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
for (const [key, value] of pending.entries()) {
|
|
553
|
+
resolved[key] = value;
|
|
554
|
+
}
|
|
555
|
+
return resolved;
|
|
556
|
+
}
|
|
557
|
+
function replaceKnownPath(content, actual, placeholder) {
|
|
558
|
+
if (!actual || actual === placeholder)
|
|
559
|
+
return content;
|
|
560
|
+
return content.split(actual).join(placeholder);
|
|
561
|
+
}
|
|
562
|
+
function templateizeMachineContent(content, machine) {
|
|
563
|
+
const vars = machineContextToVariables(machine);
|
|
564
|
+
let next = content;
|
|
565
|
+
const replacements = [
|
|
566
|
+
[vars["WORKSPACE_ROOT"], "{{WORKSPACE_ROOT}}"],
|
|
567
|
+
[vars["BUN_PATH"], "{{BUN_PATH}}"],
|
|
568
|
+
[vars["BUN_BIN_DIR"], "{{BUN_BIN_DIR}}"],
|
|
569
|
+
[vars["PATH_PREFIX"], "{{PATH_PREFIX}}"],
|
|
570
|
+
[vars["HOME_DIR"], "{{HOME_DIR}}"]
|
|
571
|
+
];
|
|
572
|
+
for (const [actual, placeholder] of replacements) {
|
|
573
|
+
next = replaceKnownPath(next, actual, placeholder);
|
|
574
|
+
}
|
|
575
|
+
next = next.replace(/~\/workspace/g, "{{WORKSPACE_ROOT}}").replace(/~\/Workspace/g, "{{WORKSPACE_ROOT}}").replace(/\/opt\/homebrew\/bin\/bun/g, "{{BUN_PATH}}");
|
|
576
|
+
return { content: next, changed: next !== content };
|
|
577
|
+
}
|
|
578
|
+
function renderMachineAwareContent(content, variables) {
|
|
579
|
+
return isTemplate(content) ? renderTemplate(content, variables) : content;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// src/db/profiles.ts
|
|
583
|
+
function rowToProfile(row) {
|
|
584
|
+
return {
|
|
585
|
+
...row,
|
|
586
|
+
selectors: JSON.parse(row.selectors || "{}"),
|
|
587
|
+
variables: JSON.parse(row.variables || "{}")
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
function uniqueProfileSlug(name, db, excludeId) {
|
|
591
|
+
const base = slugify(name);
|
|
592
|
+
let slug = base;
|
|
593
|
+
let i = 1;
|
|
594
|
+
while (true) {
|
|
595
|
+
const existing = db.query("SELECT id FROM profiles WHERE slug = ?").get(slug);
|
|
596
|
+
if (!existing || existing.id === excludeId)
|
|
597
|
+
return slug;
|
|
598
|
+
slug = `${base}-${i++}`;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
function createProfile(input, db) {
|
|
602
|
+
const d = db || getDatabase();
|
|
603
|
+
const id = uuid();
|
|
604
|
+
const ts = now();
|
|
605
|
+
const slug = uniqueProfileSlug(input.name, d);
|
|
606
|
+
d.run("INSERT INTO profiles (id, name, slug, description, selectors, variables, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [
|
|
607
|
+
id,
|
|
608
|
+
input.name,
|
|
609
|
+
slug,
|
|
610
|
+
input.description ?? null,
|
|
611
|
+
JSON.stringify(input.selectors ?? {}),
|
|
612
|
+
JSON.stringify(input.variables ?? {}),
|
|
613
|
+
ts,
|
|
614
|
+
ts
|
|
615
|
+
]);
|
|
616
|
+
return getProfile(id, d);
|
|
617
|
+
}
|
|
618
|
+
function getProfile(idOrSlug, db) {
|
|
619
|
+
const d = db || getDatabase();
|
|
620
|
+
const row = d.query("SELECT * FROM profiles WHERE id = ? OR slug = ?").get(idOrSlug, idOrSlug);
|
|
621
|
+
if (!row)
|
|
622
|
+
throw new ProfileNotFoundError(idOrSlug);
|
|
623
|
+
return rowToProfile(row);
|
|
624
|
+
}
|
|
625
|
+
function listProfiles(db) {
|
|
626
|
+
const d = db || getDatabase();
|
|
627
|
+
return d.query("SELECT * FROM profiles ORDER BY name").all().map(rowToProfile);
|
|
628
|
+
}
|
|
629
|
+
function updateProfile(idOrSlug, input, db) {
|
|
630
|
+
const d = db || getDatabase();
|
|
631
|
+
const existing = getProfile(idOrSlug, d);
|
|
632
|
+
const ts = now();
|
|
633
|
+
const updates = ["updated_at = ?"];
|
|
634
|
+
const params = [ts];
|
|
635
|
+
if (input.name !== undefined) {
|
|
636
|
+
updates.push("name = ?", "slug = ?");
|
|
637
|
+
params.push(input.name, uniqueProfileSlug(input.name, d, existing.id));
|
|
638
|
+
}
|
|
639
|
+
if (input.description !== undefined) {
|
|
640
|
+
updates.push("description = ?");
|
|
641
|
+
params.push(input.description);
|
|
642
|
+
}
|
|
643
|
+
if (input.selectors !== undefined) {
|
|
644
|
+
updates.push("selectors = ?");
|
|
645
|
+
params.push(JSON.stringify(input.selectors));
|
|
646
|
+
}
|
|
647
|
+
if (input.variables !== undefined) {
|
|
648
|
+
updates.push("variables = ?");
|
|
649
|
+
params.push(JSON.stringify(input.variables));
|
|
650
|
+
}
|
|
651
|
+
params.push(existing.id);
|
|
652
|
+
d.run(`UPDATE profiles SET ${updates.join(", ")} WHERE id = ?`, params);
|
|
653
|
+
return getProfile(existing.id, d);
|
|
654
|
+
}
|
|
655
|
+
function deleteProfile(idOrSlug, db) {
|
|
656
|
+
const d = db || getDatabase();
|
|
657
|
+
const existing = getProfile(idOrSlug, d);
|
|
658
|
+
d.run("DELETE FROM profiles WHERE id = ?", [existing.id]);
|
|
659
|
+
}
|
|
660
|
+
function addConfigToProfile(profileIdOrSlug, configId, db) {
|
|
661
|
+
const d = db || getDatabase();
|
|
662
|
+
const profile = getProfile(profileIdOrSlug, d);
|
|
663
|
+
const maxRow = d.query("SELECT MAX(sort_order) as max_order FROM profile_configs WHERE profile_id = ?").get(profile.id);
|
|
664
|
+
const order = (maxRow?.max_order ?? -1) + 1;
|
|
665
|
+
d.run("INSERT OR IGNORE INTO profile_configs (profile_id, config_id, sort_order) VALUES (?, ?, ?)", [profile.id, configId, order]);
|
|
666
|
+
}
|
|
667
|
+
function removeConfigFromProfile(profileIdOrSlug, configId, db) {
|
|
668
|
+
const d = db || getDatabase();
|
|
669
|
+
const profile = getProfile(profileIdOrSlug, d);
|
|
670
|
+
d.run("DELETE FROM profile_configs WHERE profile_id = ? AND config_id = ?", [profile.id, configId]);
|
|
671
|
+
}
|
|
672
|
+
function getProfileConfigs(profileIdOrSlug, db) {
|
|
673
|
+
const d = db || getDatabase();
|
|
674
|
+
const profile = getProfile(profileIdOrSlug, d);
|
|
675
|
+
const rows = d.query("SELECT config_id FROM profile_configs WHERE profile_id = ? ORDER BY sort_order").all(profile.id);
|
|
676
|
+
if (rows.length === 0)
|
|
677
|
+
return [];
|
|
678
|
+
const ids = rows.map((r) => r.config_id);
|
|
679
|
+
return listConfigs(undefined, d).filter((c) => ids.includes(c.id));
|
|
680
|
+
}
|
|
681
|
+
function profileHasSelectors(profile) {
|
|
682
|
+
const selectors = profile.selectors ?? {};
|
|
683
|
+
return (selectors.os?.length ?? 0) > 0 || (selectors.arch?.length ?? 0) > 0 || (selectors.hostnames?.length ?? 0) > 0;
|
|
684
|
+
}
|
|
685
|
+
function profileMatchesMachine(profile, machine) {
|
|
686
|
+
const selectors = profile.selectors ?? {};
|
|
687
|
+
const osMatches = !selectors.os?.length || selectors.os.some((candidate) => {
|
|
688
|
+
const value = candidate.trim().toLowerCase();
|
|
689
|
+
return value === machine.os_family || value === (machine.os ?? "").trim().toLowerCase() || normalizeOsFamily(candidate) === machine.os_family;
|
|
690
|
+
});
|
|
691
|
+
const archMatches = !selectors.arch?.length || selectors.arch.some((candidate) => candidate.trim().toLowerCase() === (machine.arch ?? "").trim().toLowerCase());
|
|
692
|
+
const hostnameMatches = !selectors.hostnames?.length || selectors.hostnames.some((candidate) => candidate.trim().toLowerCase() === machine.hostname.trim().toLowerCase());
|
|
693
|
+
return osMatches && archMatches && hostnameMatches;
|
|
694
|
+
}
|
|
695
|
+
function resolveProfileForMachine(machine = detectMachineContext(), db) {
|
|
696
|
+
const profiles = listProfiles(db).filter(profileHasSelectors);
|
|
697
|
+
const matches = profiles.filter((profile) => profileMatchesMachine(profile, machine)).map((profile) => {
|
|
698
|
+
const selectors = profile.selectors;
|
|
699
|
+
const score = (selectors.hostnames?.length ? 100 : 0) + (selectors.os?.length ? 10 : 0) + (selectors.arch?.length ? 10 : 0);
|
|
700
|
+
return { profile, score };
|
|
701
|
+
}).sort((a, b) => b.score - a.score || a.profile.name.localeCompare(b.profile.name));
|
|
702
|
+
return matches[0]?.profile ?? null;
|
|
703
|
+
}
|
|
704
|
+
// src/db/machines.ts
|
|
705
|
+
import { arch, hostname, type } from "os";
|
|
706
|
+
function currentHostname2() {
|
|
707
|
+
return hostname();
|
|
708
|
+
}
|
|
709
|
+
function currentOs() {
|
|
710
|
+
return type();
|
|
711
|
+
}
|
|
712
|
+
function currentArch2() {
|
|
713
|
+
return arch();
|
|
714
|
+
}
|
|
715
|
+
function registerMachine(hostnameStr, os, archStr, db) {
|
|
716
|
+
const d = db || getDatabase();
|
|
717
|
+
const h = hostnameStr ?? currentHostname2();
|
|
718
|
+
const o = os ?? currentOs();
|
|
719
|
+
const a = archStr ?? currentArch2();
|
|
720
|
+
const existing = d.query("SELECT * FROM machines WHERE hostname = ?").get(h);
|
|
721
|
+
if (existing) {
|
|
722
|
+
if (existing.os !== o || existing.arch !== a) {
|
|
723
|
+
d.run("UPDATE machines SET os = ?, arch = ? WHERE hostname = ?", [o, a, h]);
|
|
724
|
+
return d.query("SELECT * FROM machines WHERE hostname = ?").get(h);
|
|
725
|
+
}
|
|
726
|
+
return existing;
|
|
727
|
+
}
|
|
728
|
+
const id = uuid();
|
|
729
|
+
const ts = now();
|
|
730
|
+
d.run("INSERT INTO machines (id, hostname, os, arch, last_applied_at, created_at) VALUES (?, ?, ?, ?, NULL, ?)", [id, h, o, a, ts]);
|
|
731
|
+
return d.query("SELECT * FROM machines WHERE id = ?").get(id);
|
|
732
|
+
}
|
|
733
|
+
function updateMachineApplied(hostnameStr, db) {
|
|
734
|
+
const d = db || getDatabase();
|
|
735
|
+
const h = hostnameStr ?? currentHostname2();
|
|
736
|
+
d.run("UPDATE machines SET last_applied_at = ? WHERE hostname = ?", [now(), h]);
|
|
737
|
+
}
|
|
738
|
+
function listMachines(db) {
|
|
739
|
+
const d = db || getDatabase();
|
|
740
|
+
return d.query("SELECT * FROM machines ORDER BY last_applied_at DESC NULLS LAST").all();
|
|
741
|
+
}
|
|
742
|
+
// src/db/pg-migrations.ts
|
|
743
|
+
var PG_MIGRATIONS = [
|
|
744
|
+
`CREATE TABLE IF NOT EXISTS configs (
|
|
745
|
+
id TEXT PRIMARY KEY,
|
|
746
|
+
name TEXT NOT NULL,
|
|
747
|
+
slug TEXT NOT NULL UNIQUE,
|
|
748
|
+
kind TEXT NOT NULL DEFAULT 'file',
|
|
749
|
+
category TEXT NOT NULL,
|
|
750
|
+
agent TEXT NOT NULL DEFAULT 'global',
|
|
751
|
+
target_path TEXT,
|
|
752
|
+
outputs TEXT NOT NULL DEFAULT '[]',
|
|
753
|
+
format TEXT NOT NULL DEFAULT 'text',
|
|
754
|
+
content TEXT NOT NULL DEFAULT '',
|
|
755
|
+
description TEXT,
|
|
756
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
757
|
+
is_template BOOLEAN NOT NULL DEFAULT FALSE,
|
|
758
|
+
version INTEGER NOT NULL DEFAULT 1,
|
|
759
|
+
created_at TEXT NOT NULL,
|
|
760
|
+
updated_at TEXT NOT NULL,
|
|
761
|
+
synced_at TEXT
|
|
762
|
+
)`,
|
|
763
|
+
`CREATE TABLE IF NOT EXISTS config_snapshots (
|
|
764
|
+
id TEXT PRIMARY KEY,
|
|
765
|
+
config_id TEXT NOT NULL REFERENCES configs(id) ON DELETE CASCADE,
|
|
766
|
+
content TEXT NOT NULL,
|
|
767
|
+
version INTEGER NOT NULL,
|
|
768
|
+
created_at TEXT NOT NULL
|
|
769
|
+
)`,
|
|
770
|
+
`CREATE TABLE IF NOT EXISTS profiles (
|
|
771
|
+
id TEXT PRIMARY KEY,
|
|
772
|
+
name TEXT NOT NULL,
|
|
773
|
+
slug TEXT NOT NULL UNIQUE,
|
|
774
|
+
description TEXT,
|
|
775
|
+
selectors TEXT NOT NULL DEFAULT '{}',
|
|
776
|
+
variables TEXT NOT NULL DEFAULT '{}',
|
|
777
|
+
created_at TEXT NOT NULL,
|
|
778
|
+
updated_at TEXT NOT NULL
|
|
779
|
+
)`,
|
|
780
|
+
`CREATE TABLE IF NOT EXISTS profile_configs (
|
|
781
|
+
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
782
|
+
config_id TEXT NOT NULL REFERENCES configs(id) ON DELETE CASCADE,
|
|
783
|
+
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
784
|
+
PRIMARY KEY (profile_id, config_id)
|
|
785
|
+
)`,
|
|
786
|
+
`CREATE TABLE IF NOT EXISTS machines (
|
|
787
|
+
id TEXT PRIMARY KEY,
|
|
788
|
+
hostname TEXT NOT NULL UNIQUE,
|
|
789
|
+
os TEXT,
|
|
790
|
+
arch TEXT,
|
|
791
|
+
last_applied_at TEXT,
|
|
792
|
+
created_at TEXT NOT NULL
|
|
793
|
+
)`,
|
|
794
|
+
`CREATE TABLE IF NOT EXISTS feedback (
|
|
795
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
796
|
+
message TEXT NOT NULL,
|
|
797
|
+
email TEXT,
|
|
798
|
+
category TEXT DEFAULT 'general',
|
|
799
|
+
version TEXT,
|
|
800
|
+
machine_id TEXT,
|
|
801
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text
|
|
802
|
+
)`,
|
|
803
|
+
`ALTER TABLE configs ADD COLUMN IF NOT EXISTS outputs TEXT NOT NULL DEFAULT '[]'`
|
|
804
|
+
];
|
|
805
|
+
|
|
806
|
+
// src/db/remote-storage.ts
|
|
807
|
+
import pg from "pg";
|
|
808
|
+
var DISABLED_SSL_MODE = "disable";
|
|
809
|
+
function translatePlaceholders(sql) {
|
|
810
|
+
let index = 0;
|
|
811
|
+
return sql.replace(/\?/g, () => `$${++index}`);
|
|
812
|
+
}
|
|
813
|
+
function normalizeParams(params) {
|
|
814
|
+
const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
|
|
815
|
+
return flat.map((value) => value === undefined ? null : value);
|
|
816
|
+
}
|
|
817
|
+
function normalizeHost(hostname2) {
|
|
818
|
+
const stripped = hostname2.replace(/^\[/, "").replace(/\]$/, "");
|
|
819
|
+
try {
|
|
820
|
+
return decodeURIComponent(stripped).toLowerCase();
|
|
821
|
+
} catch {
|
|
822
|
+
return stripped.toLowerCase();
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
function isLocalPostgresHost(hostname2) {
|
|
826
|
+
const host = normalizeHost(hostname2);
|
|
827
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "" || host.startsWith("/");
|
|
828
|
+
}
|
|
829
|
+
function effectivePgHost(url) {
|
|
830
|
+
const hosts = url.searchParams.getAll("host");
|
|
831
|
+
const finalHost = hosts.length > 0 ? hosts[hosts.length - 1] : null;
|
|
832
|
+
return finalHost?.trim() ? finalHost : url.hostname;
|
|
833
|
+
}
|
|
834
|
+
function buildPgPoolConfig(connectionString) {
|
|
835
|
+
let url;
|
|
836
|
+
try {
|
|
837
|
+
url = new URL(connectionString);
|
|
838
|
+
} catch {
|
|
839
|
+
throw new Error("Invalid PostgreSQL connection string");
|
|
840
|
+
}
|
|
841
|
+
const sslMode = url.searchParams.get("sslmode")?.trim().toLowerCase();
|
|
842
|
+
const sslValue = url.searchParams.get("ssl")?.trim().toLowerCase();
|
|
843
|
+
const isLocal = isLocalPostgresHost(effectivePgHost(url));
|
|
844
|
+
const hasDisabledSsl = sslMode === DISABLED_SSL_MODE || sslValue === "false";
|
|
845
|
+
if (!isLocal && hasDisabledSsl) {
|
|
846
|
+
throw new Error("Refusing remote PostgreSQL connection with TLS disabled");
|
|
847
|
+
}
|
|
848
|
+
const shouldUseSsl = !isLocal || sslMode === "require" || sslMode === "verify-ca" || sslMode === "verify-full" || sslValue === "true";
|
|
849
|
+
url.searchParams.delete("sslmode");
|
|
850
|
+
url.searchParams.delete("ssl");
|
|
851
|
+
return {
|
|
852
|
+
connectionString: url.toString(),
|
|
853
|
+
ssl: shouldUseSsl ? { rejectUnauthorized: true } : undefined
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
class PgAdapterAsync {
|
|
858
|
+
pool;
|
|
859
|
+
constructor(connectionString) {
|
|
860
|
+
this.pool = new pg.Pool(buildPgPoolConfig(connectionString));
|
|
861
|
+
}
|
|
862
|
+
async run(sql, ...params) {
|
|
863
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
|
|
864
|
+
return { changes: result.rowCount ?? 0 };
|
|
865
|
+
}
|
|
866
|
+
async all(sql, ...params) {
|
|
867
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
|
|
868
|
+
return result.rows;
|
|
869
|
+
}
|
|
870
|
+
async close() {
|
|
871
|
+
await this.pool.end();
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// src/db/storage-sync.ts
|
|
876
|
+
var STORAGE_TABLES = ["configs", "config_snapshots", "profiles", "profile_configs", "machines", "feedback"];
|
|
877
|
+
var CONFIGS_STORAGE_TABLES = STORAGE_TABLES;
|
|
878
|
+
var PRIMARY_KEYS = {
|
|
879
|
+
configs: ["id"],
|
|
880
|
+
config_snapshots: ["id"],
|
|
881
|
+
profiles: ["id"],
|
|
882
|
+
profile_configs: ["profile_id", "config_id"],
|
|
883
|
+
machines: ["id"],
|
|
884
|
+
feedback: ["id"]
|
|
885
|
+
};
|
|
886
|
+
var CONFIGS_STORAGE_ENV = "HASNA_CONFIGS_DATABASE_URL";
|
|
887
|
+
var CONFIGS_STORAGE_FALLBACK_ENV = "CONFIGS_DATABASE_URL";
|
|
888
|
+
var CONFIGS_STORAGE_MODE_ENV = "HASNA_CONFIGS_STORAGE_MODE";
|
|
889
|
+
var CONFIGS_STORAGE_MODE_FALLBACK_ENV = "CONFIGS_STORAGE_MODE";
|
|
890
|
+
var STORAGE_DATABASE_ENV = [CONFIGS_STORAGE_ENV, CONFIGS_STORAGE_FALLBACK_ENV];
|
|
891
|
+
var STORAGE_MODE_ENV = [CONFIGS_STORAGE_MODE_ENV, CONFIGS_STORAGE_MODE_FALLBACK_ENV];
|
|
892
|
+
function firstEnv(names) {
|
|
893
|
+
for (const name of names) {
|
|
894
|
+
const value = process.env[name];
|
|
895
|
+
if (value)
|
|
896
|
+
return value;
|
|
897
|
+
}
|
|
898
|
+
return null;
|
|
899
|
+
}
|
|
900
|
+
function normalizeStorageMode(value) {
|
|
901
|
+
const normalized = value?.trim().toLowerCase();
|
|
902
|
+
if (normalized === "local" || normalized === "hybrid" || normalized === "remote")
|
|
903
|
+
return normalized;
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
function getStorageDatabaseUrl() {
|
|
907
|
+
return firstEnv(STORAGE_DATABASE_ENV);
|
|
908
|
+
}
|
|
909
|
+
function getStorageDatabaseEnvName() {
|
|
910
|
+
for (const name of STORAGE_DATABASE_ENV) {
|
|
911
|
+
if (process.env[name])
|
|
912
|
+
return name;
|
|
913
|
+
}
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
function getStorageMode() {
|
|
917
|
+
const mode = normalizeStorageMode(firstEnv(STORAGE_MODE_ENV));
|
|
918
|
+
if (mode)
|
|
919
|
+
return mode;
|
|
920
|
+
return getStorageDatabaseUrl() ? "hybrid" : "local";
|
|
921
|
+
}
|
|
922
|
+
async function getStoragePg() {
|
|
923
|
+
const url = getStorageDatabaseUrl();
|
|
924
|
+
if (!url)
|
|
925
|
+
throw new Error("Missing HASNA_CONFIGS_DATABASE_URL or CONFIGS_DATABASE_URL");
|
|
926
|
+
return new PgAdapterAsync(url);
|
|
927
|
+
}
|
|
928
|
+
async function runStorageMigrations(remote) {
|
|
929
|
+
await remote.run("CREATE EXTENSION IF NOT EXISTS pgcrypto");
|
|
930
|
+
for (const sql of PG_MIGRATIONS)
|
|
931
|
+
await remote.run(sql);
|
|
932
|
+
}
|
|
933
|
+
async function storagePush(options) {
|
|
934
|
+
const remote = await getStoragePg();
|
|
935
|
+
const db = getDatabase();
|
|
936
|
+
try {
|
|
937
|
+
await runStorageMigrations(remote);
|
|
938
|
+
const results = [];
|
|
939
|
+
for (const table of resolveTables(options?.tables))
|
|
940
|
+
results.push(await pushTable(db, remote, table));
|
|
941
|
+
recordSyncMeta(db, "push", results);
|
|
942
|
+
return results;
|
|
943
|
+
} finally {
|
|
944
|
+
await remote.close();
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
async function storagePull(options) {
|
|
948
|
+
const remote = await getStoragePg();
|
|
949
|
+
const db = getDatabase();
|
|
950
|
+
try {
|
|
951
|
+
await runStorageMigrations(remote);
|
|
952
|
+
const results = [];
|
|
953
|
+
for (const table of resolveTables(options?.tables))
|
|
954
|
+
results.push(await pullTable(remote, db, table));
|
|
955
|
+
recordSyncMeta(db, "pull", results);
|
|
956
|
+
return results;
|
|
957
|
+
} finally {
|
|
958
|
+
await remote.close();
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
async function storageSync(options) {
|
|
962
|
+
const pull = await storagePull(options);
|
|
963
|
+
const push = await storagePush(options);
|
|
964
|
+
return { pull, push };
|
|
965
|
+
}
|
|
966
|
+
function getStorageSyncMetaAll() {
|
|
967
|
+
const db = getDatabase();
|
|
968
|
+
ensureSyncMetaTable(db);
|
|
969
|
+
return db.query("SELECT table_name, last_synced_at, direction FROM _configs_sync_meta ORDER BY table_name, direction").all();
|
|
970
|
+
}
|
|
971
|
+
function getSyncMetaAll() {
|
|
972
|
+
return getStorageSyncMetaAll();
|
|
973
|
+
}
|
|
974
|
+
function getStorageStatus() {
|
|
975
|
+
return {
|
|
976
|
+
configured: Boolean(getStorageDatabaseUrl()),
|
|
977
|
+
mode: getStorageMode(),
|
|
978
|
+
env: STORAGE_DATABASE_ENV,
|
|
979
|
+
service: "configs",
|
|
980
|
+
tables: STORAGE_TABLES,
|
|
981
|
+
sync: getStorageSyncMetaAll()
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
function resolveTables(tables) {
|
|
985
|
+
if (!tables || tables.length === 0)
|
|
986
|
+
return [...STORAGE_TABLES];
|
|
987
|
+
const allowed = new Set(STORAGE_TABLES);
|
|
988
|
+
const requested = tables.map((table) => table.trim()).filter(Boolean);
|
|
989
|
+
const invalid = requested.filter((table) => !allowed.has(table));
|
|
990
|
+
if (invalid.length > 0)
|
|
991
|
+
throw new Error(`Unknown configs sync table(s): ${invalid.join(", ")}`);
|
|
992
|
+
return requested;
|
|
993
|
+
}
|
|
994
|
+
async function pushTable(db, remote, table) {
|
|
995
|
+
const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
|
|
996
|
+
try {
|
|
997
|
+
const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all();
|
|
998
|
+
result.rowsRead = rows.length;
|
|
999
|
+
if (rows.length === 0)
|
|
1000
|
+
return result;
|
|
1001
|
+
const columns = await filterRemoteColumns(remote, table, Object.keys(rows[0]));
|
|
1002
|
+
result.rowsWritten = await upsertPg(remote, table, columns, rows);
|
|
1003
|
+
} catch (error) {
|
|
1004
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
1005
|
+
}
|
|
1006
|
+
return result;
|
|
1007
|
+
}
|
|
1008
|
+
async function pullTable(remote, db, table) {
|
|
1009
|
+
const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
|
|
1010
|
+
try {
|
|
1011
|
+
const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`);
|
|
1012
|
+
result.rowsRead = rows.length;
|
|
1013
|
+
if (rows.length === 0)
|
|
1014
|
+
return result;
|
|
1015
|
+
const columns = filterLocalColumns(db, table, Object.keys(rows[0]));
|
|
1016
|
+
result.rowsWritten = upsertSqlite(db, table, columns, rows);
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
1019
|
+
}
|
|
1020
|
+
return result;
|
|
1021
|
+
}
|
|
1022
|
+
async function filterRemoteColumns(remote, table, columns) {
|
|
1023
|
+
const rows = await remote.all("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?", table);
|
|
1024
|
+
if (rows.length === 0)
|
|
1025
|
+
return columns;
|
|
1026
|
+
const allowed = new Set(rows.map((row) => row.column_name));
|
|
1027
|
+
return columns.filter((column) => allowed.has(column));
|
|
1028
|
+
}
|
|
1029
|
+
function filterLocalColumns(db, table, columns) {
|
|
1030
|
+
const rows = db.query(`PRAGMA table_info(${quoteIdent(table)})`).all();
|
|
1031
|
+
const allowed = new Set(rows.map((row) => row.name));
|
|
1032
|
+
return columns.filter((column) => allowed.has(column));
|
|
1033
|
+
}
|
|
1034
|
+
async function upsertPg(remote, table, columns, rows) {
|
|
1035
|
+
if (columns.length === 0)
|
|
1036
|
+
return 0;
|
|
1037
|
+
const primaryKeys = PRIMARY_KEYS[table];
|
|
1038
|
+
const columnList = columns.map(quoteIdent).join(", ");
|
|
1039
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
1040
|
+
const keyList = primaryKeys.map(quoteIdent).join(", ");
|
|
1041
|
+
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
1042
|
+
const fallbackKey = primaryKeys[0];
|
|
1043
|
+
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = EXCLUDED.${quoteIdent(fallbackKey)}`;
|
|
1044
|
+
for (const row of rows) {
|
|
1045
|
+
await remote.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders}) ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`, ...columns.map((column) => row[column] ?? null));
|
|
1046
|
+
}
|
|
1047
|
+
return rows.length;
|
|
1048
|
+
}
|
|
1049
|
+
function upsertSqlite(db, table, columns, rows) {
|
|
1050
|
+
if (columns.length === 0)
|
|
1051
|
+
return 0;
|
|
1052
|
+
const primaryKeys = PRIMARY_KEYS[table];
|
|
1053
|
+
const columnList = columns.map(quoteIdent).join(", ");
|
|
1054
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
1055
|
+
const keyList = primaryKeys.map(quoteIdent).join(", ");
|
|
1056
|
+
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
1057
|
+
const fallbackKey = primaryKeys[0];
|
|
1058
|
+
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = excluded.${quoteIdent(fallbackKey)}`;
|
|
1059
|
+
const statement = db.prepare(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders}) ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`);
|
|
1060
|
+
db.transaction((batch) => {
|
|
1061
|
+
for (const row of batch)
|
|
1062
|
+
statement.run(...columns.map((column) => coerceForSqlite(row[column])));
|
|
1063
|
+
})(rows);
|
|
1064
|
+
return rows.length;
|
|
1065
|
+
}
|
|
1066
|
+
function recordSyncMeta(db, direction, results) {
|
|
1067
|
+
ensureSyncMetaTable(db);
|
|
1068
|
+
const now2 = new Date().toISOString();
|
|
1069
|
+
const statement = db.prepare("INSERT INTO _configs_sync_meta (table_name, last_synced_at, direction) VALUES (?, ?, ?) ON CONFLICT(table_name, direction) DO UPDATE SET last_synced_at = excluded.last_synced_at");
|
|
1070
|
+
for (const result of results) {
|
|
1071
|
+
if (result.errors.length > 0)
|
|
1072
|
+
continue;
|
|
1073
|
+
statement.run(result.table, now2, direction);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
function ensureSyncMetaTable(db) {
|
|
1077
|
+
db.exec("CREATE TABLE IF NOT EXISTS _configs_sync_meta (table_name TEXT NOT NULL, last_synced_at TEXT, direction TEXT NOT NULL CHECK(direction IN ('push', 'pull')), PRIMARY KEY (table_name, direction))");
|
|
1078
|
+
}
|
|
1079
|
+
function quoteIdent(identifier) {
|
|
1080
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
1081
|
+
}
|
|
1082
|
+
function coerceForSqlite(value) {
|
|
1083
|
+
if (value === undefined || value === null)
|
|
1084
|
+
return null;
|
|
1085
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean")
|
|
1086
|
+
return value;
|
|
1087
|
+
if (value instanceof Date)
|
|
1088
|
+
return value.toISOString();
|
|
1089
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array)
|
|
1090
|
+
return value;
|
|
1091
|
+
if (typeof value === "object")
|
|
1092
|
+
return JSON.stringify(value);
|
|
1093
|
+
return String(value);
|
|
1094
|
+
}
|
|
1095
|
+
// src/status.ts
|
|
1096
|
+
import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
|
|
1097
|
+
|
|
1098
|
+
// src/lib/apply.ts
|
|
1099
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync, realpathSync, writeFileSync } from "fs";
|
|
1100
|
+
import { basename as basename2, dirname, resolve } from "path";
|
|
1101
|
+
import { homedir as homedir2 } from "os";
|
|
1102
|
+
|
|
1103
|
+
// src/lib/transforms.ts
|
|
1104
|
+
import { basename, extname } from "path";
|
|
1105
|
+
function ensureTrailingNewline(content) {
|
|
1106
|
+
return content.endsWith(`
|
|
1107
|
+
`) ? content : `${content}
|
|
1108
|
+
`;
|
|
1109
|
+
}
|
|
1110
|
+
function yamlQuote(value) {
|
|
1111
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
1112
|
+
}
|
|
1113
|
+
function headingLevel(line) {
|
|
1114
|
+
const m = line.match(/^(#{1,6})\s+/);
|
|
1115
|
+
return m ? m[1].length : null;
|
|
1116
|
+
}
|
|
1117
|
+
function stripClaudeOnlySections(content) {
|
|
1118
|
+
const withoutMarkedBlocks = content.replace(/<!--\s*claude-only:start\s*-->[\s\S]*?<!--\s*claude-only:end\s*-->/gi, "");
|
|
1119
|
+
const lines = withoutMarkedBlocks.split(`
|
|
1120
|
+
`);
|
|
1121
|
+
const kept = [];
|
|
1122
|
+
let droppingUntilLevel = null;
|
|
1123
|
+
for (const line of lines) {
|
|
1124
|
+
const level = headingLevel(line);
|
|
1125
|
+
if (level !== null && droppingUntilLevel !== null && level <= droppingUntilLevel) {
|
|
1126
|
+
droppingUntilLevel = null;
|
|
1127
|
+
}
|
|
1128
|
+
if (droppingUntilLevel !== null)
|
|
1129
|
+
continue;
|
|
1130
|
+
if (/^#{1,6}\s+.*claude(?:\s+code)?[-\s]+only\b/i.test(line)) {
|
|
1131
|
+
droppingUntilLevel = level ?? 1;
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
kept.push(line);
|
|
1135
|
+
}
|
|
1136
|
+
return kept.join(`
|
|
1137
|
+
`).replace(/\n{3,}/g, `
|
|
1138
|
+
|
|
1139
|
+
`).trim();
|
|
1140
|
+
}
|
|
1141
|
+
function isClaudeRuleConfig(source, candidate) {
|
|
1142
|
+
if (candidate.id === source.id)
|
|
1143
|
+
return false;
|
|
1144
|
+
if (candidate.agent !== "claude" || candidate.category !== "rules")
|
|
1145
|
+
return false;
|
|
1146
|
+
return !!candidate.target_path?.includes("/rules/");
|
|
1147
|
+
}
|
|
1148
|
+
function ruleLabel(config) {
|
|
1149
|
+
const file = config.target_path ? basename(config.target_path) : config.name;
|
|
1150
|
+
return file.replace(/\.(md|mdc|markdown)$/i, "");
|
|
1151
|
+
}
|
|
1152
|
+
function claudeRules(source, context) {
|
|
1153
|
+
return (context.configs ?? []).filter((config) => isClaudeRuleConfig(source, config)).sort((a, b) => (a.target_path ?? a.name).localeCompare(b.target_path ?? b.name));
|
|
1154
|
+
}
|
|
1155
|
+
function flattenWithRules(source, context) {
|
|
1156
|
+
const parts = [stripClaudeOnlySections(source.content)];
|
|
1157
|
+
const rules = claudeRules(source, context).map((rule) => ({ rule, content: stripClaudeOnlySections(rule.content) })).filter(({ content }) => Boolean(content)).map(({ rule, content }) => `### ${ruleLabel(rule)}
|
|
1158
|
+
|
|
1159
|
+
${content}`);
|
|
1160
|
+
if (rules.length > 0) {
|
|
1161
|
+
parts.push(`## Rules
|
|
1162
|
+
|
|
1163
|
+
${rules.join(`
|
|
1164
|
+
|
|
1165
|
+
`)}`);
|
|
1166
|
+
}
|
|
1167
|
+
return ensureTrailingNewline(parts.filter(Boolean).join(`
|
|
1168
|
+
|
|
1169
|
+
`));
|
|
1170
|
+
}
|
|
1171
|
+
function buildCodexAgentsMd(source, context = {}) {
|
|
1172
|
+
return flattenWithRules(source, context);
|
|
1173
|
+
}
|
|
1174
|
+
function buildOpenCodeAgentsMd(source, context = {}) {
|
|
1175
|
+
return flattenWithRules(source, context);
|
|
1176
|
+
}
|
|
1177
|
+
function buildCursorMdc(source) {
|
|
1178
|
+
const stem = source.target_path ? basename(source.target_path, extname(source.target_path)) : source.slug;
|
|
1179
|
+
const description = source.name || stem;
|
|
1180
|
+
return ensureTrailingNewline([
|
|
1181
|
+
"---",
|
|
1182
|
+
`description: ${yamlQuote(description)}`,
|
|
1183
|
+
'globs: ["**/*"]',
|
|
1184
|
+
"alwaysApply: true",
|
|
1185
|
+
"---",
|
|
1186
|
+
"",
|
|
1187
|
+
stripClaudeOnlySections(source.content)
|
|
1188
|
+
].join(`
|
|
1189
|
+
`));
|
|
1190
|
+
}
|
|
1191
|
+
function transformSkillContent(content) {
|
|
1192
|
+
return ensureTrailingNewline(content.split(`
|
|
1193
|
+
`).filter((line) => !/^\s*user_invocable\s*:/i.test(line)).join(`
|
|
1194
|
+
`).replace(/\bAgent tool\b/g, "delegate through the available MCP or agent orchestration tools").replace(/\bTodoWrite\b/g, "track tasks with the agent's native task mechanism").replace(/\bRead\/Edit\b/g, "read and edit files with the available filesystem tools").replace(/\bRead\b/g, "read files with the available filesystem tools").replace(/\bEdit\b/g, "edit files with the available filesystem tools").replace(/\bBash\b/g, "run shell commands with the available terminal tool").trim());
|
|
1195
|
+
}
|
|
1196
|
+
function applyTransform(source, output, context = {}) {
|
|
1197
|
+
switch (output.transform) {
|
|
1198
|
+
case "passthrough":
|
|
1199
|
+
case "claude-passthrough":
|
|
1200
|
+
return ensureTrailingNewline(source.content);
|
|
1201
|
+
case "codex-flat":
|
|
1202
|
+
return buildCodexAgentsMd(source, context);
|
|
1203
|
+
case "opencode-flat":
|
|
1204
|
+
return buildOpenCodeAgentsMd(source, context);
|
|
1205
|
+
case "cursor-mdc":
|
|
1206
|
+
return buildCursorMdc(source);
|
|
1207
|
+
case "skill-neutral":
|
|
1208
|
+
return transformSkillContent(source.content);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// src/lib/apply.ts
|
|
1213
|
+
function getConfigHome() {
|
|
1214
|
+
return process.env["CONFIGS_HOME"] || process.env["HOME"] || homedir2();
|
|
1215
|
+
}
|
|
1216
|
+
function expandPath(p) {
|
|
1217
|
+
if (p.startsWith("~/")) {
|
|
1218
|
+
return resolve(getConfigHome(), p.slice(2));
|
|
1219
|
+
}
|
|
1220
|
+
return resolve(p);
|
|
1221
|
+
}
|
|
1222
|
+
function normalizeTargetPath(p) {
|
|
1223
|
+
const expanded = expandPath(p);
|
|
1224
|
+
try {
|
|
1225
|
+
return realpathSync(expanded);
|
|
1226
|
+
} catch {
|
|
1227
|
+
let current = expanded;
|
|
1228
|
+
const missingSegments = [];
|
|
1229
|
+
while (true) {
|
|
1230
|
+
if (existsSync3(current)) {
|
|
1231
|
+
try {
|
|
1232
|
+
return resolve(realpathSync(current), ...missingSegments);
|
|
1233
|
+
} catch {
|
|
1234
|
+
return expanded;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
const parent = dirname(current);
|
|
1238
|
+
const name = basename2(current);
|
|
1239
|
+
if (parent === current)
|
|
1240
|
+
return expanded;
|
|
1241
|
+
missingSegments.unshift(name);
|
|
1242
|
+
current = parent;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
async function writeConfigResult(config, targetPath, content, opts, meta = {}) {
|
|
1247
|
+
const renderedTargetPath = opts.vars ? renderMachineAwareContent(targetPath, opts.vars) : targetPath;
|
|
1248
|
+
const renderedContent = opts.vars ? renderMachineAwareContent(content, opts.vars) : content;
|
|
1249
|
+
const path = expandPath(renderedTargetPath);
|
|
1250
|
+
const previousContent = existsSync3(path) ? readFileSync(path, "utf-8") : null;
|
|
1251
|
+
const changed = previousContent !== renderedContent;
|
|
1252
|
+
if (!opts.dryRun) {
|
|
1253
|
+
const dir = dirname(path);
|
|
1254
|
+
if (!existsSync3(dir)) {
|
|
1255
|
+
mkdirSync2(dir, { recursive: true });
|
|
1256
|
+
}
|
|
1257
|
+
if (previousContent !== null && changed) {
|
|
1258
|
+
const db = opts.db || getDatabase();
|
|
1259
|
+
createSnapshot(config.id, previousContent, config.version, db);
|
|
1260
|
+
}
|
|
1261
|
+
writeFileSync(path, renderedContent, "utf-8");
|
|
1262
|
+
}
|
|
1263
|
+
return {
|
|
1264
|
+
config_id: config.id,
|
|
1265
|
+
path,
|
|
1266
|
+
previous_content: previousContent,
|
|
1267
|
+
new_content: renderedContent,
|
|
1268
|
+
dry_run: opts.dryRun ?? false,
|
|
1269
|
+
changed,
|
|
1270
|
+
...meta
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
function isGeneratedOutputTarget(config, configs) {
|
|
1274
|
+
if (!config.target_path)
|
|
1275
|
+
return false;
|
|
1276
|
+
const targetPath = normalizeTargetPath(config.target_path);
|
|
1277
|
+
return configs.some((candidate) => candidate.id !== config.id && candidate.outputs.some((output) => normalizeTargetPath(output.target_path) === targetPath));
|
|
1278
|
+
}
|
|
1279
|
+
async function applyConfig(config, opts = {}) {
|
|
1280
|
+
const selectedOutputs = opts.outputAgent ? config.outputs.filter((output) => output.agent === opts.outputAgent) : config.outputs;
|
|
1281
|
+
const shouldApplyPrimary = !opts.outputAgent || config.agent === opts.outputAgent;
|
|
1282
|
+
if (config.kind === "reference" || (!config.target_path || !shouldApplyPrimary) && selectedOutputs.length === 0) {
|
|
1283
|
+
throw new ConfigApplyError(`Config "${config.name}" is a reference (kind=reference) and has no target_path \u2014 cannot apply to disk.`);
|
|
1284
|
+
}
|
|
1285
|
+
const db = opts.db || getDatabase();
|
|
1286
|
+
const contextConfigs = selectedOutputs.length > 0 || config.target_path ? listConfigs(undefined, db) : [config];
|
|
1287
|
+
if (isGeneratedOutputTarget(config, contextConfigs)) {
|
|
1288
|
+
throw new ConfigApplyError(`Config "${config.name}" targets a generated output path. Apply the canonical source config instead.`);
|
|
1289
|
+
}
|
|
1290
|
+
const outputResults = [];
|
|
1291
|
+
for (const output of selectedOutputs) {
|
|
1292
|
+
outputResults.push(await applyOutput(config, output, contextConfigs, opts));
|
|
1293
|
+
}
|
|
1294
|
+
let result;
|
|
1295
|
+
if (config.target_path && shouldApplyPrimary) {
|
|
1296
|
+
result = await writeConfigResult(config, config.target_path, config.content, opts);
|
|
1297
|
+
result.outputs = outputResults;
|
|
1298
|
+
result.changed = result.changed || outputResults.some((output) => output.changed);
|
|
1299
|
+
} else {
|
|
1300
|
+
result = {
|
|
1301
|
+
...outputResults[0],
|
|
1302
|
+
outputs: outputResults.slice(1),
|
|
1303
|
+
changed: outputResults.some((output) => output.changed)
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
if (!opts.dryRun) {
|
|
1307
|
+
updateConfig(config.id, { synced_at: now() }, db);
|
|
1308
|
+
}
|
|
1309
|
+
return result;
|
|
1310
|
+
}
|
|
1311
|
+
async function applyOutput(config, output, contextConfigs, opts) {
|
|
1312
|
+
const content = applyTransform(config, output, { configs: contextConfigs });
|
|
1313
|
+
return writeConfigResult(config, output.target_path, content, opts, {
|
|
1314
|
+
agent: output.agent,
|
|
1315
|
+
transform: output.transform
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
async function applyConfigs(configs, opts = {}) {
|
|
1319
|
+
const results = [];
|
|
1320
|
+
for (const config of configs) {
|
|
1321
|
+
if (config.kind === "reference")
|
|
1322
|
+
continue;
|
|
1323
|
+
results.push(await applyConfig(config, opts));
|
|
1324
|
+
}
|
|
1325
|
+
return results;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// src/lib/redact.ts
|
|
1329
|
+
var SECRET_KEY_PATTERN = /^(.*_?API_?KEY|.*_?TOKEN|.*_?SECRET|.*_?PASSWORD|.*_?PASSWD|.*_?CREDENTIAL|.*_?AUTH(?:_TOKEN|_KEY|ORIZATION)?|.*_?PRIVATE_?KEY|.*_?ACCESS_?KEY|.*_?CLIENT_?SECRET|.*_?SIGNING_?KEY|.*_?ENCRYPTION_?KEY|.*_AUTH_TOKEN)$/i;
|
|
1330
|
+
var VALUE_PATTERNS = [
|
|
1331
|
+
{ re: /npm_[A-Za-z0-9]{36,}/, reason: "npm token" },
|
|
1332
|
+
{ re: /gh[pousr]_[A-Za-z0-9_]{36,}/, reason: "GitHub token" },
|
|
1333
|
+
{ re: /sk-ant-[A-Za-z0-9\-_]{40,}/, reason: "Anthropic API key" },
|
|
1334
|
+
{ re: /sk-[A-Za-z0-9]{48,}/, reason: "OpenAI API key" },
|
|
1335
|
+
{ re: /xoxb-[0-9]+-[A-Za-z0-9\-]+/, reason: "Slack bot token" },
|
|
1336
|
+
{ re: /AIza[0-9A-Za-z\-_]{35}/, reason: "Google API key" },
|
|
1337
|
+
{ re: /ey[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{20,}\./, reason: "JWT token" },
|
|
1338
|
+
{ re: /AKIA[0-9A-Z]{16}/, reason: "AWS access key" }
|
|
1339
|
+
];
|
|
1340
|
+
var MIN_SECRET_VALUE_LEN = 8;
|
|
1341
|
+
function redactShell(content) {
|
|
1342
|
+
const redacted = [];
|
|
1343
|
+
const lines = content.split(`
|
|
1344
|
+
`);
|
|
1345
|
+
const out = [];
|
|
1346
|
+
for (let i = 0;i < lines.length; i++) {
|
|
1347
|
+
const line = lines[i];
|
|
1348
|
+
const m = line.match(/^(\s*(?:export\s+)?)([A-Z][A-Z0-9_]*)(\s*=\s*)(['"]?)(.+?)\4\s*$/);
|
|
1349
|
+
if (m) {
|
|
1350
|
+
const [, prefix, key, eq, quote, value] = m;
|
|
1351
|
+
if (shouldRedactKeyValue(key, value)) {
|
|
1352
|
+
const reason = reasonFor(key, value);
|
|
1353
|
+
redacted.push({ varName: key, line: i + 1, reason });
|
|
1354
|
+
out.push(`${prefix}${key}${eq}${quote}{{${key}}}${quote}`);
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
out.push(line);
|
|
1359
|
+
}
|
|
1360
|
+
return { content: out.join(`
|
|
1361
|
+
`), redacted, isTemplate: redacted.length > 0 };
|
|
1362
|
+
}
|
|
1363
|
+
function redactJson(content) {
|
|
1364
|
+
const redacted = [];
|
|
1365
|
+
const lines = content.split(`
|
|
1366
|
+
`);
|
|
1367
|
+
const out = [];
|
|
1368
|
+
for (let i = 0;i < lines.length; i++) {
|
|
1369
|
+
const line = lines[i];
|
|
1370
|
+
const m = line.match(/^(\s*"([^"]+)"\s*:\s*)"([^"]+)"(,?)(\s*)$/);
|
|
1371
|
+
if (m) {
|
|
1372
|
+
const [, prefix, key, value, comma, trail] = m;
|
|
1373
|
+
if (shouldRedactKeyValue(key, value)) {
|
|
1374
|
+
const varName = key.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
|
1375
|
+
redacted.push({ varName, line: i + 1, reason: reasonFor(key, value) });
|
|
1376
|
+
out.push(`${prefix}"{{${varName}}}"${comma}${trail}`);
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
let newLine = line;
|
|
1381
|
+
for (const { re, reason } of VALUE_PATTERNS) {
|
|
1382
|
+
newLine = newLine.replace(re, (match) => {
|
|
1383
|
+
const varName = `REDACTED_${reason.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
|
|
1384
|
+
redacted.push({ varName, line: i + 1, reason });
|
|
1385
|
+
return `{{${varName}}}`;
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
out.push(newLine);
|
|
1389
|
+
}
|
|
1390
|
+
return { content: out.join(`
|
|
1391
|
+
`), redacted, isTemplate: redacted.length > 0 };
|
|
1392
|
+
}
|
|
1393
|
+
function redactToml(content) {
|
|
1394
|
+
const redacted = [];
|
|
1395
|
+
const lines = content.split(`
|
|
1396
|
+
`);
|
|
1397
|
+
const out = [];
|
|
1398
|
+
for (let i = 0;i < lines.length; i++) {
|
|
1399
|
+
const line = lines[i];
|
|
1400
|
+
const m = line.match(/^(\s*)([a-zA-Z][a-zA-Z0-9_\-]*)(\s*=\s*)(['"]?)(.+?)\4\s*$/);
|
|
1401
|
+
if (m) {
|
|
1402
|
+
const [, indent, key, eq, quote, value] = m;
|
|
1403
|
+
if (shouldRedactKeyValue(key, value)) {
|
|
1404
|
+
const varName = key.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
|
1405
|
+
redacted.push({ varName, line: i + 1, reason: reasonFor(key, value) });
|
|
1406
|
+
out.push(`${indent}${key}${eq}${quote}{{${varName}}}${quote}`);
|
|
1407
|
+
continue;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
out.push(line);
|
|
1411
|
+
}
|
|
1412
|
+
return { content: out.join(`
|
|
1413
|
+
`), redacted, isTemplate: redacted.length > 0 };
|
|
1414
|
+
}
|
|
1415
|
+
function redactIni(content) {
|
|
1416
|
+
const redacted = [];
|
|
1417
|
+
const lines = content.split(`
|
|
1418
|
+
`);
|
|
1419
|
+
const out = [];
|
|
1420
|
+
for (let i = 0;i < lines.length; i++) {
|
|
1421
|
+
const line = lines[i];
|
|
1422
|
+
const authM = line.match(/^(\/\/[^:]+:_authToken=)(.+)$/);
|
|
1423
|
+
if (authM && !authM[2].startsWith("{{")) {
|
|
1424
|
+
redacted.push({ varName: "NPM_AUTH_TOKEN", line: i + 1, reason: "npm auth token" });
|
|
1425
|
+
out.push(`${authM[1]}{{NPM_AUTH_TOKEN}}`);
|
|
1426
|
+
continue;
|
|
1427
|
+
}
|
|
1428
|
+
const m = line.match(/^(\s*)([a-zA-Z][a-zA-Z0-9_\-]*)(\s*=\s*)(.+?)\s*$/);
|
|
1429
|
+
if (m) {
|
|
1430
|
+
const [, indent, key, eq, value] = m;
|
|
1431
|
+
if (shouldRedactKeyValue(key, value)) {
|
|
1432
|
+
const varName = key.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
|
1433
|
+
redacted.push({ varName, line: i + 1, reason: reasonFor(key, value) });
|
|
1434
|
+
out.push(`${indent}${key}${eq}{{${varName}}}`);
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
out.push(line);
|
|
1439
|
+
}
|
|
1440
|
+
return { content: out.join(`
|
|
1441
|
+
`), redacted, isTemplate: redacted.length > 0 };
|
|
1442
|
+
}
|
|
1443
|
+
function redactGeneric(content) {
|
|
1444
|
+
const redacted = [];
|
|
1445
|
+
const lines = content.split(`
|
|
1446
|
+
`);
|
|
1447
|
+
const out = [];
|
|
1448
|
+
for (let i = 0;i < lines.length; i++) {
|
|
1449
|
+
let line = lines[i];
|
|
1450
|
+
for (const { re, reason } of VALUE_PATTERNS) {
|
|
1451
|
+
line = line.replace(re, (match) => {
|
|
1452
|
+
const varName = reason.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
|
1453
|
+
redacted.push({ varName, line: i + 1, reason });
|
|
1454
|
+
return `{{${varName}}}`;
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
out.push(line);
|
|
1458
|
+
}
|
|
1459
|
+
return { content: out.join(`
|
|
1460
|
+
`), redacted, isTemplate: redacted.length > 0 };
|
|
1461
|
+
}
|
|
1462
|
+
function shouldRedactKeyValue(key, value) {
|
|
1463
|
+
if (!value || value.startsWith("{{"))
|
|
1464
|
+
return false;
|
|
1465
|
+
if (value.length < MIN_SECRET_VALUE_LEN)
|
|
1466
|
+
return false;
|
|
1467
|
+
if (/^(true|false|yes|no|on|off|null|undefined|\d+)$/i.test(value))
|
|
1468
|
+
return false;
|
|
1469
|
+
if (SECRET_KEY_PATTERN.test(key))
|
|
1470
|
+
return true;
|
|
1471
|
+
for (const { re } of VALUE_PATTERNS) {
|
|
1472
|
+
if (re.test(value))
|
|
1473
|
+
return true;
|
|
1474
|
+
}
|
|
1475
|
+
return false;
|
|
1476
|
+
}
|
|
1477
|
+
function reasonFor(key, value) {
|
|
1478
|
+
if (SECRET_KEY_PATTERN.test(key))
|
|
1479
|
+
return `secret key name: ${key}`;
|
|
1480
|
+
for (const { re, reason } of VALUE_PATTERNS) {
|
|
1481
|
+
if (re.test(value))
|
|
1482
|
+
return reason;
|
|
1483
|
+
}
|
|
1484
|
+
return "secret value pattern";
|
|
1485
|
+
}
|
|
1486
|
+
function redactContent(content, format) {
|
|
1487
|
+
switch (format) {
|
|
1488
|
+
case "shell":
|
|
1489
|
+
return redactShell(content);
|
|
1490
|
+
case "json":
|
|
1491
|
+
return redactJson(content);
|
|
1492
|
+
case "toml":
|
|
1493
|
+
return redactToml(content);
|
|
1494
|
+
case "ini":
|
|
1495
|
+
return redactIni(content);
|
|
1496
|
+
default:
|
|
1497
|
+
return redactGeneric(content);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
function scanSecrets(content, format) {
|
|
1501
|
+
const r = redactContent(content, format);
|
|
1502
|
+
return r.redacted;
|
|
1503
|
+
}
|
|
1504
|
+
function hasSecrets(content, format) {
|
|
1505
|
+
return scanSecrets(content, format).length > 0;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// src/status.ts
|
|
1509
|
+
var PACKAGE_NAME = "@hasna/instructions";
|
|
1510
|
+
var PACKAGE_VERSION = "0.3.0";
|
|
1511
|
+
function activeDatabaseEnv() {
|
|
1512
|
+
if (process.env["HASNA_CONFIGS_DB_PATH"])
|
|
1513
|
+
return "HASNA_CONFIGS_DB_PATH";
|
|
1514
|
+
if (process.env["CONFIGS_DB_PATH"])
|
|
1515
|
+
return "CONFIGS_DB_PATH";
|
|
1516
|
+
return null;
|
|
1517
|
+
}
|
|
1518
|
+
function configuredDatabaseKind() {
|
|
1519
|
+
const value = process.env["HASNA_CONFIGS_DB_PATH"] ?? process.env["CONFIGS_DB_PATH"] ?? "";
|
|
1520
|
+
return value === ":memory:" || value.startsWith("file::memory:") ? "memory" : "file";
|
|
1521
|
+
}
|
|
1522
|
+
function countBy(items, getValue) {
|
|
1523
|
+
const counts = {};
|
|
1524
|
+
for (const item of items) {
|
|
1525
|
+
const value = getValue(item);
|
|
1526
|
+
if (!value)
|
|
1527
|
+
continue;
|
|
1528
|
+
counts[value] = (counts[value] ?? 0) + 1;
|
|
1529
|
+
}
|
|
1530
|
+
return counts;
|
|
1531
|
+
}
|
|
1532
|
+
function tableCount(db, table) {
|
|
1533
|
+
try {
|
|
1534
|
+
const row = db.query(`SELECT COUNT(*) AS count FROM ${table}`).get();
|
|
1535
|
+
return Number(row?.count ?? 0);
|
|
1536
|
+
} catch {
|
|
1537
|
+
return 0;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
function getConfigsStatus(db = getDatabase()) {
|
|
1541
|
+
let databaseReachable = true;
|
|
1542
|
+
let configs = [];
|
|
1543
|
+
let categoryStats = { total: 0 };
|
|
1544
|
+
try {
|
|
1545
|
+
configs = listConfigs(undefined, db);
|
|
1546
|
+
categoryStats = getConfigStats(db);
|
|
1547
|
+
} catch {
|
|
1548
|
+
databaseReachable = false;
|
|
1549
|
+
}
|
|
1550
|
+
const fileConfigs = configs.filter((config) => config.kind === "file");
|
|
1551
|
+
let driftedTargets = 0;
|
|
1552
|
+
let missingTargets = 0;
|
|
1553
|
+
let unredactedSecretFindings = 0;
|
|
1554
|
+
let knownTargets = 0;
|
|
1555
|
+
for (const config of fileConfigs) {
|
|
1556
|
+
unredactedSecretFindings += scanSecrets(config.content, config.format).length;
|
|
1557
|
+
if (!config.target_path)
|
|
1558
|
+
continue;
|
|
1559
|
+
knownTargets += 1;
|
|
1560
|
+
const targetPath = expandPath(config.target_path);
|
|
1561
|
+
if (!existsSync4(targetPath)) {
|
|
1562
|
+
missingTargets += 1;
|
|
1563
|
+
continue;
|
|
1564
|
+
}
|
|
1565
|
+
const disk = readFileSync2(targetPath, "utf-8");
|
|
1566
|
+
const { content: redactedDisk } = redactContent(disk, config.format);
|
|
1567
|
+
if (redactedDisk !== config.content) {
|
|
1568
|
+
driftedTargets += 1;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
const profiles = databaseReachable ? listProfiles(db).length : 0;
|
|
1572
|
+
const machines = databaseReachable ? listMachines(db).length : 0;
|
|
1573
|
+
const profileLinks = databaseReachable ? tableCount(db, "profile_configs") : 0;
|
|
1574
|
+
const snapshots = databaseReachable ? tableCount(db, "config_snapshots") : 0;
|
|
1575
|
+
const byCategory = Object.fromEntries(Object.entries(categoryStats).filter(([key]) => key !== "total"));
|
|
1576
|
+
const status = databaseReachable && driftedTargets === 0 && missingTargets === 0 && unredactedSecretFindings === 0 ? "ok" : "warn";
|
|
1577
|
+
return {
|
|
1578
|
+
service: "configs",
|
|
1579
|
+
schemaVersion: "1.0",
|
|
1580
|
+
package: {
|
|
1581
|
+
name: PACKAGE_NAME,
|
|
1582
|
+
version: PACKAGE_VERSION
|
|
1583
|
+
},
|
|
1584
|
+
env: {
|
|
1585
|
+
database: {
|
|
1586
|
+
primary: "HASNA_CONFIGS_DB_PATH",
|
|
1587
|
+
fallback: "CONFIGS_DB_PATH",
|
|
1588
|
+
active: activeDatabaseEnv(),
|
|
1589
|
+
kind: configuredDatabaseKind()
|
|
1590
|
+
}
|
|
1591
|
+
},
|
|
1592
|
+
counts: {
|
|
1593
|
+
configs: {
|
|
1594
|
+
total: configs.length,
|
|
1595
|
+
file: fileConfigs.length,
|
|
1596
|
+
reference: configs.filter((config) => config.kind === "reference").length,
|
|
1597
|
+
templates: configs.filter((config) => config.is_template).length
|
|
1598
|
+
},
|
|
1599
|
+
byCategory,
|
|
1600
|
+
byAgent: countBy(configs, (config) => config.agent),
|
|
1601
|
+
byFormat: countBy(configs, (config) => config.format),
|
|
1602
|
+
profiles,
|
|
1603
|
+
profileLinks,
|
|
1604
|
+
machines,
|
|
1605
|
+
snapshots,
|
|
1606
|
+
knownTargets
|
|
1607
|
+
},
|
|
1608
|
+
health: {
|
|
1609
|
+
status,
|
|
1610
|
+
databaseReachable,
|
|
1611
|
+
driftedTargets,
|
|
1612
|
+
missingTargets,
|
|
1613
|
+
unredactedSecretFindings,
|
|
1614
|
+
hasDrift: driftedTargets > 0,
|
|
1615
|
+
hasMissingTargets: missingTargets > 0,
|
|
1616
|
+
hasUnredactedSecrets: unredactedSecretFindings > 0
|
|
1617
|
+
},
|
|
1618
|
+
safety: {
|
|
1619
|
+
includesConfigValues: false,
|
|
1620
|
+
includesPrivatePaths: false,
|
|
1621
|
+
includesHostnames: false,
|
|
1622
|
+
includesSecretValues: false,
|
|
1623
|
+
statusOutputIsMetadataOnly: true
|
|
1624
|
+
}
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
// src/lib/session-render.ts
|
|
1628
|
+
import { createHash } from "crypto";
|
|
1629
|
+
import { existsSync as existsSync5, readFileSync as readFileSync3, realpathSync as realpathSync2, statSync as statSync2 } from "fs";
|
|
1630
|
+
import { homedir as homedir3 } from "os";
|
|
1631
|
+
import { basename as basename3, dirname as dirname2, extname as extname2, isAbsolute, join as join3, parse, posix, relative, resolve as resolve2 } from "path";
|
|
1632
|
+
var CODEWITH_NATIVE_IMPORTS_ENV = "HASNA_CONFIGS_CODEWITH_NATIVE_IMPORTS";
|
|
1633
|
+
var SESSION_RENDER_MANAGED_MARKER = "Managed by @hasna/configs session render";
|
|
1634
|
+
var SESSION_RENDER_SCHEMA = "hasna.configs.session-render/v1";
|
|
1635
|
+
var RAW_STORE_ROOT_ENV = "HASNA_CONFIGS_HOME";
|
|
1636
|
+
var SESSION_RENDER_TOOLS = [
|
|
1637
|
+
"claude",
|
|
1638
|
+
"codex",
|
|
1639
|
+
"cursor",
|
|
1640
|
+
"opencode",
|
|
1641
|
+
"codewith"
|
|
1642
|
+
];
|
|
1643
|
+
var CODEWITH_FLATTENED_ADAPTER = {
|
|
1644
|
+
tool: "codewith",
|
|
1645
|
+
mode: "flattened-markdown",
|
|
1646
|
+
indexFile: "CODEWITH.md",
|
|
1647
|
+
managedDir: ".hasna/instructions",
|
|
1648
|
+
envVar: "CODEWITH_HOME",
|
|
1649
|
+
nativeImports: false,
|
|
1650
|
+
description: "Codewith CODEWITH.md flattened until native @ imports are implemented in Codewith."
|
|
1651
|
+
};
|
|
1652
|
+
var CODEWITH_NATIVE_ADAPTER = {
|
|
1653
|
+
tool: "codewith",
|
|
1654
|
+
mode: "native-imports",
|
|
1655
|
+
indexFile: "CODEWITH.md",
|
|
1656
|
+
managedDir: ".hasna/instructions",
|
|
1657
|
+
envVar: "CODEWITH_HOME",
|
|
1658
|
+
nativeImports: true,
|
|
1659
|
+
description: "Codewith CODEWITH.md with gated @ imports into managed fragments."
|
|
1660
|
+
};
|
|
1661
|
+
var SESSION_TOOL_ADAPTERS = {
|
|
1662
|
+
claude: {
|
|
1663
|
+
tool: "claude",
|
|
1664
|
+
mode: "native-imports",
|
|
1665
|
+
indexFile: "CLAUDE.md",
|
|
1666
|
+
managedDir: ".hasna/instructions",
|
|
1667
|
+
envVar: "CLAUDE_CONFIG_DIR",
|
|
1668
|
+
nativeImports: true,
|
|
1669
|
+
description: "Claude Code CLAUDE.md with @ imports into managed fragments."
|
|
1670
|
+
},
|
|
1671
|
+
codex: {
|
|
1672
|
+
tool: "codex",
|
|
1673
|
+
mode: "flattened-markdown",
|
|
1674
|
+
indexFile: "AGENTS.md",
|
|
1675
|
+
managedDir: ".hasna/instructions",
|
|
1676
|
+
envVar: "CODEX_HOME",
|
|
1677
|
+
nativeImports: false,
|
|
1678
|
+
description: "Codex AGENTS.md flattened instruction file."
|
|
1679
|
+
},
|
|
1680
|
+
cursor: {
|
|
1681
|
+
tool: "cursor",
|
|
1682
|
+
mode: "cursor-mdc",
|
|
1683
|
+
managedDir: ".cursor/rules",
|
|
1684
|
+
nativeImports: false,
|
|
1685
|
+
description: "Cursor project rule files in .cursor/rules/*.mdc."
|
|
1686
|
+
},
|
|
1687
|
+
opencode: {
|
|
1688
|
+
tool: "opencode",
|
|
1689
|
+
mode: "opencode-instructions",
|
|
1690
|
+
indexFile: "AGENTS.md",
|
|
1691
|
+
configFile: "opencode.json",
|
|
1692
|
+
managedDir: ".hasna/instructions",
|
|
1693
|
+
envVar: "OPENCODE_CONFIG_DIR",
|
|
1694
|
+
nativeImports: false,
|
|
1695
|
+
description: "OpenCode AGENTS.md plus opencode.json instructions pointing at managed fragments."
|
|
1696
|
+
},
|
|
1697
|
+
codewith: CODEWITH_FLATTENED_ADAPTER
|
|
1698
|
+
};
|
|
1699
|
+
var LAYER_RANK = {
|
|
1700
|
+
global: 10,
|
|
1701
|
+
tool: 20,
|
|
1702
|
+
account: 30,
|
|
1703
|
+
agent: 40,
|
|
1704
|
+
project: 50,
|
|
1705
|
+
local: 60
|
|
1706
|
+
};
|
|
1707
|
+
function ensureTrailingNewline2(content) {
|
|
1708
|
+
return content.endsWith(`
|
|
1709
|
+
`) ? content : `${content}
|
|
1710
|
+
`;
|
|
1711
|
+
}
|
|
1712
|
+
function sha256(content) {
|
|
1713
|
+
return createHash("sha256").update(content).digest("hex");
|
|
1714
|
+
}
|
|
1715
|
+
function fingerprint(value) {
|
|
1716
|
+
return sha256(JSON.stringify(value));
|
|
1717
|
+
}
|
|
1718
|
+
function slug(value) {
|
|
1719
|
+
const s = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1720
|
+
return s || "instruction";
|
|
1721
|
+
}
|
|
1722
|
+
function yamlQuote2(value) {
|
|
1723
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
1724
|
+
}
|
|
1725
|
+
function getRawStoreRoot() {
|
|
1726
|
+
return resolve2(process.env[RAW_STORE_ROOT_ENV] || join3(process.env["HOME"] || homedir3(), ".hasna", "configs"));
|
|
1727
|
+
}
|
|
1728
|
+
function defaultTargetHome(tool, profile, sessionId) {
|
|
1729
|
+
return join3(getRawStoreRoot(), "sessions", tool, slug(profile), slug(sessionId || "latest"));
|
|
1730
|
+
}
|
|
1731
|
+
function joinTarget(targetHome, relativePath) {
|
|
1732
|
+
const safeTargetHome = assertSafeTargetRoot(targetHome);
|
|
1733
|
+
const safeRelativePath = assertSafeRelativePath(relativePath);
|
|
1734
|
+
return join3(safeTargetHome, ...safeRelativePath.split("/"));
|
|
1735
|
+
}
|
|
1736
|
+
function makeFile(targetHome, relativePath, role, content, sourceIds) {
|
|
1737
|
+
const safeTargetHome = assertSafeTargetRoot(targetHome);
|
|
1738
|
+
const safeRelativePath = assertSafeRelativePath(relativePath);
|
|
1739
|
+
const normalizedContent = ensureTrailingNewline2(content);
|
|
1740
|
+
return {
|
|
1741
|
+
path: joinTarget(safeTargetHome, safeRelativePath),
|
|
1742
|
+
relativePath: safeRelativePath,
|
|
1743
|
+
role,
|
|
1744
|
+
content: normalizedContent,
|
|
1745
|
+
sha256: sha256(normalizedContent),
|
|
1746
|
+
sourceIds
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
function normalizeSources(sources, tool, allowEmptySources) {
|
|
1750
|
+
const ordered = sources.map((source, index) => {
|
|
1751
|
+
if (!source.id.trim())
|
|
1752
|
+
throw new Error("Session instruction source id is required.");
|
|
1753
|
+
const content = filterProviderOnlyBlocks(source.content ?? "", tool);
|
|
1754
|
+
const normalized = {
|
|
1755
|
+
...source,
|
|
1756
|
+
content,
|
|
1757
|
+
normalizedId: slug(source.id),
|
|
1758
|
+
resolvedLabel: source.label ?? source.id,
|
|
1759
|
+
resolvedLayer: source.layer ?? "agent",
|
|
1760
|
+
resolvedMerge: source.merge ?? "append",
|
|
1761
|
+
resolvedOrder: source.order ?? index,
|
|
1762
|
+
resolvedRules: normalizeInstructionRules(source, tool)
|
|
1763
|
+
};
|
|
1764
|
+
const hasPathReferences = (normalized.sourcePaths ?? []).length > 0;
|
|
1765
|
+
if (!allowEmptySources && !normalized.content.trim() && normalized.resolvedRules.length === 0 && !hasPathReferences) {
|
|
1766
|
+
throw new Error(`Session instruction source "${source.id}" is empty. Pass --allow-empty-sources only for explicit empty renders.`);
|
|
1767
|
+
}
|
|
1768
|
+
return normalized;
|
|
1769
|
+
}).sort((a, b) => LAYER_RANK[a.resolvedLayer] - LAYER_RANK[b.resolvedLayer] || a.resolvedOrder - b.resolvedOrder || a.id.localeCompare(b.id));
|
|
1770
|
+
rejectDuplicateSourceSlugs(ordered);
|
|
1771
|
+
rejectDuplicateRulePaths(ordered);
|
|
1772
|
+
return ordered;
|
|
1773
|
+
}
|
|
1774
|
+
function filterProviderOnlyBlocks(content, tool) {
|
|
1775
|
+
const lines = content.split(/\r?\n/);
|
|
1776
|
+
const output = [];
|
|
1777
|
+
let activeProviders = null;
|
|
1778
|
+
for (const line of lines) {
|
|
1779
|
+
const start = line.match(/^\s*<!--\s*@hasna-provider:\s*([^>]+?)\s*-->\s*$/i);
|
|
1780
|
+
if (start) {
|
|
1781
|
+
if (activeProviders)
|
|
1782
|
+
throw new Error("Nested provider-only instruction blocks are not supported.");
|
|
1783
|
+
activeProviders = start[1].split(",").map((item) => item.trim().toLowerCase()).filter(Boolean);
|
|
1784
|
+
continue;
|
|
1785
|
+
}
|
|
1786
|
+
if (/^\s*<!--\s*@hasna-end-provider\s*-->\s*$/i.test(line)) {
|
|
1787
|
+
if (!activeProviders)
|
|
1788
|
+
throw new Error("Provider-only instruction block end marker without start marker.");
|
|
1789
|
+
activeProviders = null;
|
|
1790
|
+
continue;
|
|
1791
|
+
}
|
|
1792
|
+
if (!activeProviders || activeProviders.includes(tool) || activeProviders.includes("all") || activeProviders.includes("generic")) {
|
|
1793
|
+
output.push(line);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
if (activeProviders)
|
|
1797
|
+
throw new Error("Provider-only instruction block was not closed.");
|
|
1798
|
+
return output.join(`
|
|
1799
|
+
`);
|
|
1800
|
+
}
|
|
1801
|
+
function composeSources(sources) {
|
|
1802
|
+
let start = -1;
|
|
1803
|
+
for (let i = 0;i < sources.length; i++) {
|
|
1804
|
+
if (sources[i].resolvedMerge === "replace")
|
|
1805
|
+
start = i;
|
|
1806
|
+
}
|
|
1807
|
+
if (start < 0)
|
|
1808
|
+
return sources;
|
|
1809
|
+
const protectedSources = sources.slice(0, start).filter((source) => source.nonOverridable);
|
|
1810
|
+
return [...protectedSources, ...sources.slice(start)];
|
|
1811
|
+
}
|
|
1812
|
+
function sectionForSource(source) {
|
|
1813
|
+
const parts = [
|
|
1814
|
+
`<!-- ${SESSION_RENDER_MANAGED_MARKER}. Do not edit this generated file directly. -->`,
|
|
1815
|
+
`# ${source.resolvedLabel}`
|
|
1816
|
+
];
|
|
1817
|
+
if (source.path)
|
|
1818
|
+
parts.push(`Source: ${source.path}`);
|
|
1819
|
+
if (source.sourcePaths && source.sourcePaths.length > 0) {
|
|
1820
|
+
parts.push([
|
|
1821
|
+
"Source paths:",
|
|
1822
|
+
...source.sourcePaths.map((sourcePath) => {
|
|
1823
|
+
const flags = [
|
|
1824
|
+
sourcePath.editable ? "editable" : null,
|
|
1825
|
+
sourcePath.required ? "required" : null,
|
|
1826
|
+
sourcePath.hash ? sourcePath.hash : null
|
|
1827
|
+
].filter(Boolean);
|
|
1828
|
+
return `- ${sourcePath.path}${flags.length > 0 ? ` (${flags.join(", ")})` : ""}`;
|
|
1829
|
+
})
|
|
1830
|
+
].join(`
|
|
1831
|
+
`));
|
|
1832
|
+
}
|
|
1833
|
+
if (source.owner)
|
|
1834
|
+
parts.push(`Owner: ${source.owner.kind}:${source.owner.id}`);
|
|
1835
|
+
const content = source.content.trim();
|
|
1836
|
+
if (content)
|
|
1837
|
+
parts.push(content);
|
|
1838
|
+
return parts.join(`
|
|
1839
|
+
|
|
1840
|
+
`);
|
|
1841
|
+
}
|
|
1842
|
+
function sectionForRule(source, rule) {
|
|
1843
|
+
const parts = [
|
|
1844
|
+
`<!-- ${SESSION_RENDER_MANAGED_MARKER}. Do not edit this generated file directly. -->`,
|
|
1845
|
+
`# ${rule.resolvedLabel}`
|
|
1846
|
+
];
|
|
1847
|
+
if (source.path)
|
|
1848
|
+
parts.push(`Source: ${source.path}`);
|
|
1849
|
+
if (rule.path)
|
|
1850
|
+
parts.push(`Rule path: ${rule.path}`);
|
|
1851
|
+
const content = rule.content.trim();
|
|
1852
|
+
if (content)
|
|
1853
|
+
parts.push(content);
|
|
1854
|
+
return parts.join(`
|
|
1855
|
+
|
|
1856
|
+
`);
|
|
1857
|
+
}
|
|
1858
|
+
function fragmentPath(adapter, index, source) {
|
|
1859
|
+
const n = String(index + 1).padStart(2, "0");
|
|
1860
|
+
return posix.join(adapter.managedDir, `${n}-${source.normalizedId}.md`);
|
|
1861
|
+
}
|
|
1862
|
+
function ruleFragmentPath(adapter, source, rule) {
|
|
1863
|
+
return posix.join(adapter.managedDir, "rules", source.normalizedId, rule.resolvedPath);
|
|
1864
|
+
}
|
|
1865
|
+
function importPath(indexRelativePath, fragmentRelativePath) {
|
|
1866
|
+
const relative2 = posix.relative(posix.dirname(indexRelativePath), fragmentRelativePath);
|
|
1867
|
+
if (relative2.startsWith("./") || relative2.startsWith("../"))
|
|
1868
|
+
return relative2;
|
|
1869
|
+
return `./${relative2}`;
|
|
1870
|
+
}
|
|
1871
|
+
function indexHeader(tool, profile) {
|
|
1872
|
+
return [
|
|
1873
|
+
`<!-- ${SESSION_RENDER_MANAGED_MARKER}. Do not edit this generated file directly. -->`,
|
|
1874
|
+
`# ${tool} session instructions`,
|
|
1875
|
+
"",
|
|
1876
|
+
`Profile: ${profile}`
|
|
1877
|
+
].join(`
|
|
1878
|
+
`);
|
|
1879
|
+
}
|
|
1880
|
+
function buildNativeImportFiles(targetHome, adapter, profile, sources) {
|
|
1881
|
+
const indexFile = adapter.indexFile;
|
|
1882
|
+
const fragments = sources.flatMap((source, index2) => [
|
|
1883
|
+
makeFile(targetHome, fragmentPath(adapter, index2, source), "fragment", sectionForSource(source), [source.id]),
|
|
1884
|
+
...source.resolvedRules.map((rule) => makeFile(targetHome, ruleFragmentPath(adapter, source, rule), "rule", sectionForRule(source, rule), [source.id, rule.id]))
|
|
1885
|
+
]);
|
|
1886
|
+
const imports = fragments.map((file) => `@${importPath(indexFile, file.relativePath)}`);
|
|
1887
|
+
const index = makeFile(targetHome, indexFile, "index", [indexHeader(adapter.tool, profile), ...imports].join(`
|
|
1888
|
+
`), sources.map((source) => source.id));
|
|
1889
|
+
return [index, ...fragments];
|
|
1890
|
+
}
|
|
1891
|
+
function buildFlattenedMarkdownFiles(targetHome, adapter, profile, sources) {
|
|
1892
|
+
const content = [
|
|
1893
|
+
indexHeader(adapter.tool, profile),
|
|
1894
|
+
...sources.flatMap((source) => [
|
|
1895
|
+
sectionForSource(source),
|
|
1896
|
+
...source.resolvedRules.map((rule) => sectionForRule(source, rule))
|
|
1897
|
+
])
|
|
1898
|
+
].join(`
|
|
1899
|
+
|
|
1900
|
+
`);
|
|
1901
|
+
return [
|
|
1902
|
+
makeFile(targetHome, adapter.indexFile, "index", content, [
|
|
1903
|
+
...sources.map((source) => source.id),
|
|
1904
|
+
...sources.flatMap((source) => source.resolvedRules.map((rule) => rule.id))
|
|
1905
|
+
])
|
|
1906
|
+
];
|
|
1907
|
+
}
|
|
1908
|
+
function buildCursorRuleFiles(targetHome, adapter, sources) {
|
|
1909
|
+
return sources.flatMap((source, index) => {
|
|
1910
|
+
const n = String(index + 1).padStart(2, "0");
|
|
1911
|
+
const stem = `${n}-${source.normalizedId}`;
|
|
1912
|
+
const relativePath = posix.join(adapter.managedDir, `${stem}.mdc`);
|
|
1913
|
+
const description = `${source.resolvedLabel} (${source.resolvedLayer})`;
|
|
1914
|
+
const content = [
|
|
1915
|
+
"---",
|
|
1916
|
+
`description: ${yamlQuote2(description)}`,
|
|
1917
|
+
'globs: ["**/*"]',
|
|
1918
|
+
"alwaysApply: true",
|
|
1919
|
+
"---",
|
|
1920
|
+
"",
|
|
1921
|
+
`<!-- ${SESSION_RENDER_MANAGED_MARKER}. Do not edit this generated file directly. -->`,
|
|
1922
|
+
"",
|
|
1923
|
+
source.content.trim()
|
|
1924
|
+
].join(`
|
|
1925
|
+
`);
|
|
1926
|
+
const sourceFile = makeFile(targetHome, relativePath, "rule", content, [source.id]);
|
|
1927
|
+
const ruleFiles = source.resolvedRules.map((rule) => {
|
|
1928
|
+
const ruleStem = `${n}-${source.normalizedId}-${rule.normalizedId}`;
|
|
1929
|
+
const ruleRelativePath = posix.join(adapter.managedDir, `${ruleStem}.mdc`);
|
|
1930
|
+
const ruleDescription = `${rule.resolvedLabel} (${source.resolvedLayer})`;
|
|
1931
|
+
const ruleContent = [
|
|
1932
|
+
"---",
|
|
1933
|
+
`description: ${yamlQuote2(ruleDescription)}`,
|
|
1934
|
+
`globs: ${JSON.stringify(rule.globs && rule.globs.length > 0 ? rule.globs : ["**/*"])}`,
|
|
1935
|
+
"alwaysApply: true",
|
|
1936
|
+
"---",
|
|
1937
|
+
"",
|
|
1938
|
+
`<!-- ${SESSION_RENDER_MANAGED_MARKER}. Do not edit this generated file directly. -->`,
|
|
1939
|
+
"",
|
|
1940
|
+
rule.content.trim()
|
|
1941
|
+
].join(`
|
|
1942
|
+
`);
|
|
1943
|
+
return makeFile(targetHome, ruleRelativePath, "rule", ruleContent, [source.id, rule.id]);
|
|
1944
|
+
});
|
|
1945
|
+
return [sourceFile, ...ruleFiles];
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
function buildOpenCodeFiles(targetHome, adapter, profile, sources) {
|
|
1949
|
+
const fragments = sources.flatMap((source, index) => [
|
|
1950
|
+
makeFile(targetHome, fragmentPath(adapter, index, source), "fragment", sectionForSource(source), [source.id]),
|
|
1951
|
+
...source.resolvedRules.map((rule) => makeFile(targetHome, ruleFragmentPath(adapter, source, rule), "rule", sectionForRule(source, rule), [source.id, rule.id]))
|
|
1952
|
+
]);
|
|
1953
|
+
const flattenedIndex = makeFile(targetHome, adapter.indexFile, "index", [
|
|
1954
|
+
indexHeader(adapter.tool, profile),
|
|
1955
|
+
...sources.flatMap((source) => [
|
|
1956
|
+
sectionForSource(source),
|
|
1957
|
+
...source.resolvedRules.map((rule) => sectionForRule(source, rule))
|
|
1958
|
+
])
|
|
1959
|
+
].join(`
|
|
1960
|
+
|
|
1961
|
+
`), [
|
|
1962
|
+
...sources.map((source) => source.id),
|
|
1963
|
+
...sources.flatMap((source) => source.resolvedRules.map((rule) => rule.id))
|
|
1964
|
+
]);
|
|
1965
|
+
const config = {
|
|
1966
|
+
$schema: "https://opencode.ai/config.json",
|
|
1967
|
+
instructions: fragments.map((file) => file.relativePath)
|
|
1968
|
+
};
|
|
1969
|
+
return [
|
|
1970
|
+
flattenedIndex,
|
|
1971
|
+
makeFile(targetHome, adapter.configFile, "config", JSON.stringify(config, null, 2), sources.map((source) => source.id)),
|
|
1972
|
+
...fragments
|
|
1973
|
+
];
|
|
1974
|
+
}
|
|
1975
|
+
function buildFiles(targetHome, adapter, profile, sources) {
|
|
1976
|
+
switch (adapter.mode) {
|
|
1977
|
+
case "native-imports":
|
|
1978
|
+
return buildNativeImportFiles(targetHome, adapter, profile, sources);
|
|
1979
|
+
case "flattened-markdown":
|
|
1980
|
+
return buildFlattenedMarkdownFiles(targetHome, adapter, profile, sources);
|
|
1981
|
+
case "cursor-mdc":
|
|
1982
|
+
return buildCursorRuleFiles(targetHome, adapter, sources);
|
|
1983
|
+
case "opencode-instructions":
|
|
1984
|
+
return buildOpenCodeFiles(targetHome, adapter, profile, sources);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
function adapterFor(input) {
|
|
1988
|
+
if (input.tool !== "codewith")
|
|
1989
|
+
return SESSION_TOOL_ADAPTERS[input.tool];
|
|
1990
|
+
const gatedNativeImports = input.codewithNativeImports === true || process.env[CODEWITH_NATIVE_IMPORTS_ENV] === "1" || process.env[CODEWITH_NATIVE_IMPORTS_ENV] === "true";
|
|
1991
|
+
return gatedNativeImports ? CODEWITH_NATIVE_ADAPTER : CODEWITH_FLATTENED_ADAPTER;
|
|
1992
|
+
}
|
|
1993
|
+
function getHomeDir() {
|
|
1994
|
+
return process.env["CONFIGS_HOME"] || process.env["HOME"] || homedir3();
|
|
1995
|
+
}
|
|
1996
|
+
function cleanSessionPathInput(path) {
|
|
1997
|
+
const trimmed = path.trim();
|
|
1998
|
+
if (trimmed.length >= 2 && (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
1999
|
+
return trimmed.slice(1, -1);
|
|
2000
|
+
}
|
|
2001
|
+
return trimmed;
|
|
2002
|
+
}
|
|
2003
|
+
function resolveSessionPath(path) {
|
|
2004
|
+
const cleaned = cleanSessionPathInput(path);
|
|
2005
|
+
if (!cleaned)
|
|
2006
|
+
throw new Error("Session render path cannot be empty.");
|
|
2007
|
+
const home = getHomeDir();
|
|
2008
|
+
if (cleaned === "~")
|
|
2009
|
+
return resolve2(home);
|
|
2010
|
+
if (cleaned.startsWith("~/"))
|
|
2011
|
+
return resolve2(home, cleaned.slice(2));
|
|
2012
|
+
if (cleaned === "{{HOME}}" || cleaned === "${HOME}")
|
|
2013
|
+
return resolve2(home);
|
|
2014
|
+
if (cleaned.startsWith("{{HOME}}/"))
|
|
2015
|
+
return resolve2(home, cleaned.slice("{{HOME}}/".length));
|
|
2016
|
+
if (cleaned.startsWith("${HOME}/"))
|
|
2017
|
+
return resolve2(home, cleaned.slice("${HOME}/".length));
|
|
2018
|
+
return resolve2(cleaned);
|
|
2019
|
+
}
|
|
2020
|
+
function assertSafeRelativePath(relativePath) {
|
|
2021
|
+
if (!relativePath.trim())
|
|
2022
|
+
throw new Error("Session render relative path cannot be empty.");
|
|
2023
|
+
if (relativePath.includes("\\"))
|
|
2024
|
+
throw new Error(`Session render relative path must use POSIX separators: ${relativePath}`);
|
|
2025
|
+
const normalized = posix.normalize(relativePath);
|
|
2026
|
+
if (normalized === "." || posix.isAbsolute(normalized) || normalized === ".." || normalized.startsWith("../")) {
|
|
2027
|
+
throw new Error(`Session render relative path escapes target root: ${relativePath}`);
|
|
2028
|
+
}
|
|
2029
|
+
return normalized;
|
|
2030
|
+
}
|
|
2031
|
+
function assertSafeTargetRoot(targetHome) {
|
|
2032
|
+
if (!isAbsolute(targetHome))
|
|
2033
|
+
throw new Error(`Session render target must be an absolute path: ${targetHome}`);
|
|
2034
|
+
const normalized = resolve2(targetHome);
|
|
2035
|
+
if (normalized === parse(normalized).root) {
|
|
2036
|
+
throw new Error(`Session render target cannot be the filesystem root: ${targetHome}`);
|
|
2037
|
+
}
|
|
2038
|
+
return normalized;
|
|
2039
|
+
}
|
|
2040
|
+
function resolveRenderTarget(input) {
|
|
2041
|
+
if (input.tool === "cursor") {
|
|
2042
|
+
if (!input.projectRoot) {
|
|
2043
|
+
return {
|
|
2044
|
+
targetHome: defaultTargetHome(input.tool, input.profile, input.sessionId),
|
|
2045
|
+
targetKind: "blocked",
|
|
2046
|
+
blockers: [
|
|
2047
|
+
"Cursor rules are project-scoped; pass --project-root (or projectRoot) before applying .cursor/rules files. --target-home is not treated as a repository root for Cursor."
|
|
2048
|
+
]
|
|
2049
|
+
};
|
|
2050
|
+
}
|
|
2051
|
+
return {
|
|
2052
|
+
targetHome: resolveSessionPath(input.projectRoot),
|
|
2053
|
+
targetKind: "project-root",
|
|
2054
|
+
blockers: []
|
|
2055
|
+
};
|
|
2056
|
+
}
|
|
2057
|
+
return {
|
|
2058
|
+
targetHome: input.targetHome ? resolveSessionPath(input.targetHome) : defaultTargetHome(input.tool, input.profile, input.sessionId),
|
|
2059
|
+
targetKind: "session-home",
|
|
2060
|
+
blockers: []
|
|
2061
|
+
};
|
|
2062
|
+
}
|
|
2063
|
+
function resolveSessionTargetOwnership(input, target) {
|
|
2064
|
+
if (target.targetKind === "blocked") {
|
|
2065
|
+
return {
|
|
2066
|
+
kind: "blocked",
|
|
2067
|
+
tool: input.tool,
|
|
2068
|
+
profile: input.profile,
|
|
2069
|
+
targetHome: target.targetHome,
|
|
2070
|
+
projectRoot: input.projectRoot ? resolveSessionPath(input.projectRoot) : null,
|
|
2071
|
+
ownedBy: "open-configs",
|
|
2072
|
+
reason: "target resolution blocked before provider files can be owned"
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
2075
|
+
if (target.targetKind === "project-root") {
|
|
2076
|
+
return {
|
|
2077
|
+
kind: "project",
|
|
2078
|
+
tool: input.tool,
|
|
2079
|
+
profile: input.profile,
|
|
2080
|
+
targetHome: target.targetHome,
|
|
2081
|
+
projectRoot: target.targetHome,
|
|
2082
|
+
ownedBy: "open-configs",
|
|
2083
|
+
reason: "project-scoped provider files are generated in the explicit repository root"
|
|
2084
|
+
};
|
|
2085
|
+
}
|
|
2086
|
+
return {
|
|
2087
|
+
kind: "provider-profile",
|
|
2088
|
+
tool: input.tool,
|
|
2089
|
+
profile: input.profile,
|
|
2090
|
+
targetHome: target.targetHome,
|
|
2091
|
+
projectRoot: null,
|
|
2092
|
+
ownedBy: "open-configs",
|
|
2093
|
+
reason: "profile-scoped provider home is generated by OpenConfigs from identity/config sources"
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
2096
|
+
function planSessionRender(input) {
|
|
2097
|
+
if (!SESSION_RENDER_TOOLS.includes(input.tool))
|
|
2098
|
+
throw new Error(`Unsupported session render tool: ${input.tool}`);
|
|
2099
|
+
if (!input.profile.trim())
|
|
2100
|
+
throw new Error("Session render profile is required.");
|
|
2101
|
+
const adapter = adapterFor(input);
|
|
2102
|
+
const { targetHome, targetKind, blockers } = resolveRenderTarget(input);
|
|
2103
|
+
const targetOwner = resolveSessionTargetOwnership(input, { targetHome, targetKind });
|
|
2104
|
+
const blocked = blockers.length > 0;
|
|
2105
|
+
const allowEmptySources = input.allowEmptySources === true;
|
|
2106
|
+
const orderedSources = composeSources(normalizeSources(input.sources, input.tool, allowEmptySources));
|
|
2107
|
+
if (orderedSources.length === 0 && !allowEmptySources) {
|
|
2108
|
+
throw new Error("Session render has no instruction sources. Pass --allow-empty-sources only for explicit empty renders.");
|
|
2109
|
+
}
|
|
2110
|
+
const generatedAt = input.generatedAt ?? new Date().toISOString();
|
|
2111
|
+
const env = adapter.envVar && !blocked ? { [adapter.envVar]: targetHome } : {};
|
|
2112
|
+
const warnings = [
|
|
2113
|
+
...orderedSources.length === 0 ? ["No instruction sources were provided."] : [],
|
|
2114
|
+
...blockers
|
|
2115
|
+
];
|
|
2116
|
+
const files = blocked ? [] : buildFiles(targetHome, adapter, input.profile, orderedSources);
|
|
2117
|
+
rejectDuplicateRenderPaths(files);
|
|
2118
|
+
const manifest = {
|
|
2119
|
+
schema: SESSION_RENDER_SCHEMA,
|
|
2120
|
+
tool: input.tool,
|
|
2121
|
+
adapterMode: adapter.mode,
|
|
2122
|
+
profile: input.profile,
|
|
2123
|
+
sessionId: input.sessionId ?? null,
|
|
2124
|
+
targetHome,
|
|
2125
|
+
targetKind,
|
|
2126
|
+
targetOwner,
|
|
2127
|
+
writable: !blocked,
|
|
2128
|
+
blocked,
|
|
2129
|
+
blockers,
|
|
2130
|
+
generatedAt,
|
|
2131
|
+
env,
|
|
2132
|
+
sourceHash: fingerprint(orderedSources.map((source) => ({
|
|
2133
|
+
id: source.id,
|
|
2134
|
+
layer: source.resolvedLayer,
|
|
2135
|
+
order: source.resolvedOrder,
|
|
2136
|
+
merge: source.resolvedMerge,
|
|
2137
|
+
content: source.content,
|
|
2138
|
+
rules: source.resolvedRules.map((rule) => ({ id: rule.id, path: rule.resolvedPath, content: rule.content })),
|
|
2139
|
+
hash: source.hash ?? null
|
|
2140
|
+
}))),
|
|
2141
|
+
sources: orderedSources.map((source) => ({
|
|
2142
|
+
id: source.id,
|
|
2143
|
+
label: source.resolvedLabel,
|
|
2144
|
+
layer: source.resolvedLayer,
|
|
2145
|
+
merge: source.resolvedMerge,
|
|
2146
|
+
order: source.resolvedOrder,
|
|
2147
|
+
path: source.path ?? null,
|
|
2148
|
+
targetProviders: source.targetProviders ?? [],
|
|
2149
|
+
owner: source.owner ?? null,
|
|
2150
|
+
sourcePaths: source.sourcePaths ?? [],
|
|
2151
|
+
hash: source.hash ?? null,
|
|
2152
|
+
nonOverridable: source.nonOverridable === true,
|
|
2153
|
+
replacementScope: source.replacementScope ?? null,
|
|
2154
|
+
rules: source.resolvedRules.map((rule) => ({
|
|
2155
|
+
id: rule.id,
|
|
2156
|
+
label: rule.resolvedLabel,
|
|
2157
|
+
path: rule.resolvedPath,
|
|
2158
|
+
globs: rule.globs ?? [],
|
|
2159
|
+
hash: rule.hash ?? null
|
|
2160
|
+
})),
|
|
2161
|
+
provenance: source.provenance ?? null
|
|
2162
|
+
})),
|
|
2163
|
+
skippedSources: [],
|
|
2164
|
+
files: files.map((file) => ({
|
|
2165
|
+
path: file.path,
|
|
2166
|
+
relativePath: file.relativePath,
|
|
2167
|
+
role: file.role,
|
|
2168
|
+
sha256: file.sha256,
|
|
2169
|
+
sourceIds: file.sourceIds
|
|
2170
|
+
})),
|
|
2171
|
+
warnings
|
|
2172
|
+
};
|
|
2173
|
+
const manifestFile = makeFile(targetHome, posix.join(".hasna", "session-render-manifest.json"), "manifest", JSON.stringify(manifest, null, 2), orderedSources.map((source) => source.id));
|
|
2174
|
+
return {
|
|
2175
|
+
dryRun: true,
|
|
2176
|
+
tool: input.tool,
|
|
2177
|
+
adapter,
|
|
2178
|
+
profile: input.profile,
|
|
2179
|
+
sessionId: input.sessionId ?? null,
|
|
2180
|
+
targetHome,
|
|
2181
|
+
targetKind,
|
|
2182
|
+
targetOwner,
|
|
2183
|
+
writable: !blocked,
|
|
2184
|
+
blocked,
|
|
2185
|
+
blockers,
|
|
2186
|
+
env,
|
|
2187
|
+
files,
|
|
2188
|
+
manifest,
|
|
2189
|
+
manifestFile,
|
|
2190
|
+
allFiles: [...files, manifestFile],
|
|
2191
|
+
warnings
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
function sourceFromFilePath(path, content, order = 0) {
|
|
2195
|
+
const file = basename3(path);
|
|
2196
|
+
return {
|
|
2197
|
+
id: file.replace(extname2(file), ""),
|
|
2198
|
+
label: file,
|
|
2199
|
+
content,
|
|
2200
|
+
layer: "agent",
|
|
2201
|
+
order,
|
|
2202
|
+
path
|
|
2203
|
+
};
|
|
2204
|
+
}
|
|
2205
|
+
function sourceFromConfig(config, order = 0, layer) {
|
|
2206
|
+
return {
|
|
2207
|
+
id: config.slug,
|
|
2208
|
+
label: config.name,
|
|
2209
|
+
content: config.content,
|
|
2210
|
+
layer: layer ?? (config.agent === "global" ? "global" : "agent"),
|
|
2211
|
+
order,
|
|
2212
|
+
path: config.target_path ?? undefined,
|
|
2213
|
+
provenance: {
|
|
2214
|
+
source: "open-configs",
|
|
2215
|
+
configSlug: config.slug,
|
|
2216
|
+
configAgent: config.agent
|
|
2217
|
+
}
|
|
2218
|
+
};
|
|
2219
|
+
}
|
|
2220
|
+
function sourcesFromIdentityExport(value, options = {}) {
|
|
2221
|
+
const record = asRecord(value, "identity instruction export");
|
|
2222
|
+
const shape = requireIdentityExportShape(record);
|
|
2223
|
+
const validation = asOptionalRecord(record["validation"]);
|
|
2224
|
+
if (validation && validation["valid"] === false) {
|
|
2225
|
+
const issues = Array.isArray(validation["issues"]) ? validation["issues"] : [];
|
|
2226
|
+
throw new Error(`Identity instruction export is invalid: ${JSON.stringify(issues)}`);
|
|
2227
|
+
}
|
|
2228
|
+
const sources = record["sources"];
|
|
2229
|
+
if (!Array.isArray(sources))
|
|
2230
|
+
throw new Error("Identity instruction export sources must be an array.");
|
|
2231
|
+
const offset = options.orderOffset ?? 0;
|
|
2232
|
+
return sources.map((item, index) => identitySourceToSessionSource(item, {
|
|
2233
|
+
path: options.path,
|
|
2234
|
+
tool: options.tool,
|
|
2235
|
+
orderFallback: offset + index,
|
|
2236
|
+
exportShape: shape
|
|
2237
|
+
})).filter((source) => source !== null);
|
|
2238
|
+
}
|
|
2239
|
+
function requireIdentityExportShape(record) {
|
|
2240
|
+
if (record["contract"] === "hasna.identities.configs-instructions/v1")
|
|
2241
|
+
return "configs-contract";
|
|
2242
|
+
if (record["version"] === 1 && record["package"] === "@hasna/identities")
|
|
2243
|
+
return "canonical-open-identities";
|
|
2244
|
+
throw new Error("Unsupported identity instruction export contract.");
|
|
2245
|
+
}
|
|
2246
|
+
function normalizeInstructionRules(source, tool) {
|
|
2247
|
+
const seen = new Set;
|
|
2248
|
+
return (source.rules ?? []).map((rule) => {
|
|
2249
|
+
if (!rule.id.trim())
|
|
2250
|
+
throw new Error(`Instruction rule id is required for source ${source.id}.`);
|
|
2251
|
+
const content = filterProviderOnlyBlocks(rule.content ?? "", tool);
|
|
2252
|
+
if (!content.trim() && !rule.path)
|
|
2253
|
+
throw new Error(`Instruction rule content or path is required for rule ${rule.id}.`);
|
|
2254
|
+
const resolvedPath = normalizeRulePath(rule.path ?? `${slug(rule.id)}.md`);
|
|
2255
|
+
const key = resolvedPath.toLowerCase();
|
|
2256
|
+
if (seen.has(key))
|
|
2257
|
+
throw new Error(`Duplicate rule path for source ${source.id}: ${resolvedPath}`);
|
|
2258
|
+
seen.add(key);
|
|
2259
|
+
return {
|
|
2260
|
+
...rule,
|
|
2261
|
+
content,
|
|
2262
|
+
normalizedId: slug(rule.id),
|
|
2263
|
+
resolvedLabel: rule.label ?? rule.id,
|
|
2264
|
+
resolvedPath
|
|
2265
|
+
};
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
function rejectDuplicateRenderPaths(files) {
|
|
2269
|
+
const seen = new Set;
|
|
2270
|
+
for (const file of files) {
|
|
2271
|
+
const key = file.relativePath.toLowerCase();
|
|
2272
|
+
if (seen.has(key))
|
|
2273
|
+
throw new Error(`Duplicate session render file path: ${file.relativePath}`);
|
|
2274
|
+
seen.add(key);
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
function rejectDuplicateSourceSlugs(sources) {
|
|
2278
|
+
const seen = new Map;
|
|
2279
|
+
for (const source of sources) {
|
|
2280
|
+
const existing = seen.get(source.normalizedId);
|
|
2281
|
+
if (existing)
|
|
2282
|
+
throw new Error(`Duplicate session instruction source slug: ${source.normalizedId} (${existing}, ${source.id})`);
|
|
2283
|
+
seen.set(source.normalizedId, source.id);
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
function rejectDuplicateRulePaths(sources) {
|
|
2287
|
+
const seen = new Map;
|
|
2288
|
+
for (const source of sources) {
|
|
2289
|
+
for (const rule of source.resolvedRules) {
|
|
2290
|
+
const key = rule.resolvedPath.toLowerCase();
|
|
2291
|
+
const existing = seen.get(key);
|
|
2292
|
+
if (existing)
|
|
2293
|
+
throw new Error(`Duplicate instruction rule path: ${rule.resolvedPath} (${existing}, ${rule.id})`);
|
|
2294
|
+
seen.set(key, rule.id);
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
function normalizeRulePath(path) {
|
|
2299
|
+
if (!path.trim())
|
|
2300
|
+
throw new Error("Instruction rule path cannot be empty.");
|
|
2301
|
+
if (path.includes("\\"))
|
|
2302
|
+
throw new Error(`Instruction rule path must use POSIX separators: ${path}`);
|
|
2303
|
+
const normalized = posix.normalize(path);
|
|
2304
|
+
if (normalized === "." || posix.isAbsolute(normalized) || normalized === ".." || normalized.startsWith("../")) {
|
|
2305
|
+
throw new Error(`Instruction rule path escapes managed rule directory: ${path}`);
|
|
2306
|
+
}
|
|
2307
|
+
return normalized;
|
|
2308
|
+
}
|
|
2309
|
+
function identitySourceToSessionSource(value, options) {
|
|
2310
|
+
const record = asRecord(value, "identity instruction source");
|
|
2311
|
+
const providers = asStringArray(record["targetProviders"]);
|
|
2312
|
+
if (options.tool && providers.length > 0 && !providerTargetsTool(providers, options.tool))
|
|
2313
|
+
return null;
|
|
2314
|
+
const sourcePaths = normalizeSourcePaths(record["sourcePaths"]);
|
|
2315
|
+
const kind = maybeString(record["kind"]);
|
|
2316
|
+
const layer = record["layer"] === undefined ? layerFromIdentityKind(kind, options.exportShape) : requireLayer(record["layer"]);
|
|
2317
|
+
const merge = requireMerge(record["merge"] ?? record["mergePolicy"] ?? "append");
|
|
2318
|
+
const id = requireString(record["id"], "identity instruction source id");
|
|
2319
|
+
const inlineContent = maybeString(record["content"]);
|
|
2320
|
+
const resolvedContent = inlineContent && inlineContent.trim() ? inlineContent : contentFromIdentitySourcePaths(sourcePaths, options.path, id) ?? inlineContent;
|
|
2321
|
+
return {
|
|
2322
|
+
id,
|
|
2323
|
+
label: maybeString(record["label"]) ?? maybeString(record["title"]) ?? id,
|
|
2324
|
+
layer,
|
|
2325
|
+
merge,
|
|
2326
|
+
order: typeof record["order"] === "number" ? record["order"] : typeof record["precedence"] === "number" ? record["precedence"] : options.orderFallback,
|
|
2327
|
+
content: resolvedContent ?? "",
|
|
2328
|
+
path: options.path,
|
|
2329
|
+
rules: normalizeIdentityRules(record["rules"]),
|
|
2330
|
+
provenance: asOptionalRecord(record["provenance"]) ?? null,
|
|
2331
|
+
targetProviders: providers,
|
|
2332
|
+
owner: normalizeIdentityOwner(record["owner"]),
|
|
2333
|
+
sourcePaths,
|
|
2334
|
+
globs: asStringArray(record["globs"]),
|
|
2335
|
+
hash: maybeString(record["hash"]),
|
|
2336
|
+
nonOverridable: record["nonOverridable"] === true,
|
|
2337
|
+
replacementScope: maybeString(record["replacementScope"]),
|
|
2338
|
+
metadata: asOptionalRecord(record["metadata"]) ?? null
|
|
2339
|
+
};
|
|
2340
|
+
}
|
|
2341
|
+
function layerFromIdentityKind(kind, exportShape) {
|
|
2342
|
+
if (!kind) {
|
|
2343
|
+
if (exportShape === "configs-contract")
|
|
2344
|
+
throw new Error("Invalid session instruction layer: undefined");
|
|
2345
|
+
return "agent";
|
|
2346
|
+
}
|
|
2347
|
+
switch (kind) {
|
|
2348
|
+
case "global-rules":
|
|
2349
|
+
case "global-system-prompt":
|
|
2350
|
+
return "global";
|
|
2351
|
+
case "provider-rules":
|
|
2352
|
+
case "provider-system-prompt":
|
|
2353
|
+
return "tool";
|
|
2354
|
+
case "identity-doc":
|
|
2355
|
+
case "persona-doc":
|
|
2356
|
+
return "agent";
|
|
2357
|
+
case "account-overlay":
|
|
2358
|
+
return "account";
|
|
2359
|
+
case "project-overlay":
|
|
2360
|
+
return "project";
|
|
2361
|
+
case "machine-overlay":
|
|
2362
|
+
case "session-overlay":
|
|
2363
|
+
return "local";
|
|
2364
|
+
default:
|
|
2365
|
+
throw new Error(`Invalid identity instruction source kind: ${kind}`);
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
function contentFromIdentitySourcePaths(sourcePaths, exportPath, sourceId) {
|
|
2369
|
+
if (sourcePaths.length === 0 || !exportPath)
|
|
2370
|
+
return;
|
|
2371
|
+
const baseDir = dirname2(resolveSessionPath(exportPath));
|
|
2372
|
+
const contents = [];
|
|
2373
|
+
for (const sourcePath of sourcePaths) {
|
|
2374
|
+
const content = readIdentitySourcePath(sourcePath, baseDir, sourceId);
|
|
2375
|
+
if (content !== undefined)
|
|
2376
|
+
contents.push({ path: sourcePath.path, content });
|
|
2377
|
+
}
|
|
2378
|
+
if (contents.length === 0)
|
|
2379
|
+
return;
|
|
2380
|
+
if (contents.length === 1)
|
|
2381
|
+
return ensureTrailingNewline2(contents[0].content);
|
|
2382
|
+
return ensureTrailingNewline2(contents.map((item) => `<!-- Source path: ${item.path} -->
|
|
2383
|
+
${item.content.trimEnd()}`).join(`
|
|
2384
|
+
|
|
2385
|
+
`));
|
|
2386
|
+
}
|
|
2387
|
+
function readIdentitySourcePath(sourcePath, baseDir, sourceId) {
|
|
2388
|
+
const resolvedPath = resolveIdentitySourcePath(sourcePath.path, baseDir, sourceId);
|
|
2389
|
+
if (!existsSync5(resolvedPath)) {
|
|
2390
|
+
if (sourcePath.required) {
|
|
2391
|
+
throw new Error(`Required identity instruction source path not found for ${sourceId}: ${sourcePath.path}`);
|
|
2392
|
+
}
|
|
2393
|
+
return;
|
|
2394
|
+
}
|
|
2395
|
+
const stat = statSync2(resolvedPath);
|
|
2396
|
+
if (!stat.isFile()) {
|
|
2397
|
+
throw new Error(`Identity instruction source path is not a file for ${sourceId}: ${sourcePath.path}`);
|
|
2398
|
+
}
|
|
2399
|
+
const realBase = realpathSync2(baseDir);
|
|
2400
|
+
const realPath = realpathSync2(resolvedPath);
|
|
2401
|
+
if (!pathIsInside(realPath, realBase)) {
|
|
2402
|
+
throw new Error(`Identity instruction source path escapes export directory for ${sourceId}: ${sourcePath.path}`);
|
|
2403
|
+
}
|
|
2404
|
+
return readFileSync3(realPath, "utf-8");
|
|
2405
|
+
}
|
|
2406
|
+
function resolveIdentitySourcePath(path, baseDir, sourceId) {
|
|
2407
|
+
const cleaned = cleanSessionPathInput(path);
|
|
2408
|
+
if (!cleaned)
|
|
2409
|
+
throw new Error(`Identity instruction source path cannot be empty for ${sourceId}.`);
|
|
2410
|
+
if (cleaned.includes("\\"))
|
|
2411
|
+
throw new Error(`Identity instruction source path must use POSIX separators for ${sourceId}: ${path}`);
|
|
2412
|
+
const resolvedPath = isAbsolute(cleaned) ? resolve2(cleaned) : resolve2(baseDir, cleaned);
|
|
2413
|
+
if (!pathIsInside(resolvedPath, resolve2(baseDir))) {
|
|
2414
|
+
throw new Error(`Identity instruction source path escapes export directory for ${sourceId}: ${path}`);
|
|
2415
|
+
}
|
|
2416
|
+
return resolvedPath;
|
|
2417
|
+
}
|
|
2418
|
+
function pathIsInside(path, baseDir) {
|
|
2419
|
+
const rel = relative(baseDir, path);
|
|
2420
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
2421
|
+
}
|
|
2422
|
+
function providerTargetsTool(targets, tool) {
|
|
2423
|
+
return targets.map((target) => target.toLowerCase()).some((target) => target === tool || target === "all" || target === "generic");
|
|
2424
|
+
}
|
|
2425
|
+
function normalizeIdentityRules(value) {
|
|
2426
|
+
if (value === undefined || value === null)
|
|
2427
|
+
return [];
|
|
2428
|
+
if (!Array.isArray(value))
|
|
2429
|
+
throw new Error("Identity instruction source rules must be an array.");
|
|
2430
|
+
return value.map((item) => {
|
|
2431
|
+
const record = asRecord(item, "identity instruction rule");
|
|
2432
|
+
const id = requireString(record["id"], "identity instruction rule id");
|
|
2433
|
+
return {
|
|
2434
|
+
id,
|
|
2435
|
+
label: maybeString(record["label"]) ?? id,
|
|
2436
|
+
path: maybeString(record["path"]),
|
|
2437
|
+
content: maybeString(record["content"]) ?? "",
|
|
2438
|
+
globs: asStringArray(record["globs"]),
|
|
2439
|
+
hash: maybeString(record["hash"]),
|
|
2440
|
+
metadata: asOptionalRecord(record["metadata"]) ?? null
|
|
2441
|
+
};
|
|
2442
|
+
});
|
|
2443
|
+
}
|
|
2444
|
+
function normalizeIdentityOwner(value) {
|
|
2445
|
+
if (value === undefined || value === null)
|
|
2446
|
+
return null;
|
|
2447
|
+
const record = asRecord(value, "identity instruction owner");
|
|
2448
|
+
return {
|
|
2449
|
+
kind: requireString(record["kind"], "identity instruction owner kind"),
|
|
2450
|
+
id: requireString(record["id"], "identity instruction owner id")
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
function normalizeSourcePaths(value) {
|
|
2454
|
+
if (value === undefined || value === null)
|
|
2455
|
+
return [];
|
|
2456
|
+
if (!Array.isArray(value))
|
|
2457
|
+
throw new Error("Identity instruction source paths must be an array.");
|
|
2458
|
+
return value.map((item) => {
|
|
2459
|
+
const record = asRecord(item, "identity instruction source path");
|
|
2460
|
+
return {
|
|
2461
|
+
path: requireString(record["path"], "identity instruction source path"),
|
|
2462
|
+
editable: record["editable"] === true,
|
|
2463
|
+
required: record["required"] === true,
|
|
2464
|
+
hash: maybeString(record["hash"])
|
|
2465
|
+
};
|
|
2466
|
+
});
|
|
2467
|
+
}
|
|
2468
|
+
function requireLayer(value) {
|
|
2469
|
+
if (value === "global" || value === "tool" || value === "account" || value === "agent" || value === "project" || value === "local")
|
|
2470
|
+
return value;
|
|
2471
|
+
throw new Error(`Invalid session instruction layer: ${String(value)}`);
|
|
2472
|
+
}
|
|
2473
|
+
function requireMerge(value) {
|
|
2474
|
+
if (value === "append" || value === "replace")
|
|
2475
|
+
return value;
|
|
2476
|
+
throw new Error(`Invalid session instruction merge policy: ${String(value)}`);
|
|
2477
|
+
}
|
|
2478
|
+
function asRecord(value, label) {
|
|
2479
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
2480
|
+
throw new Error(`Invalid ${label}.`);
|
|
2481
|
+
return value;
|
|
2482
|
+
}
|
|
2483
|
+
function asOptionalRecord(value) {
|
|
2484
|
+
if (value === undefined || value === null)
|
|
2485
|
+
return null;
|
|
2486
|
+
return asRecord(value, "record");
|
|
2487
|
+
}
|
|
2488
|
+
function maybeString(value) {
|
|
2489
|
+
return typeof value === "string" ? value : undefined;
|
|
2490
|
+
}
|
|
2491
|
+
function requireString(value, label) {
|
|
2492
|
+
if (typeof value !== "string" || !value.trim())
|
|
2493
|
+
throw new Error(`Invalid ${label}.`);
|
|
2494
|
+
return value;
|
|
2495
|
+
}
|
|
2496
|
+
function asStringArray(value) {
|
|
2497
|
+
if (value === undefined || value === null)
|
|
2498
|
+
return [];
|
|
2499
|
+
if (!Array.isArray(value))
|
|
2500
|
+
return [];
|
|
2501
|
+
return value.filter((item) => typeof item === "string");
|
|
2502
|
+
}
|
|
2503
|
+
// src/lib/session-apply.ts
|
|
2504
|
+
import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
|
|
2505
|
+
import {
|
|
2506
|
+
existsSync as existsSync6,
|
|
2507
|
+
lstatSync,
|
|
2508
|
+
mkdirSync as mkdirSync3,
|
|
2509
|
+
readFileSync as readFileSync4,
|
|
2510
|
+
renameSync,
|
|
2511
|
+
rmSync,
|
|
2512
|
+
writeFileSync as writeFileSync2
|
|
2513
|
+
} from "fs";
|
|
2514
|
+
import { dirname as dirname3, isAbsolute as isAbsolute2, join as join4, parse as parse2, relative as relative2, resolve as resolve3 } from "path";
|
|
2515
|
+
class SessionApplyError extends Error {
|
|
2516
|
+
constructor(message) {
|
|
2517
|
+
super(message);
|
|
2518
|
+
this.name = "SessionApplyError";
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
function applySessionRender(plan, options = {}) {
|
|
2522
|
+
if (plan.blocked || !plan.writable) {
|
|
2523
|
+
throw new SessionApplyError(`Session render plan is blocked: ${plan.blockers.join("; ")}`);
|
|
2524
|
+
}
|
|
2525
|
+
const targetHome = assertSafeTargetHome(plan.targetHome);
|
|
2526
|
+
const files = [...plan.files, plan.manifestFile];
|
|
2527
|
+
const manifestPath = resolvePlannedFilePath(plan, plan.manifestFile, targetHome);
|
|
2528
|
+
const previousManifest = readPreviousManifest(manifestPath);
|
|
2529
|
+
const previousHashes = previousManifest ? new Map(previousManifest.files.map((file) => [file.relativePath, file.sha256])) : new Map;
|
|
2530
|
+
const currentRelativePaths = new Set(files.map((file) => file.relativePath));
|
|
2531
|
+
const drift = checkSessionRenderDrift(targetHome, manifestPath);
|
|
2532
|
+
const results = [
|
|
2533
|
+
...files.map((file) => planFileResult(plan, file, targetHome, previousHashes, previousManifest, options)),
|
|
2534
|
+
...planStaleFileResults(plan, targetHome, previousManifest, currentRelativePaths, options)
|
|
2535
|
+
];
|
|
2536
|
+
const conflicts = results.filter((result) => result.action === "conflict");
|
|
2537
|
+
if (conflicts.length > 0) {
|
|
2538
|
+
return {
|
|
2539
|
+
dryRun: options.dryRun ?? false,
|
|
2540
|
+
applied: false,
|
|
2541
|
+
targetHome,
|
|
2542
|
+
manifestPath,
|
|
2543
|
+
snapshotPath: null,
|
|
2544
|
+
env: plan.env,
|
|
2545
|
+
files: results,
|
|
2546
|
+
conflicts,
|
|
2547
|
+
drift
|
|
2548
|
+
};
|
|
2549
|
+
}
|
|
2550
|
+
let snapshotPath = null;
|
|
2551
|
+
if (!options.dryRun) {
|
|
2552
|
+
snapshotPath = writeSessionSnapshot(plan, targetHome, manifestPath, results, previousManifest);
|
|
2553
|
+
for (const file of files) {
|
|
2554
|
+
const target = resolvePlannedFilePath(plan, file, targetHome);
|
|
2555
|
+
const existingContent = existsSync6(target) ? readFileSync4(target, "utf-8") : null;
|
|
2556
|
+
if (existingContent === file.content)
|
|
2557
|
+
continue;
|
|
2558
|
+
writePlannedFile(target, file.content, targetHome);
|
|
2559
|
+
}
|
|
2560
|
+
for (const result of results) {
|
|
2561
|
+
if (result.action !== "delete")
|
|
2562
|
+
continue;
|
|
2563
|
+
assertNoSymlinkSegments(targetHome, result.path);
|
|
2564
|
+
if (existsSync6(result.path))
|
|
2565
|
+
rmSync(result.path);
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
return {
|
|
2569
|
+
dryRun: options.dryRun ?? false,
|
|
2570
|
+
applied: !(options.dryRun ?? false),
|
|
2571
|
+
targetHome,
|
|
2572
|
+
manifestPath,
|
|
2573
|
+
snapshotPath,
|
|
2574
|
+
env: plan.env,
|
|
2575
|
+
files: results,
|
|
2576
|
+
conflicts,
|
|
2577
|
+
drift
|
|
2578
|
+
};
|
|
2579
|
+
}
|
|
2580
|
+
function checkSessionRenderDrift(targetHome, manifestPath) {
|
|
2581
|
+
const safeTargetHome = assertSafeTargetHome(targetHome);
|
|
2582
|
+
const resolvedManifestPath = manifestPath ? resolveManifestRelativePath(relative2(safeTargetHome, resolve3(manifestPath)), safeTargetHome) : resolve3(safeTargetHome, ".hasna", "session-render-manifest.json");
|
|
2583
|
+
const checkedAt = new Date().toISOString();
|
|
2584
|
+
const previousManifest = readPreviousManifest(resolvedManifestPath);
|
|
2585
|
+
if (!previousManifest) {
|
|
2586
|
+
return {
|
|
2587
|
+
checked: false,
|
|
2588
|
+
clean: true,
|
|
2589
|
+
manifestPath: resolvedManifestPath,
|
|
2590
|
+
checkedAt,
|
|
2591
|
+
missing: [],
|
|
2592
|
+
drifted: []
|
|
2593
|
+
};
|
|
2594
|
+
}
|
|
2595
|
+
const missing = [];
|
|
2596
|
+
const drifted = [];
|
|
2597
|
+
for (const file of previousManifest.files) {
|
|
2598
|
+
const target = resolveManifestRelativePath(file.relativePath, safeTargetHome);
|
|
2599
|
+
if (!existsSync6(target)) {
|
|
2600
|
+
missing.push({
|
|
2601
|
+
path: target,
|
|
2602
|
+
relativePath: file.relativePath,
|
|
2603
|
+
expectedSha256: file.sha256,
|
|
2604
|
+
actualSha256: null,
|
|
2605
|
+
reason: "missing"
|
|
2606
|
+
});
|
|
2607
|
+
continue;
|
|
2608
|
+
}
|
|
2609
|
+
const actualSha256 = sha2562(readFileSync4(target, "utf-8"));
|
|
2610
|
+
if (actualSha256 !== file.sha256) {
|
|
2611
|
+
drifted.push({
|
|
2612
|
+
path: target,
|
|
2613
|
+
relativePath: file.relativePath,
|
|
2614
|
+
expectedSha256: file.sha256,
|
|
2615
|
+
actualSha256,
|
|
2616
|
+
reason: "hash_mismatch"
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
return {
|
|
2621
|
+
checked: true,
|
|
2622
|
+
clean: missing.length === 0 && drifted.length === 0,
|
|
2623
|
+
manifestPath: resolvedManifestPath,
|
|
2624
|
+
checkedAt,
|
|
2625
|
+
missing,
|
|
2626
|
+
drifted
|
|
2627
|
+
};
|
|
2628
|
+
}
|
|
2629
|
+
function planFileResult(plan, file, targetHome, previousHashes, previousManifest, options) {
|
|
2630
|
+
const target = resolvePlannedFilePath(plan, file, targetHome);
|
|
2631
|
+
const previousContent = existsSync6(target) ? readFileSync4(target, "utf-8") : null;
|
|
2632
|
+
const previousSha256 = previousContent === null ? null : sha2562(previousContent);
|
|
2633
|
+
const previouslyManaged = isPreviouslyManaged(file, previousSha256, previousHashes, previousManifest);
|
|
2634
|
+
const changed = previousContent !== file.content;
|
|
2635
|
+
if (previousContent !== null && !options.force && !previouslyManaged) {
|
|
2636
|
+
return {
|
|
2637
|
+
path: target,
|
|
2638
|
+
relativePath: file.relativePath,
|
|
2639
|
+
role: file.role,
|
|
2640
|
+
action: "conflict",
|
|
2641
|
+
changed,
|
|
2642
|
+
previousSha256,
|
|
2643
|
+
newSha256: file.sha256,
|
|
2644
|
+
reason: "existing unmanaged file; pass force to overwrite or adopt"
|
|
2645
|
+
};
|
|
2646
|
+
}
|
|
2647
|
+
if (!changed && options.force && !previouslyManaged) {
|
|
2648
|
+
return {
|
|
2649
|
+
path: target,
|
|
2650
|
+
relativePath: file.relativePath,
|
|
2651
|
+
role: file.role,
|
|
2652
|
+
action: "update",
|
|
2653
|
+
changed: false,
|
|
2654
|
+
previousSha256,
|
|
2655
|
+
newSha256: file.sha256,
|
|
2656
|
+
reason: "force"
|
|
2657
|
+
};
|
|
2658
|
+
}
|
|
2659
|
+
if (!changed) {
|
|
2660
|
+
return {
|
|
2661
|
+
path: target,
|
|
2662
|
+
relativePath: file.relativePath,
|
|
2663
|
+
role: file.role,
|
|
2664
|
+
action: "unchanged",
|
|
2665
|
+
changed: false,
|
|
2666
|
+
previousSha256,
|
|
2667
|
+
newSha256: file.sha256,
|
|
2668
|
+
reason: null
|
|
2669
|
+
};
|
|
2670
|
+
}
|
|
2671
|
+
if (previousContent === null) {
|
|
2672
|
+
return {
|
|
2673
|
+
path: target,
|
|
2674
|
+
relativePath: file.relativePath,
|
|
2675
|
+
role: file.role,
|
|
2676
|
+
action: "create",
|
|
2677
|
+
changed: true,
|
|
2678
|
+
previousSha256,
|
|
2679
|
+
newSha256: file.sha256,
|
|
2680
|
+
reason: null
|
|
2681
|
+
};
|
|
2682
|
+
}
|
|
2683
|
+
if (options.force || previouslyManaged) {
|
|
2684
|
+
return {
|
|
2685
|
+
path: target,
|
|
2686
|
+
relativePath: file.relativePath,
|
|
2687
|
+
role: file.role,
|
|
2688
|
+
action: "update",
|
|
2689
|
+
changed: true,
|
|
2690
|
+
previousSha256,
|
|
2691
|
+
newSha256: file.sha256,
|
|
2692
|
+
reason: options.force ? "force" : "previous manifest hash matched"
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2695
|
+
return {
|
|
2696
|
+
path: target,
|
|
2697
|
+
relativePath: file.relativePath,
|
|
2698
|
+
role: file.role,
|
|
2699
|
+
action: "conflict",
|
|
2700
|
+
changed: true,
|
|
2701
|
+
previousSha256,
|
|
2702
|
+
newSha256: file.sha256,
|
|
2703
|
+
reason: "existing unmanaged file differs; pass force to overwrite"
|
|
2704
|
+
};
|
|
2705
|
+
}
|
|
2706
|
+
function planStaleFileResults(plan, targetHome, previousManifest, currentRelativePaths, options) {
|
|
2707
|
+
if (!previousManifest)
|
|
2708
|
+
return [];
|
|
2709
|
+
const managedPrefix = `${plan.adapter.managedDir}/`;
|
|
2710
|
+
return previousManifest.files.filter((file) => !currentRelativePaths.has(file.relativePath)).filter((file) => file.relativePath === plan.adapter.managedDir || file.relativePath.startsWith(managedPrefix)).map((file) => planStaleFileResult(file, targetHome, options)).filter((result) => result !== null);
|
|
2711
|
+
}
|
|
2712
|
+
function planStaleFileResult(file, targetHome, options) {
|
|
2713
|
+
const target = resolveManifestRelativePath(file.relativePath, targetHome);
|
|
2714
|
+
if (!existsSync6(target))
|
|
2715
|
+
return null;
|
|
2716
|
+
const previousContent = readFileSync4(target, "utf-8");
|
|
2717
|
+
const previousSha256 = sha2562(previousContent);
|
|
2718
|
+
if (!options.force && previousSha256 !== file.sha256) {
|
|
2719
|
+
return {
|
|
2720
|
+
path: target,
|
|
2721
|
+
relativePath: file.relativePath,
|
|
2722
|
+
role: file.role,
|
|
2723
|
+
action: "conflict",
|
|
2724
|
+
changed: true,
|
|
2725
|
+
previousSha256,
|
|
2726
|
+
newSha256: "",
|
|
2727
|
+
reason: "stale managed file changed since previous manifest; pass force to remove"
|
|
2728
|
+
};
|
|
2729
|
+
}
|
|
2730
|
+
if (!options.force && !previousContent.includes(SESSION_RENDER_MANAGED_MARKER)) {
|
|
2731
|
+
return {
|
|
2732
|
+
path: target,
|
|
2733
|
+
relativePath: file.relativePath,
|
|
2734
|
+
role: file.role,
|
|
2735
|
+
action: "conflict",
|
|
2736
|
+
changed: true,
|
|
2737
|
+
previousSha256,
|
|
2738
|
+
newSha256: "",
|
|
2739
|
+
reason: "stale file lacks managed marker; pass force to remove"
|
|
2740
|
+
};
|
|
2741
|
+
}
|
|
2742
|
+
return {
|
|
2743
|
+
path: target,
|
|
2744
|
+
relativePath: file.relativePath,
|
|
2745
|
+
role: file.role,
|
|
2746
|
+
action: "delete",
|
|
2747
|
+
changed: true,
|
|
2748
|
+
previousSha256,
|
|
2749
|
+
newSha256: "",
|
|
2750
|
+
reason: "stale managed file removed"
|
|
2751
|
+
};
|
|
2752
|
+
}
|
|
2753
|
+
function isPreviouslyManaged(file, previousSha256, previousHashes, previousManifest) {
|
|
2754
|
+
if (file.role === "manifest")
|
|
2755
|
+
return previousManifest !== null;
|
|
2756
|
+
if (!previousSha256)
|
|
2757
|
+
return false;
|
|
2758
|
+
return previousHashes.get(file.relativePath) === previousSha256;
|
|
2759
|
+
}
|
|
2760
|
+
function resolvePlannedFilePath(plan, file, targetHome) {
|
|
2761
|
+
const target = resolve3(targetHome, ...file.relativePath.split("/"));
|
|
2762
|
+
const rel = relative2(targetHome, target);
|
|
2763
|
+
if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute2(rel)) {
|
|
2764
|
+
throw new SessionApplyError(`Session file escapes target home: ${file.relativePath}`);
|
|
2765
|
+
}
|
|
2766
|
+
if (resolve3(file.path) !== target) {
|
|
2767
|
+
throw new SessionApplyError(`Session file path mismatch for ${file.relativePath}: ${file.path}`);
|
|
2768
|
+
}
|
|
2769
|
+
assertNoSymlinkSegments(targetHome, target);
|
|
2770
|
+
return target;
|
|
2771
|
+
}
|
|
2772
|
+
function resolveManifestRelativePath(relativePath, targetHome) {
|
|
2773
|
+
const target = resolve3(targetHome, ...relativePath.split(/[\\/]+/));
|
|
2774
|
+
const rel = relative2(targetHome, target);
|
|
2775
|
+
if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute2(rel)) {
|
|
2776
|
+
throw new SessionApplyError(`Session manifest file escapes target home: ${relativePath}`);
|
|
2777
|
+
}
|
|
2778
|
+
assertNoSymlinkSegments(targetHome, target);
|
|
2779
|
+
return target;
|
|
2780
|
+
}
|
|
2781
|
+
function readPreviousManifest(path) {
|
|
2782
|
+
if (!existsSync6(path))
|
|
2783
|
+
return null;
|
|
2784
|
+
try {
|
|
2785
|
+
const parsed = JSON.parse(readFileSync4(path, "utf-8"));
|
|
2786
|
+
if (parsed.schema !== SESSION_RENDER_SCHEMA)
|
|
2787
|
+
return null;
|
|
2788
|
+
if (!Array.isArray(parsed.files))
|
|
2789
|
+
return null;
|
|
2790
|
+
return parsed;
|
|
2791
|
+
} catch {
|
|
2792
|
+
return null;
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
function writePlannedFile(path, content, targetHome) {
|
|
2796
|
+
const dir = dirname3(path);
|
|
2797
|
+
mkdirSync3(dir, { recursive: true });
|
|
2798
|
+
assertNoSymlinkSegments(targetHome, path);
|
|
2799
|
+
const tmp = join4(dir, `.session-${randomUUID2()}.tmp`);
|
|
2800
|
+
writeFileSync2(tmp, content, "utf-8");
|
|
2801
|
+
renameSync(tmp, path);
|
|
2802
|
+
}
|
|
2803
|
+
function writeSessionSnapshot(plan, targetHome, manifestPath, results, previousManifest) {
|
|
2804
|
+
const existingFiles = results.filter((result) => result.action === "update" || result.action === "delete").filter((result) => existsSync6(result.path)).map((result) => {
|
|
2805
|
+
const content = readFileSync4(result.path, "utf-8");
|
|
2806
|
+
return {
|
|
2807
|
+
path: result.path,
|
|
2808
|
+
relativePath: result.relativePath,
|
|
2809
|
+
role: result.role,
|
|
2810
|
+
sha256: sha2562(content),
|
|
2811
|
+
content
|
|
2812
|
+
};
|
|
2813
|
+
});
|
|
2814
|
+
if (!previousManifest && existingFiles.length === 0)
|
|
2815
|
+
return null;
|
|
2816
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2817
|
+
const snapshotPath = resolve3(targetHome, ".hasna", "session-render-snapshots", `${timestamp}-${randomUUID2()}.json`);
|
|
2818
|
+
const snapshot = {
|
|
2819
|
+
schema: "hasna.configs.session-render-snapshot/v1",
|
|
2820
|
+
createdAt: new Date().toISOString(),
|
|
2821
|
+
tool: plan.tool,
|
|
2822
|
+
profile: plan.profile,
|
|
2823
|
+
targetHome,
|
|
2824
|
+
manifestPath,
|
|
2825
|
+
previousManifest,
|
|
2826
|
+
files: existingFiles
|
|
2827
|
+
};
|
|
2828
|
+
writePlannedFile(snapshotPath, `${JSON.stringify(snapshot, null, 2)}
|
|
2829
|
+
`, targetHome);
|
|
2830
|
+
return snapshotPath;
|
|
2831
|
+
}
|
|
2832
|
+
function assertSafeTargetHome(targetHome) {
|
|
2833
|
+
if (!isAbsolute2(targetHome))
|
|
2834
|
+
throw new SessionApplyError(`Session target home must be absolute: ${targetHome}`);
|
|
2835
|
+
const normalized = resolve3(targetHome);
|
|
2836
|
+
if (normalized === parse2(normalized).root) {
|
|
2837
|
+
throw new SessionApplyError(`Session target home cannot be the filesystem root: ${targetHome}`);
|
|
2838
|
+
}
|
|
2839
|
+
assertNoSymlinkAncestors(normalized);
|
|
2840
|
+
if (existsSync6(normalized) && lstatSync(normalized).isSymbolicLink()) {
|
|
2841
|
+
throw new SessionApplyError(`Session target home cannot be a symlink: ${normalized}`);
|
|
2842
|
+
}
|
|
2843
|
+
return normalized;
|
|
2844
|
+
}
|
|
2845
|
+
function assertNoSymlinkSegments(root, target) {
|
|
2846
|
+
assertNoSymlinkAncestors(root);
|
|
2847
|
+
const rel = relative2(root, target);
|
|
2848
|
+
let current = root;
|
|
2849
|
+
for (const segment of rel.split(/[\\/]+/).filter(Boolean)) {
|
|
2850
|
+
current = join4(current, segment);
|
|
2851
|
+
if (existsSync6(current) && lstatSync(current).isSymbolicLink()) {
|
|
2852
|
+
throw new SessionApplyError(`Session apply path uses a symlink: ${current}`);
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
function assertNoSymlinkAncestors(path) {
|
|
2857
|
+
const normalized = resolve3(path);
|
|
2858
|
+
const parsed = parse2(normalized);
|
|
2859
|
+
let current = parsed.root;
|
|
2860
|
+
const rel = relative2(parsed.root, normalized);
|
|
2861
|
+
for (const segment of rel.split(/[\\/]+/).filter(Boolean)) {
|
|
2862
|
+
current = join4(current, segment);
|
|
2863
|
+
if (!existsSync6(current))
|
|
2864
|
+
return;
|
|
2865
|
+
if (lstatSync(current).isSymbolicLink()) {
|
|
2866
|
+
throw new SessionApplyError(`Session apply path uses a symlink ancestor: ${current}`);
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
function sha2562(content) {
|
|
2871
|
+
return createHash2("sha256").update(content).digest("hex");
|
|
2872
|
+
}
|
|
2873
|
+
// src/lib/project-dashboard-standard.ts
|
|
2874
|
+
var PROJECT_DASHBOARD_STANDARD_SLUG = "agent-managed-project-dashboard-standard";
|
|
2875
|
+
var PROJECT_DASHBOARD_PROFILE_VARIABLES = {
|
|
2876
|
+
PROJECT_DASHBOARD_DIR: ".hasna/project",
|
|
2877
|
+
PROJECT_DASHBOARD_RENDER_MANIFEST: ".hasna/project/dashboard/render.json",
|
|
2878
|
+
PROJECT_DASHBOARD_SNAPSHOTS_DIR: ".hasna/project/dashboard/snapshots",
|
|
2879
|
+
PROJECT_CHANNEL_PREFIX: "iproj-"
|
|
2880
|
+
};
|
|
2881
|
+
var PROJECT_DASHBOARD_STANDARD_CONTENT = `# Agent-Managed Project Dashboard Standard
|
|
2882
|
+
|
|
2883
|
+
This standard applies to Hasna work projects under \`Workspace/<division>/project/<slug>\`.
|
|
2884
|
+
Humans do not work directly inside these folders; agents keep structure,
|
|
2885
|
+
evidence, tasks, knowledge, and dashboard output consistent.
|
|
2886
|
+
|
|
2887
|
+
## Canonical Files
|
|
2888
|
+
|
|
2889
|
+
- Project manifest root: \`.hasna/project/\`
|
|
2890
|
+
- Dashboard render manifest: \`.hasna/project/dashboard/render.json\`
|
|
2891
|
+
- Latest snapshot: \`.hasna/project/dashboard/snapshots/latest.snapshot.json\`
|
|
2892
|
+
- Dashboard schema ids come from \`@hasna/contracts\`.
|
|
2893
|
+
- Project folders may contain private documents, but render JSON must contain
|
|
2894
|
+
only ids, counts, statuses, resource refs, evidence refs, and redacted
|
|
2895
|
+
summaries.
|
|
2896
|
+
|
|
2897
|
+
## Viewer Commands
|
|
2898
|
+
|
|
2899
|
+
Use the Projects-owned viewer. Do not invent a separate per-project app unless
|
|
2900
|
+
\`open-projects\` cannot express the surface.
|
|
2901
|
+
|
|
2902
|
+
\`\`\`bash
|
|
2903
|
+
projects dashboard snapshot <project> --write --json
|
|
2904
|
+
projects dashboard render <project> --json
|
|
2905
|
+
projects dashboard validate <project> --json
|
|
2906
|
+
PROJECTS_DASHBOARD_TOKEN=<token> projects dashboard serve <project> --host 0.0.0.0 --port <port>
|
|
2907
|
+
\`\`\`
|
|
2908
|
+
|
|
2909
|
+
Non-loopback serving must use \`--token\`, \`PROJECTS_DASHBOARD_TOKEN\`, or an
|
|
2910
|
+
explicit \`--trust-network\` choice. Never put the token in a URL, task
|
|
2911
|
+
evidence, render spec, or report.
|
|
2912
|
+
|
|
2913
|
+
## Provider Panels
|
|
2914
|
+
|
|
2915
|
+
Provider CLIs emit bounded \`hasna.project_panel.v1\` summaries:
|
|
2916
|
+
|
|
2917
|
+
\`\`\`bash
|
|
2918
|
+
todos project-panel --project <project> --json --contract
|
|
2919
|
+
files project-panel --project <project> --json --contract
|
|
2920
|
+
mailery project-panel --project <project> --limit 20 --json --contract
|
|
2921
|
+
conversations project-panel --project <project> --limit 30 --json --contract
|
|
2922
|
+
knowledge project-panel --project <project> --scope project --limit 30 --json --contract
|
|
2923
|
+
mementos --json project-panel --project <project> --contract
|
|
2924
|
+
reports project-panel --project <project> --json --contract
|
|
2925
|
+
\`\`\`
|
|
2926
|
+
|
|
2927
|
+
Providers must degrade to unavailable/error panels instead of dumping raw
|
|
2928
|
+
content. Mailery is workspace-scoped until explicit project-email mapping is
|
|
2929
|
+
configured; Knowledge must run from the project cwd or with the correct store
|
|
2930
|
+
context.
|
|
2931
|
+
|
|
2932
|
+
## Coordination
|
|
2933
|
+
|
|
2934
|
+
- Project conversation channels use \`iproj-<project-slug>\` in the CLI and are
|
|
2935
|
+
displayed to humans as \`#iproj-<project-slug>\`.
|
|
2936
|
+
- Todos tasks are the source of truth for work; messages are only coordination.
|
|
2937
|
+
- Durable Codewith goal plans should own long-running implementation.
|
|
2938
|
+
- New implementation/verification work should route through task-triggered fresh
|
|
2939
|
+
agent runs, not by opening or pasting into existing tmux panes.
|
|
2940
|
+
- Agent handoff should reference task ids, project ids, commit ids, evidence
|
|
2941
|
+
paths, and dashboard URLs without exposing secrets or raw private documents.
|
|
2942
|
+
|
|
2943
|
+
## Report Rules
|
|
2944
|
+
|
|
2945
|
+
Reports and dashboards should use JSON Render/React Flow through
|
|
2946
|
+
\`projects dashboard render\`. Include decisions, open questions, bank/document
|
|
2947
|
+
ids, tasks, and evidence refs. Exclude raw email bodies, account numbers, tax
|
|
2948
|
+
ids, passport numbers, credentials, and contract clauses unless an explicit
|
|
2949
|
+
approved storage policy exists.
|
|
2950
|
+
`;
|
|
2951
|
+
function ensureProjectDashboardStandardConfig(db) {
|
|
2952
|
+
const input = {
|
|
2953
|
+
name: "Agent Managed Project Dashboard Standard",
|
|
2954
|
+
category: "workspace",
|
|
2955
|
+
agent: "global",
|
|
2956
|
+
format: "markdown",
|
|
2957
|
+
content: PROJECT_DASHBOARD_STANDARD_CONTENT,
|
|
2958
|
+
kind: "reference",
|
|
2959
|
+
description: "Standard structure, provider commands, channel naming, and safety rules for agent-managed project dashboards",
|
|
2960
|
+
tags: ["projects-dashboard", "agent-projects", "json-render"]
|
|
2961
|
+
};
|
|
2962
|
+
try {
|
|
2963
|
+
const existing = getConfig(PROJECT_DASHBOARD_STANDARD_SLUG, db);
|
|
2964
|
+
if (existing.content !== input.content || existing.description !== input.description || existing.category !== input.category || existing.agent !== input.agent || existing.format !== input.format || existing.kind !== input.kind) {
|
|
2965
|
+
return updateConfig(existing.id, input, db);
|
|
2966
|
+
}
|
|
2967
|
+
return existing;
|
|
2968
|
+
} catch {
|
|
2969
|
+
return createConfig(input, db);
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
// src/lib/platform-profiles.ts
|
|
2974
|
+
var PLATFORM_PROFILE_PRESETS = [
|
|
2975
|
+
{
|
|
2976
|
+
name: "linux-arm64",
|
|
2977
|
+
description: "Default Linux arm64 profile for linux-node-a/linux-node-b-style machines",
|
|
2978
|
+
selectors: { os: ["linux"], arch: ["arm64"], hostnames: ["linux-node-a", "linux-node-b"] },
|
|
2979
|
+
variables: {
|
|
2980
|
+
WORKSPACE_ROOT: "{{HOME_DIR}}/workspace",
|
|
2981
|
+
BUN_BIN_DIR: "{{HOME_DIR}}/.bun/bin",
|
|
2982
|
+
BUN_PATH: "{{BUN_BIN_DIR}}/bun",
|
|
2983
|
+
PATH_PREFIX: "{{BUN_BIN_DIR}}",
|
|
2984
|
+
...PROJECT_DASHBOARD_PROFILE_VARIABLES
|
|
2985
|
+
}
|
|
2986
|
+
},
|
|
2987
|
+
{
|
|
2988
|
+
name: "macos-arm64",
|
|
2989
|
+
description: "Default macOS arm64 profile for macos-node-a/macos-node-b-style machines",
|
|
2990
|
+
selectors: { os: ["macos"], arch: ["arm64"], hostnames: ["macos-node-a", "macos-node-b"] },
|
|
2991
|
+
variables: {
|
|
2992
|
+
WORKSPACE_ROOT: "{{HOME_DIR}}/Workspace",
|
|
2993
|
+
BUN_BIN_DIR: "{{HOME_DIR}}/.bun/bin",
|
|
2994
|
+
BUN_PATH: "/opt/homebrew/bin/bun",
|
|
2995
|
+
PATH_PREFIX: "/opt/homebrew/bin:{{BUN_BIN_DIR}}",
|
|
2996
|
+
...PROJECT_DASHBOARD_PROFILE_VARIABLES
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
];
|
|
3000
|
+
function ensurePlatformProfiles(db) {
|
|
3001
|
+
const configs = listConfigs(undefined, db);
|
|
3002
|
+
const ensured = [];
|
|
3003
|
+
for (const preset of PLATFORM_PROFILE_PRESETS) {
|
|
3004
|
+
let profile;
|
|
3005
|
+
try {
|
|
3006
|
+
profile = getProfile(preset.name, db);
|
|
3007
|
+
if (!profileHasSelectors(profile) || Object.keys(profile.variables).length === 0) {
|
|
3008
|
+
profile = updateProfile(profile.id, {
|
|
3009
|
+
description: profile.description ?? preset.description,
|
|
3010
|
+
selectors: profileHasSelectors(profile) ? profile.selectors : preset.selectors,
|
|
3011
|
+
variables: Object.keys(profile.variables).length > 0 ? profile.variables : preset.variables
|
|
3012
|
+
}, db);
|
|
3013
|
+
}
|
|
3014
|
+
} catch {
|
|
3015
|
+
profile = createProfile(preset, db);
|
|
3016
|
+
}
|
|
3017
|
+
for (const config of configs) {
|
|
3018
|
+
addConfigToProfile(profile.id, config.id, db);
|
|
3019
|
+
}
|
|
3020
|
+
ensured.push(profile);
|
|
3021
|
+
}
|
|
3022
|
+
return ensured;
|
|
3023
|
+
}
|
|
3024
|
+
// src/lib/sync.ts
|
|
3025
|
+
import { existsSync as existsSync8, readdirSync as readdirSync2, readFileSync as readFileSync6 } from "fs";
|
|
3026
|
+
import { basename as basename4, extname as extname3, join as join6 } from "path";
|
|
3027
|
+
|
|
3028
|
+
// src/lib/sync-dir.ts
|
|
3029
|
+
import { existsSync as existsSync7, readdirSync, readFileSync as readFileSync5, statSync as statSync3 } from "fs";
|
|
3030
|
+
import { join as join5, relative as relative3 } from "path";
|
|
3031
|
+
import { homedir as homedir4 } from "os";
|
|
3032
|
+
var SKIP = [".db", ".db-shm", ".db-wal", ".log", ".lock", ".DS_Store", "node_modules", ".git"];
|
|
3033
|
+
function shouldSkip(p) {
|
|
3034
|
+
return SKIP.some((s) => p.includes(s));
|
|
3035
|
+
}
|
|
3036
|
+
async function syncFromDir(dir, opts = {}) {
|
|
3037
|
+
const d = opts.db || getDatabase();
|
|
3038
|
+
const absDir = expandPath(dir);
|
|
3039
|
+
if (!existsSync7(absDir))
|
|
3040
|
+
return { added: 0, updated: 0, unchanged: 0, skipped: [`Not found: ${absDir}`] };
|
|
3041
|
+
const files = opts.recursive !== false ? walkDir(absDir) : readdirSync(absDir).map((f) => join5(absDir, f)).filter((f) => statSync3(f).isFile());
|
|
3042
|
+
const result = { added: 0, updated: 0, unchanged: 0, skipped: [] };
|
|
3043
|
+
const home = homedir4();
|
|
3044
|
+
const allConfigs = listConfigs(undefined, d);
|
|
3045
|
+
for (const file of files) {
|
|
3046
|
+
if (shouldSkip(file)) {
|
|
3047
|
+
result.skipped.push(file);
|
|
3048
|
+
continue;
|
|
3049
|
+
}
|
|
3050
|
+
try {
|
|
3051
|
+
const content = readFileSync5(file, "utf-8");
|
|
3052
|
+
if (content.length > 500000) {
|
|
3053
|
+
result.skipped.push(file + " (too large)");
|
|
3054
|
+
continue;
|
|
3055
|
+
}
|
|
3056
|
+
const targetPath = file.replace(home, "~");
|
|
3057
|
+
const existing = allConfigs.find((c) => c.target_path === targetPath);
|
|
3058
|
+
if (!existing) {
|
|
3059
|
+
if (!opts.dryRun)
|
|
3060
|
+
createConfig({ name: relative3(absDir, file), category: detectCategory(file), agent: detectAgent(file), target_path: targetPath, format: detectFormat(file), content }, d);
|
|
3061
|
+
result.added++;
|
|
3062
|
+
} else if (existing.content !== content) {
|
|
3063
|
+
if (!opts.dryRun)
|
|
3064
|
+
updateConfig(existing.id, { content }, d);
|
|
3065
|
+
result.updated++;
|
|
3066
|
+
} else {
|
|
3067
|
+
result.unchanged++;
|
|
3068
|
+
}
|
|
3069
|
+
} catch {
|
|
3070
|
+
result.skipped.push(file);
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
return result;
|
|
3074
|
+
}
|
|
3075
|
+
async function syncToDir(dir, opts = {}) {
|
|
3076
|
+
const d = opts.db || getDatabase();
|
|
3077
|
+
const home = homedir4();
|
|
3078
|
+
const absDir = expandPath(dir);
|
|
3079
|
+
const normalized = dir.startsWith("~/") ? dir : absDir.replace(home, "~");
|
|
3080
|
+
const configs = listConfigs(undefined, d).filter((c) => c.target_path && (c.target_path.startsWith(normalized) || c.target_path.startsWith(absDir)));
|
|
3081
|
+
const result = { added: 0, updated: 0, unchanged: 0, skipped: [] };
|
|
3082
|
+
for (const config of configs) {
|
|
3083
|
+
if (config.kind === "reference")
|
|
3084
|
+
continue;
|
|
3085
|
+
try {
|
|
3086
|
+
const r = await applyConfig(config, { dryRun: opts.dryRun, db: d });
|
|
3087
|
+
r.changed ? result.updated++ : result.unchanged++;
|
|
3088
|
+
} catch {
|
|
3089
|
+
result.skipped.push(config.target_path || config.id);
|
|
3090
|
+
}
|
|
3091
|
+
}
|
|
3092
|
+
return result;
|
|
3093
|
+
}
|
|
3094
|
+
function walkDir(dir, files = []) {
|
|
3095
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
3096
|
+
const full = join5(dir, entry.name);
|
|
3097
|
+
if (shouldSkip(full))
|
|
3098
|
+
continue;
|
|
3099
|
+
if (entry.isDirectory())
|
|
3100
|
+
walkDir(full, files);
|
|
3101
|
+
else if (entry.isFile())
|
|
3102
|
+
files.push(full);
|
|
3103
|
+
}
|
|
3104
|
+
return files;
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
// src/lib/sync.ts
|
|
3108
|
+
var CLAUDE_PROMPT_OUTPUTS = [
|
|
3109
|
+
{ agent: "codex", target_path: "~/.codex/AGENTS.md", transform: "codex-flat" },
|
|
3110
|
+
{ agent: "codewith", target_path: "~/.codewith/CODEWITH.md", transform: "codex-flat" },
|
|
3111
|
+
{ agent: "opencode", target_path: "~/.config/opencode/AGENTS.md", transform: "opencode-flat" },
|
|
3112
|
+
{ agent: "aicopilot", target_path: "~/.config/aicopilot/AGENTS.md", transform: "opencode-flat" },
|
|
3113
|
+
{ agent: "cursor", target_path: "~/.cursor/rules/claude.mdc", transform: "cursor-mdc" }
|
|
3114
|
+
];
|
|
3115
|
+
function claudeRuleOutputs(fileName) {
|
|
3116
|
+
const stem = basename4(fileName, extname3(fileName));
|
|
3117
|
+
return [
|
|
3118
|
+
{ agent: "cursor", target_path: `~/.cursor/rules/${stem}.mdc`, transform: "cursor-mdc" }
|
|
3119
|
+
];
|
|
3120
|
+
}
|
|
3121
|
+
function normalizeOutputs(outputs) {
|
|
3122
|
+
return outputs ?? [];
|
|
3123
|
+
}
|
|
3124
|
+
function outputsEqual(a, b) {
|
|
3125
|
+
return JSON.stringify(normalizeOutputs(a)) === JSON.stringify(normalizeOutputs(b));
|
|
3126
|
+
}
|
|
3127
|
+
function outputOwnerIdsByTarget(configs) {
|
|
3128
|
+
const owners = new Map;
|
|
3129
|
+
for (const config of configs) {
|
|
3130
|
+
for (const output of config.outputs) {
|
|
3131
|
+
const targetPath = normalizeTargetPath(output.target_path);
|
|
3132
|
+
const existing = owners.get(targetPath) ?? new Set;
|
|
3133
|
+
existing.add(config.id);
|
|
3134
|
+
owners.set(targetPath, existing);
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
return owners;
|
|
3138
|
+
}
|
|
3139
|
+
function isGeneratedOutputTarget2(config, owners) {
|
|
3140
|
+
if (!config.target_path)
|
|
3141
|
+
return false;
|
|
3142
|
+
const ownerIds = owners.get(normalizeTargetPath(config.target_path));
|
|
3143
|
+
return !!ownerIds && !ownerIds.has(config.id);
|
|
3144
|
+
}
|
|
3145
|
+
function hasClaudePromptSource() {
|
|
3146
|
+
return existsSync8(expandPath("~/.claude/CLAUDE.md"));
|
|
3147
|
+
}
|
|
3148
|
+
function hasClaudeRuleSourceForCursorTarget(targetPath) {
|
|
3149
|
+
const absoluteTargetPath = expandPath(targetPath);
|
|
3150
|
+
const absolutePrefix = expandPath("~/.cursor/rules");
|
|
3151
|
+
if (!absoluteTargetPath.startsWith(`${absolutePrefix}/`) || !absoluteTargetPath.endsWith(".mdc"))
|
|
3152
|
+
return false;
|
|
3153
|
+
const stem = basename4(absoluteTargetPath, ".mdc");
|
|
3154
|
+
return existsSync8(expandPath(`~/.claude/rules/${stem}.md`)) || existsSync8(expandPath(`~/.claude/rules/${stem}.mdc`));
|
|
3155
|
+
}
|
|
3156
|
+
function isKnownGeneratedTargetPath(targetPath) {
|
|
3157
|
+
const normalizedTargetPath = normalizeTargetPath(targetPath);
|
|
3158
|
+
const promptTargets = new Set(CLAUDE_PROMPT_OUTPUTS.map((output) => normalizeTargetPath(output.target_path)));
|
|
3159
|
+
if (promptTargets.has(normalizedTargetPath))
|
|
3160
|
+
return hasClaudePromptSource();
|
|
3161
|
+
return hasClaudeRuleSourceForCursorTarget(targetPath);
|
|
3162
|
+
}
|
|
3163
|
+
var KNOWN_CONFIGS = [
|
|
3164
|
+
{ path: "~/.claude/CLAUDE.md", name: "claude-claude-md", category: "rules", agent: "claude", format: "markdown", outputs: CLAUDE_PROMPT_OUTPUTS },
|
|
3165
|
+
{ path: "~/.claude/settings.json", name: "claude-settings", category: "agent", agent: "claude", format: "json" },
|
|
3166
|
+
{ path: "~/.claude/settings.local.json", name: "claude-settings-local", category: "agent", agent: "claude", format: "json" },
|
|
3167
|
+
{ path: "~/.claude/keybindings.json", name: "claude-keybindings", category: "agent", agent: "claude", format: "json", optional: true },
|
|
3168
|
+
{ path: "~/.claude/rules", name: "claude-rules", category: "rules", agent: "claude", rulesDir: "~/.claude/rules", rulesExtensions: [".md", ".mdc"] },
|
|
3169
|
+
{ path: "~/.codex/config.toml", name: "codex-config", category: "agent", agent: "codex", format: "toml" },
|
|
3170
|
+
{ path: "~/.codex/AGENTS.md", name: "codex-agents-md", category: "rules", agent: "codex", format: "markdown" },
|
|
3171
|
+
{ path: "~/.config/opencode/AGENTS.md", name: "opencode-agents-md", category: "rules", agent: "opencode", format: "markdown", optional: true },
|
|
3172
|
+
{ path: "~/.config/opencode/opencode.json", name: "opencode-config", category: "mcp", agent: "opencode", format: "json", optional: true, description: "OpenCode config (includes Skills MCP server entries)" },
|
|
3173
|
+
{ path: "~/.cursor/rules", name: "cursor-rules", category: "rules", agent: "cursor", rulesDir: "~/.cursor/rules", rulesExtensions: [".md", ".mdc"] },
|
|
3174
|
+
{ path: "~/.cursor/mcp.json", name: "cursor-mcp", category: "mcp", agent: "cursor", format: "json", optional: true, description: "Cursor MCP config (includes Skills MCP server entries)" },
|
|
3175
|
+
{ path: "~/.codewith/CODEWITH.md", name: "codewith-codewith-md", category: "rules", agent: "codewith", format: "markdown", optional: true },
|
|
3176
|
+
{ path: "~/.codewith/config.toml", name: "codewith-config", category: "mcp", agent: "codewith", format: "toml", optional: true, description: "codewith config (Codex fork, includes Skills MCP server entries)" },
|
|
3177
|
+
{ path: "~/.config/aicopilot/AGENTS.md", name: "aicopilot-agents-md", category: "rules", agent: "aicopilot", format: "markdown", optional: true },
|
|
3178
|
+
{ path: "~/.config/aicopilot/opencode.json", name: "aicopilot-config", category: "mcp", agent: "aicopilot", format: "json", optional: true, description: "AI Copilot config (OpenCode fork, includes Skills MCP server entries)" },
|
|
3179
|
+
{ path: "~/.gemini/settings.json", name: "gemini-settings", category: "agent", agent: "gemini", format: "json" },
|
|
3180
|
+
{ path: "~/.gemini/GEMINI.md", name: "gemini-gemini-md", category: "rules", agent: "gemini", format: "markdown", optional: true },
|
|
3181
|
+
{ path: "~/.claude.json", name: "claude-json", category: "mcp", agent: "claude", format: "json", description: "Claude Code global config (includes MCP server entries)" },
|
|
3182
|
+
{ path: "~/.zshrc", name: "zshrc", category: "shell", agent: "zsh" },
|
|
3183
|
+
{ path: "~/.zprofile", name: "zprofile", category: "shell", agent: "zsh", optional: true },
|
|
3184
|
+
{ path: "~/.bashrc", name: "bashrc", category: "shell", agent: "zsh", optional: true },
|
|
3185
|
+
{ path: "~/.bash_profile", name: "bash-profile", category: "shell", agent: "zsh", optional: true },
|
|
3186
|
+
{ path: "~/.gitconfig", name: "gitconfig", category: "git", agent: "git", format: "ini" },
|
|
3187
|
+
{ path: "~/.gitignore_global", name: "gitignore-global", category: "git", agent: "git", optional: true },
|
|
3188
|
+
{ path: "~/.npmrc", name: "npmrc", category: "tools", agent: "npm", format: "ini" },
|
|
3189
|
+
{ path: "~/.bunfig.toml", name: "bunfig", category: "tools", agent: "global", format: "toml", optional: true }
|
|
3190
|
+
];
|
|
3191
|
+
var PROJECT_CONFIG_FILES = [
|
|
3192
|
+
{ file: "CLAUDE.md", category: "rules", agent: "claude", format: "markdown" },
|
|
3193
|
+
{ file: ".claude/settings.json", category: "agent", agent: "claude", format: "json" },
|
|
3194
|
+
{ file: ".claude/settings.local.json", category: "agent", agent: "claude", format: "json" },
|
|
3195
|
+
{ file: ".mcp.json", category: "mcp", agent: "claude", format: "json" },
|
|
3196
|
+
{ file: "AGENTS.md", category: "rules", agent: "codex", format: "markdown" },
|
|
3197
|
+
{ file: ".codex/AGENTS.md", category: "rules", agent: "codex", format: "markdown" },
|
|
3198
|
+
{ file: "GEMINI.md", category: "rules", agent: "gemini", format: "markdown" },
|
|
3199
|
+
{ file: ".opencode/AGENTS.md", category: "rules", agent: "opencode", format: "markdown" },
|
|
3200
|
+
{ file: ".codewith/CODEWITH.md", category: "rules", agent: "codewith", format: "markdown" },
|
|
3201
|
+
{ file: ".cursor/mcp.json", category: "mcp", agent: "cursor", format: "json" }
|
|
3202
|
+
];
|
|
3203
|
+
async function syncProject(opts) {
|
|
3204
|
+
const d = opts.db || getDatabase();
|
|
3205
|
+
const absDir = expandPath(opts.projectDir);
|
|
3206
|
+
const projectName = absDir.split("/").pop() || "project";
|
|
3207
|
+
const result = { added: 0, updated: 0, unchanged: 0, skipped: [] };
|
|
3208
|
+
const allConfigs = listConfigs(undefined, d);
|
|
3209
|
+
const machine = detectMachineContext();
|
|
3210
|
+
for (const pf of PROJECT_CONFIG_FILES) {
|
|
3211
|
+
const abs = join6(absDir, pf.file);
|
|
3212
|
+
if (!existsSync8(abs))
|
|
3213
|
+
continue;
|
|
3214
|
+
try {
|
|
3215
|
+
const rawContent = readFileSync6(abs, "utf-8");
|
|
3216
|
+
if (rawContent.length > 500000) {
|
|
3217
|
+
result.skipped.push(pf.file);
|
|
3218
|
+
continue;
|
|
3219
|
+
}
|
|
3220
|
+
const redacted = redactContent(rawContent, pf.format);
|
|
3221
|
+
const machineAware = templateizeMachineContent(redacted.content, machine);
|
|
3222
|
+
const content = machineAware.content;
|
|
3223
|
+
const isTemplate2 = redacted.isTemplate || machineAware.changed;
|
|
3224
|
+
const name = `${projectName}/${pf.file}`;
|
|
3225
|
+
const targetPath = abs.startsWith(getConfigHome()) ? abs.replace(getConfigHome(), "~") : abs;
|
|
3226
|
+
const slug2 = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
3227
|
+
const existing = allConfigs.find((c) => c.target_path === targetPath || c.slug === slug2);
|
|
3228
|
+
if (!existing) {
|
|
3229
|
+
if (!opts.dryRun)
|
|
3230
|
+
createConfig({ name, category: pf.category, agent: pf.agent, format: pf.format, content, target_path: targetPath, is_template: isTemplate2 }, d);
|
|
3231
|
+
result.added++;
|
|
3232
|
+
} else if (existing.content !== content) {
|
|
3233
|
+
if (!opts.dryRun)
|
|
3234
|
+
updateConfig(existing.id, { content, is_template: isTemplate2 }, d);
|
|
3235
|
+
result.updated++;
|
|
3236
|
+
} else {
|
|
3237
|
+
result.unchanged++;
|
|
3238
|
+
}
|
|
3239
|
+
} catch {
|
|
3240
|
+
result.skipped.push(pf.file);
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
const rulesDir = join6(absDir, ".claude", "rules");
|
|
3244
|
+
if (existsSync8(rulesDir)) {
|
|
3245
|
+
const mdFiles = readdirSync2(rulesDir).filter((f) => f.endsWith(".md") || f.endsWith(".mdc"));
|
|
3246
|
+
for (const f of mdFiles) {
|
|
3247
|
+
const abs = join6(rulesDir, f);
|
|
3248
|
+
const raw = readFileSync6(abs, "utf-8");
|
|
3249
|
+
const redacted = redactContent(raw, "markdown");
|
|
3250
|
+
const machineAware = templateizeMachineContent(redacted.content, machine);
|
|
3251
|
+
const content = machineAware.content;
|
|
3252
|
+
const isTemplate2 = redacted.isTemplate || machineAware.changed;
|
|
3253
|
+
const name = `${projectName}/rules/${f}`;
|
|
3254
|
+
const targetPath = abs.startsWith(getConfigHome()) ? abs.replace(getConfigHome(), "~") : abs;
|
|
3255
|
+
const slug2 = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
3256
|
+
const existing = allConfigs.find((c) => c.target_path === targetPath || c.slug === slug2);
|
|
3257
|
+
if (!existing) {
|
|
3258
|
+
if (!opts.dryRun)
|
|
3259
|
+
createConfig({ name, category: "rules", agent: "claude", format: "markdown", content, target_path: targetPath, is_template: isTemplate2 }, d);
|
|
3260
|
+
result.added++;
|
|
3261
|
+
} else if (existing.content !== content) {
|
|
3262
|
+
if (!opts.dryRun)
|
|
3263
|
+
updateConfig(existing.id, { content, is_template: isTemplate2 }, d);
|
|
3264
|
+
result.updated++;
|
|
3265
|
+
} else {
|
|
3266
|
+
result.unchanged++;
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
return result;
|
|
3271
|
+
}
|
|
3272
|
+
async function syncKnown(opts = {}) {
|
|
3273
|
+
const d = opts.db || getDatabase();
|
|
3274
|
+
const result = { added: 0, updated: 0, unchanged: 0, skipped: [] };
|
|
3275
|
+
const home = getConfigHome();
|
|
3276
|
+
const machine = detectMachineContext();
|
|
3277
|
+
let targets = KNOWN_CONFIGS;
|
|
3278
|
+
if (opts.agent)
|
|
3279
|
+
targets = targets.filter((k) => k.agent === opts.agent);
|
|
3280
|
+
if (opts.category)
|
|
3281
|
+
targets = targets.filter((k) => k.category === opts.category);
|
|
3282
|
+
const allConfigs = listConfigs(undefined, d);
|
|
3283
|
+
const existingOutputOwners = outputOwnerIdsByTarget(allConfigs);
|
|
3284
|
+
for (const known of targets) {
|
|
3285
|
+
if (known.rulesDir) {
|
|
3286
|
+
const absDir = expandPath(known.rulesDir);
|
|
3287
|
+
if (!existsSync8(absDir)) {
|
|
3288
|
+
result.skipped.push(known.rulesDir);
|
|
3289
|
+
continue;
|
|
3290
|
+
}
|
|
3291
|
+
const extensions = known.rulesExtensions ?? [".md", ".mdc"];
|
|
3292
|
+
const ruleFiles = readdirSync2(absDir).filter((f) => extensions.some((ext) => f.endsWith(ext)));
|
|
3293
|
+
for (const f of ruleFiles) {
|
|
3294
|
+
const abs2 = join6(absDir, f);
|
|
3295
|
+
const targetPath = abs2.replace(home, "~");
|
|
3296
|
+
if (existingOutputOwners.has(normalizeTargetPath(targetPath)) || isKnownGeneratedTargetPath(targetPath)) {
|
|
3297
|
+
result.skipped.push(`${targetPath} (generated output)`);
|
|
3298
|
+
continue;
|
|
3299
|
+
}
|
|
3300
|
+
const raw = readFileSync6(abs2, "utf-8");
|
|
3301
|
+
const redacted = redactContent(raw, "markdown");
|
|
3302
|
+
const machineAware = templateizeMachineContent(redacted.content, machine);
|
|
3303
|
+
const content = machineAware.content;
|
|
3304
|
+
const isTemplate2 = redacted.isTemplate || machineAware.changed;
|
|
3305
|
+
const name = `${known.name}-${f}`;
|
|
3306
|
+
const slug2 = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
3307
|
+
const existing = allConfigs.find((c) => c.target_path === targetPath || c.slug === slug2);
|
|
3308
|
+
const outputs = known.agent === "claude" ? claudeRuleOutputs(f) : known.outputs;
|
|
3309
|
+
if (!existing) {
|
|
3310
|
+
if (!opts.dryRun)
|
|
3311
|
+
createConfig({ name, category: known.category, agent: known.agent, format: "markdown", content, target_path: targetPath, is_template: isTemplate2, outputs }, d);
|
|
3312
|
+
result.added++;
|
|
3313
|
+
} else if (existing.content !== content) {
|
|
3314
|
+
if (!opts.dryRun)
|
|
3315
|
+
updateConfig(existing.id, { content, is_template: isTemplate2, outputs }, d);
|
|
3316
|
+
result.updated++;
|
|
3317
|
+
} else if (!outputsEqual(existing.outputs, outputs)) {
|
|
3318
|
+
if (!opts.dryRun)
|
|
3319
|
+
updateConfig(existing.id, { outputs }, d);
|
|
3320
|
+
result.updated++;
|
|
3321
|
+
} else {
|
|
3322
|
+
result.unchanged++;
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
continue;
|
|
3326
|
+
}
|
|
3327
|
+
const abs = expandPath(known.path);
|
|
3328
|
+
if (!existsSync8(abs)) {
|
|
3329
|
+
result.skipped.push(known.path);
|
|
3330
|
+
continue;
|
|
3331
|
+
}
|
|
3332
|
+
try {
|
|
3333
|
+
const rawContent = readFileSync6(abs, "utf-8");
|
|
3334
|
+
if (rawContent.length > 500000) {
|
|
3335
|
+
result.skipped.push(known.path + " (too large)");
|
|
3336
|
+
continue;
|
|
3337
|
+
}
|
|
3338
|
+
const fmt = known.format ?? detectFormat(abs);
|
|
3339
|
+
const redacted = redactContent(rawContent, fmt);
|
|
3340
|
+
const machineAware = templateizeMachineContent(redacted.content, machine);
|
|
3341
|
+
const content = machineAware.content;
|
|
3342
|
+
const isTemplate2 = redacted.isTemplate || machineAware.changed;
|
|
3343
|
+
const targetPath = abs.replace(home, "~");
|
|
3344
|
+
if (existingOutputOwners.has(normalizeTargetPath(targetPath)) || isKnownGeneratedTargetPath(targetPath)) {
|
|
3345
|
+
result.skipped.push(`${targetPath} (generated output)`);
|
|
3346
|
+
continue;
|
|
3347
|
+
}
|
|
3348
|
+
const existing = allConfigs.find((c) => c.target_path === targetPath || c.slug === known.name);
|
|
3349
|
+
if (!existing) {
|
|
3350
|
+
if (!opts.dryRun) {
|
|
3351
|
+
createConfig({
|
|
3352
|
+
name: known.name,
|
|
3353
|
+
category: known.category,
|
|
3354
|
+
agent: known.agent,
|
|
3355
|
+
format: fmt,
|
|
3356
|
+
content,
|
|
3357
|
+
target_path: known.kind === "reference" ? null : targetPath,
|
|
3358
|
+
kind: known.kind ?? "file",
|
|
3359
|
+
description: known.description,
|
|
3360
|
+
is_template: isTemplate2,
|
|
3361
|
+
outputs: known.outputs
|
|
3362
|
+
}, d);
|
|
3363
|
+
}
|
|
3364
|
+
result.added++;
|
|
3365
|
+
} else if (existing.content !== content) {
|
|
3366
|
+
if (!opts.dryRun)
|
|
3367
|
+
updateConfig(existing.id, { content, is_template: isTemplate2, outputs: known.outputs }, d);
|
|
3368
|
+
result.updated++;
|
|
3369
|
+
} else if (!outputsEqual(existing.outputs, known.outputs)) {
|
|
3370
|
+
if (!opts.dryRun)
|
|
3371
|
+
updateConfig(existing.id, { outputs: known.outputs }, d);
|
|
3372
|
+
result.updated++;
|
|
3373
|
+
} else {
|
|
3374
|
+
result.unchanged++;
|
|
3375
|
+
}
|
|
3376
|
+
} catch {
|
|
3377
|
+
result.skipped.push(known.path);
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
return result;
|
|
3381
|
+
}
|
|
3382
|
+
async function syncToDisk(opts = {}) {
|
|
3383
|
+
const d = opts.db || getDatabase();
|
|
3384
|
+
const result = { added: 0, updated: 0, unchanged: 0, skipped: [] };
|
|
3385
|
+
const allFileConfigs = listConfigs({ kind: "file", ...opts.category ? { category: opts.category } : {} }, d);
|
|
3386
|
+
const outputOwners = outputOwnerIdsByTarget(allFileConfigs);
|
|
3387
|
+
let configs = allFileConfigs.filter((config) => {
|
|
3388
|
+
return !isGeneratedOutputTarget2(config, outputOwners);
|
|
3389
|
+
}).filter((config) => {
|
|
3390
|
+
if (!opts.agent)
|
|
3391
|
+
return true;
|
|
3392
|
+
return config.agent === opts.agent || config.outputs.some((output) => output.agent === opts.agent);
|
|
3393
|
+
});
|
|
3394
|
+
for (const config of configs) {
|
|
3395
|
+
if (!config.target_path && config.outputs.length === 0)
|
|
3396
|
+
continue;
|
|
3397
|
+
try {
|
|
3398
|
+
const r = await applyConfig(config, { dryRun: opts.dryRun, db: d, outputAgent: opts.agent });
|
|
3399
|
+
r.changed ? result.updated++ : result.unchanged++;
|
|
3400
|
+
} catch {
|
|
3401
|
+
result.skipped.push(config.target_path ?? config.id);
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
return result;
|
|
3405
|
+
}
|
|
3406
|
+
function buildDiff(expectedContent, targetPath) {
|
|
3407
|
+
const path = expandPath(targetPath);
|
|
3408
|
+
if (!existsSync8(path))
|
|
3409
|
+
return `(file not found on disk: ${path})`;
|
|
3410
|
+
const diskContent = readFileSync6(path, "utf-8");
|
|
3411
|
+
if (diskContent === expectedContent)
|
|
3412
|
+
return "(no diff \u2014 identical)";
|
|
3413
|
+
const stored = expectedContent.split(`
|
|
3414
|
+
`);
|
|
3415
|
+
const disk = diskContent.split(`
|
|
3416
|
+
`);
|
|
3417
|
+
const lines = [`--- stored (DB)`, `+++ disk (${path})`];
|
|
3418
|
+
const maxLen = Math.max(stored.length, disk.length);
|
|
3419
|
+
for (let i = 0;i < maxLen; i++) {
|
|
3420
|
+
const s = stored[i];
|
|
3421
|
+
const dk = disk[i];
|
|
3422
|
+
if (s === dk) {
|
|
3423
|
+
if (s !== undefined)
|
|
3424
|
+
lines.push(` ${s}`);
|
|
3425
|
+
} else {
|
|
3426
|
+
if (s !== undefined)
|
|
3427
|
+
lines.push(`-${s}`);
|
|
3428
|
+
if (dk !== undefined)
|
|
3429
|
+
lines.push(`+${dk}`);
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
return lines.join(`
|
|
3433
|
+
`);
|
|
3434
|
+
}
|
|
3435
|
+
function diffConfig(config, opts = {}) {
|
|
3436
|
+
if (!config.target_path && config.outputs.length === 0)
|
|
3437
|
+
return "(reference \u2014 no target path)";
|
|
3438
|
+
const diffs = [];
|
|
3439
|
+
const db = opts.db || getDatabase();
|
|
3440
|
+
const contextConfigs = config.outputs.length > 0 || config.target_path ? listConfigs(undefined, db) : [config];
|
|
3441
|
+
if (isGeneratedOutputTarget2(config, outputOwnerIdsByTarget(contextConfigs))) {
|
|
3442
|
+
return "(generated output \u2014 managed by fan-out)";
|
|
3443
|
+
}
|
|
3444
|
+
if (config.target_path) {
|
|
3445
|
+
const diff = buildDiff(config.content, config.target_path);
|
|
3446
|
+
if (!diff.includes("no diff"))
|
|
3447
|
+
diffs.push(diff);
|
|
3448
|
+
}
|
|
3449
|
+
if (config.outputs.length > 0) {
|
|
3450
|
+
for (const output of config.outputs) {
|
|
3451
|
+
const expected = applyTransform(config, output, { configs: contextConfigs });
|
|
3452
|
+
const diff = buildDiff(expected, output.target_path);
|
|
3453
|
+
if (!diff.includes("no diff"))
|
|
3454
|
+
diffs.push(diff);
|
|
3455
|
+
}
|
|
3456
|
+
}
|
|
3457
|
+
return diffs.length > 0 ? diffs.join(`
|
|
3458
|
+
|
|
3459
|
+
`) : "(no diff \u2014 identical)";
|
|
3460
|
+
}
|
|
3461
|
+
function detectCategory(filePath) {
|
|
3462
|
+
const p = filePath.toLowerCase().replace(getConfigHome(), "~");
|
|
3463
|
+
if (p.includes("/.claude/rules/") || p.includes("/.cursor/rules/") || p.endsWith("claude.md") || p.endsWith("agents.md") || p.endsWith("codewith.md") || p.endsWith("gemini.md") || p.endsWith(".mdc"))
|
|
3464
|
+
return "rules";
|
|
3465
|
+
if (p.includes("/.claude/") || p.includes("/.codex/") || p.includes("/.gemini/") || p.includes("/.cursor/") || p.includes("/.config/opencode/") || p.includes("/.codewith/") || p.includes("/.config/aicopilot/"))
|
|
3466
|
+
return "agent";
|
|
3467
|
+
if (p.includes(".mcp.json") || p.includes("mcp"))
|
|
3468
|
+
return "mcp";
|
|
3469
|
+
if (p.includes(".zshrc") || p.includes(".zprofile") || p.includes(".bashrc") || p.includes(".bash_profile"))
|
|
3470
|
+
return "shell";
|
|
3471
|
+
if (p.includes(".gitconfig") || p.includes(".gitignore"))
|
|
3472
|
+
return "git";
|
|
3473
|
+
if (p.includes(".npmrc") || p.includes("tsconfig") || p.includes("bunfig"))
|
|
3474
|
+
return "tools";
|
|
3475
|
+
if (p.includes(".secrets"))
|
|
3476
|
+
return "secrets_schema";
|
|
3477
|
+
return "tools";
|
|
3478
|
+
}
|
|
3479
|
+
function detectAgent(filePath) {
|
|
3480
|
+
const p = filePath.toLowerCase().replace(getConfigHome(), "~");
|
|
3481
|
+
if (p.includes("/.claude/") || p.endsWith("claude.md"))
|
|
3482
|
+
return "claude";
|
|
3483
|
+
if (p.includes("/.config/opencode/"))
|
|
3484
|
+
return "opencode";
|
|
3485
|
+
if (p.includes("/.cursor/") || p.endsWith(".mdc"))
|
|
3486
|
+
return "cursor";
|
|
3487
|
+
if (p.includes("/.codewith/") || p.endsWith("codewith.md"))
|
|
3488
|
+
return "codewith";
|
|
3489
|
+
if (p.includes("/.config/aicopilot/"))
|
|
3490
|
+
return "aicopilot";
|
|
3491
|
+
if (p.includes("/.codex/") || p.endsWith("agents.md"))
|
|
3492
|
+
return "codex";
|
|
3493
|
+
if (p.includes("/.gemini/") || p.endsWith("gemini.md"))
|
|
3494
|
+
return "gemini";
|
|
3495
|
+
if (p.includes(".zshrc") || p.includes(".zprofile") || p.includes(".bashrc"))
|
|
3496
|
+
return "zsh";
|
|
3497
|
+
if (p.includes(".gitconfig") || p.includes(".gitignore"))
|
|
3498
|
+
return "git";
|
|
3499
|
+
if (p.includes(".npmrc"))
|
|
3500
|
+
return "npm";
|
|
3501
|
+
return "global";
|
|
3502
|
+
}
|
|
3503
|
+
function detectFormat(filePath) {
|
|
3504
|
+
const ext = extname3(filePath).toLowerCase();
|
|
3505
|
+
if (ext === ".json")
|
|
3506
|
+
return "json";
|
|
3507
|
+
if (ext === ".toml")
|
|
3508
|
+
return "toml";
|
|
3509
|
+
if (ext === ".yaml" || ext === ".yml")
|
|
3510
|
+
return "yaml";
|
|
3511
|
+
if (ext === ".md" || ext === ".mdc" || ext === ".markdown")
|
|
3512
|
+
return "markdown";
|
|
3513
|
+
if (ext === ".ini" || ext === ".cfg")
|
|
3514
|
+
return "ini";
|
|
3515
|
+
return "text";
|
|
3516
|
+
}
|
|
3517
|
+
// src/lib/export.ts
|
|
3518
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync4, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
3519
|
+
import { join as join7, resolve as resolve4 } from "path";
|
|
3520
|
+
import { tmpdir } from "os";
|
|
3521
|
+
async function exportConfigs(outputPath, opts = {}) {
|
|
3522
|
+
const d = opts.db || getDatabase();
|
|
3523
|
+
const configs = listConfigs(opts.filter, d);
|
|
3524
|
+
const absOutput = resolve4(outputPath);
|
|
3525
|
+
const tmpDir = join7(tmpdir(), `configs-export-${Date.now()}`);
|
|
3526
|
+
const contentsDir = join7(tmpDir, "contents");
|
|
3527
|
+
try {
|
|
3528
|
+
mkdirSync4(contentsDir, { recursive: true });
|
|
3529
|
+
const manifest = {
|
|
3530
|
+
version: "1.0.0",
|
|
3531
|
+
exported_at: now(),
|
|
3532
|
+
configs: configs.map(({ content: _content, ...meta }) => meta)
|
|
3533
|
+
};
|
|
3534
|
+
writeFileSync3(join7(tmpDir, "manifest.json"), JSON.stringify(manifest, null, 2), "utf-8");
|
|
3535
|
+
for (const config of configs) {
|
|
3536
|
+
const fileName = `${config.slug}.${config.format === "text" ? "txt" : config.format}`;
|
|
3537
|
+
writeFileSync3(join7(contentsDir, fileName), config.content, "utf-8");
|
|
3538
|
+
}
|
|
3539
|
+
const proc = Bun.spawn(["tar", "czf", absOutput, "-C", tmpDir, "."], {
|
|
3540
|
+
stdout: "pipe",
|
|
3541
|
+
stderr: "pipe"
|
|
3542
|
+
});
|
|
3543
|
+
const exitCode = await proc.exited;
|
|
3544
|
+
if (exitCode !== 0) {
|
|
3545
|
+
const stderr = await new Response(proc.stderr).text();
|
|
3546
|
+
throw new Error(`tar failed: ${stderr}`);
|
|
3547
|
+
}
|
|
3548
|
+
return { path: absOutput, count: configs.length };
|
|
3549
|
+
} finally {
|
|
3550
|
+
if (existsSync9(tmpDir)) {
|
|
3551
|
+
rmSync2(tmpDir, { recursive: true, force: true });
|
|
3552
|
+
}
|
|
3553
|
+
}
|
|
3554
|
+
}
|
|
3555
|
+
// src/lib/import.ts
|
|
3556
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync5, readFileSync as readFileSync7, rmSync as rmSync3 } from "fs";
|
|
3557
|
+
import { join as join8, resolve as resolve5 } from "path";
|
|
3558
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
3559
|
+
async function importConfigs(bundlePath, opts = {}) {
|
|
3560
|
+
const d = opts.db || getDatabase();
|
|
3561
|
+
const conflict = opts.conflict ?? "skip";
|
|
3562
|
+
const absPath = resolve5(bundlePath);
|
|
3563
|
+
const tmpDir = join8(tmpdir2(), `configs-import-${Date.now()}`);
|
|
3564
|
+
const result = { created: 0, updated: 0, skipped: 0, errors: [] };
|
|
3565
|
+
try {
|
|
3566
|
+
mkdirSync5(tmpDir, { recursive: true });
|
|
3567
|
+
const proc = Bun.spawn(["tar", "xzf", absPath, "-C", tmpDir], {
|
|
3568
|
+
stdout: "pipe",
|
|
3569
|
+
stderr: "pipe"
|
|
3570
|
+
});
|
|
3571
|
+
const exitCode = await proc.exited;
|
|
3572
|
+
if (exitCode !== 0) {
|
|
3573
|
+
const stderr = await new Response(proc.stderr).text();
|
|
3574
|
+
throw new Error(`tar extraction failed: ${stderr}`);
|
|
3575
|
+
}
|
|
3576
|
+
const manifestPath = join8(tmpDir, "manifest.json");
|
|
3577
|
+
if (!existsSync10(manifestPath))
|
|
3578
|
+
throw new Error("Invalid bundle: missing manifest.json");
|
|
3579
|
+
const manifest = JSON.parse(readFileSync7(manifestPath, "utf-8"));
|
|
3580
|
+
for (const meta of manifest.configs) {
|
|
3581
|
+
try {
|
|
3582
|
+
const ext = meta.format === "text" ? "txt" : meta.format;
|
|
3583
|
+
const contentFile = join8(tmpDir, "contents", `${meta.slug}.${ext}`);
|
|
3584
|
+
const content = existsSync10(contentFile) ? readFileSync7(contentFile, "utf-8") : "";
|
|
3585
|
+
let existing = null;
|
|
3586
|
+
try {
|
|
3587
|
+
existing = getConfig(meta.slug, d);
|
|
3588
|
+
} catch {}
|
|
3589
|
+
if (existing) {
|
|
3590
|
+
if (conflict === "skip") {
|
|
3591
|
+
result.skipped++;
|
|
3592
|
+
} else if (conflict === "overwrite" || conflict === "version") {
|
|
3593
|
+
updateConfig(existing.id, { content, description: meta.description ?? undefined, tags: meta.tags, outputs: meta.outputs }, d);
|
|
3594
|
+
result.updated++;
|
|
3595
|
+
}
|
|
3596
|
+
} else {
|
|
3597
|
+
createConfig({
|
|
3598
|
+
name: meta.name,
|
|
3599
|
+
kind: meta.kind,
|
|
3600
|
+
category: meta.category,
|
|
3601
|
+
agent: meta.agent,
|
|
3602
|
+
target_path: meta.target_path ?? undefined,
|
|
3603
|
+
outputs: meta.outputs,
|
|
3604
|
+
format: meta.format,
|
|
3605
|
+
content,
|
|
3606
|
+
description: meta.description ?? undefined,
|
|
3607
|
+
tags: meta.tags,
|
|
3608
|
+
is_template: meta.is_template
|
|
3609
|
+
}, d);
|
|
3610
|
+
result.created++;
|
|
3611
|
+
}
|
|
3612
|
+
} catch (err) {
|
|
3613
|
+
result.errors.push(`${meta.slug}: ${err instanceof Error ? err.message : String(err)}`);
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
return result;
|
|
3617
|
+
} finally {
|
|
3618
|
+
if (existsSync10(tmpDir)) {
|
|
3619
|
+
rmSync3(tmpDir, { recursive: true, force: true });
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
export {
|
|
3624
|
+
uuid,
|
|
3625
|
+
updateProfile,
|
|
3626
|
+
updateMachineApplied,
|
|
3627
|
+
updateConfig,
|
|
3628
|
+
transformSkillContent,
|
|
3629
|
+
templateizeMachineContent,
|
|
3630
|
+
syncToDisk,
|
|
3631
|
+
syncToDir,
|
|
3632
|
+
syncProject,
|
|
3633
|
+
syncKnown,
|
|
3634
|
+
syncFromDir,
|
|
3635
|
+
stripClaudeOnlySections,
|
|
3636
|
+
storageSync,
|
|
3637
|
+
storagePush,
|
|
3638
|
+
storagePull,
|
|
3639
|
+
sourcesFromIdentityExport,
|
|
3640
|
+
sourceFromFilePath,
|
|
3641
|
+
sourceFromConfig,
|
|
3642
|
+
slugify,
|
|
3643
|
+
scanSecrets,
|
|
3644
|
+
runStorageMigrations,
|
|
3645
|
+
resolveTables,
|
|
3646
|
+
resolveSessionTargetOwnership,
|
|
3647
|
+
resolveSessionPath,
|
|
3648
|
+
resolveProfileVariables,
|
|
3649
|
+
resolveProfileForMachine,
|
|
3650
|
+
resetDatabase,
|
|
3651
|
+
renderTemplate,
|
|
3652
|
+
renderMachineAwareContent,
|
|
3653
|
+
removeConfigFromProfile,
|
|
3654
|
+
registerMachine,
|
|
3655
|
+
redactContent,
|
|
3656
|
+
pruneSnapshots,
|
|
3657
|
+
profileMatchesMachine,
|
|
3658
|
+
profileHasSelectors,
|
|
3659
|
+
planSessionRender,
|
|
3660
|
+
parseTemplateVars,
|
|
3661
|
+
now,
|
|
3662
|
+
normalizeOsFamily,
|
|
3663
|
+
machineContextToVariables,
|
|
3664
|
+
listSnapshots,
|
|
3665
|
+
listProfiles,
|
|
3666
|
+
listMachines,
|
|
3667
|
+
listConfigs,
|
|
3668
|
+
isTemplate,
|
|
3669
|
+
isLocalPostgresHost,
|
|
3670
|
+
importConfigs,
|
|
3671
|
+
hasSecrets,
|
|
3672
|
+
getSyncMetaAll,
|
|
3673
|
+
getStorageSyncMetaAll,
|
|
3674
|
+
getStorageStatus,
|
|
3675
|
+
getStoragePg,
|
|
3676
|
+
getStorageMode,
|
|
3677
|
+
getStorageDatabaseUrl,
|
|
3678
|
+
getStorageDatabaseEnvName,
|
|
3679
|
+
getSnapshotByVersion,
|
|
3680
|
+
getSnapshot,
|
|
3681
|
+
getProfileConfigs,
|
|
3682
|
+
getProfile,
|
|
3683
|
+
getDatabase,
|
|
3684
|
+
getConfigsStatus,
|
|
3685
|
+
getConfigStats,
|
|
3686
|
+
getConfigById,
|
|
3687
|
+
getConfig,
|
|
3688
|
+
extractTemplateVars,
|
|
3689
|
+
exportConfigs,
|
|
3690
|
+
expandPath,
|
|
3691
|
+
ensureProjectDashboardStandardConfig,
|
|
3692
|
+
ensurePlatformProfiles,
|
|
3693
|
+
diffConfig,
|
|
3694
|
+
detectMachineContext,
|
|
3695
|
+
detectFormat,
|
|
3696
|
+
detectCategory,
|
|
3697
|
+
detectAgent,
|
|
3698
|
+
deleteProfile,
|
|
3699
|
+
deleteConfig,
|
|
3700
|
+
currentOs,
|
|
3701
|
+
currentHostname2 as currentHostname,
|
|
3702
|
+
currentArch2 as currentArch,
|
|
3703
|
+
createSnapshot,
|
|
3704
|
+
createProfile,
|
|
3705
|
+
createConfig,
|
|
3706
|
+
cleanSessionPathInput,
|
|
3707
|
+
checkSessionRenderDrift,
|
|
3708
|
+
buildPgPoolConfig,
|
|
3709
|
+
buildOpenCodeAgentsMd,
|
|
3710
|
+
buildCursorMdc,
|
|
3711
|
+
buildCodexAgentsMd,
|
|
3712
|
+
applyTransform,
|
|
3713
|
+
applySessionRender,
|
|
3714
|
+
applyConfigs,
|
|
3715
|
+
applyConfig,
|
|
3716
|
+
addConfigToProfile,
|
|
3717
|
+
TemplateRenderError,
|
|
3718
|
+
SessionApplyError,
|
|
3719
|
+
STORAGE_TABLES,
|
|
3720
|
+
STORAGE_MODE_ENV,
|
|
3721
|
+
STORAGE_DATABASE_ENV,
|
|
3722
|
+
SESSION_TOOL_ADAPTERS,
|
|
3723
|
+
SESSION_RENDER_TOOLS,
|
|
3724
|
+
SESSION_RENDER_SCHEMA,
|
|
3725
|
+
SESSION_RENDER_MANAGED_MARKER,
|
|
3726
|
+
RAW_STORE_ROOT_ENV,
|
|
3727
|
+
ProfileNotFoundError,
|
|
3728
|
+
PgAdapterAsync,
|
|
3729
|
+
PROJECT_DASHBOARD_STANDARD_SLUG,
|
|
3730
|
+
PROJECT_DASHBOARD_STANDARD_CONTENT,
|
|
3731
|
+
PROJECT_DASHBOARD_PROFILE_VARIABLES,
|
|
3732
|
+
PROJECT_CONFIG_FILES,
|
|
3733
|
+
PLATFORM_PROFILE_PRESETS,
|
|
3734
|
+
PG_MIGRATIONS,
|
|
3735
|
+
KNOWN_CONFIGS,
|
|
3736
|
+
ConfigNotFoundError,
|
|
3737
|
+
ConfigApplyError,
|
|
3738
|
+
CONFIG_TRANSFORMS,
|
|
3739
|
+
CONFIG_KINDS,
|
|
3740
|
+
CONFIG_FORMATS,
|
|
3741
|
+
CONFIG_CATEGORIES,
|
|
3742
|
+
CONFIG_AGENTS,
|
|
3743
|
+
CONFIGS_STORAGE_TABLES,
|
|
3744
|
+
CONFIGS_STORAGE_MODE_FALLBACK_ENV,
|
|
3745
|
+
CONFIGS_STORAGE_MODE_ENV,
|
|
3746
|
+
CONFIGS_STORAGE_FALLBACK_ENV,
|
|
3747
|
+
CONFIGS_STORAGE_ENV,
|
|
3748
|
+
CODEWITH_NATIVE_IMPORTS_ENV
|
|
3749
|
+
};
|