@hasna/skills 0.1.71 → 0.2.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/bin/migrate.js CHANGED
@@ -2,8 +2,8 @@
2
2
  // @bun
3
3
 
4
4
  // src/server/migrate.ts
5
- import { existsSync as existsSync3, readdirSync as readdirSync3, readFileSync as readFileSync3 } from "fs";
6
- import { join as join5 } from "path";
5
+ import { existsSync as existsSync4, readdirSync as readdirSync3, readFileSync as readFileSync3 } from "fs";
6
+ import { join as join6 } from "path";
7
7
 
8
8
  // src/lib/retired-settings.ts
9
9
  var RETIRED_ENV_SUFFIXES = ["_STORAGE_MODE", "_DEPLOYMENT_MODE", "_CLOUD_MODE"];
@@ -56,6 +56,7 @@ function resolveServerConfig(env = process.env) {
56
56
  port,
57
57
  databaseUrl: env[DATABASE_URL_ENV] || env.DATABASE_URL || undefined,
58
58
  bootstrapApiKey: env.HASNA_SKILLS_BOOTSTRAP_API_KEY || undefined,
59
+ seedBundledCorpus: (env.HASNA_SKILLS_SEED_BUNDLED_CORPUS ?? "1") !== "0",
59
60
  artifactBucket: env.HASNA_SKILLS_S3_BUCKET || env.SKILLS_S3_BUCKET || undefined,
60
61
  artifactPrefix: normalizePrefix(env.HASNA_SKILLS_S3_PREFIX || env.SKILLS_S3_PREFIX || "skills/artifacts"),
61
62
  inlineWorker: env.HASNA_SKILLS_INLINE_WORKER === "1",
@@ -81,65 +82,167 @@ function normalizePrefix(value) {
81
82
  }
82
83
 
83
84
  // src/server/database-url.ts
84
- import { isAbsolute, join as join2 } from "path";
85
+ import { isAbsolute, join as join3 } from "path";
85
86
  import { fileURLToPath } from "url";
86
87
 
87
88
  // src/lib/config.ts
88
- import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, readdirSync, statSync } from "fs";
89
- import { join, dirname } from "path";
89
+ import { existsSync as existsSync2, readFileSync, writeFileSync, mkdirSync, copyFileSync, readdirSync, statSync } from "fs";
90
+ import { join as join2, dirname } from "path";
91
+
92
+ // src/lib/app-home.ts
93
+ import { existsSync } from "fs";
90
94
  import { homedir } from "os";
95
+ import { join, resolve } from "path";
96
+ import { homedir as pathsResolverHomedir } from "os";
97
+ import { join as pathsResolverJoin } from "path";
98
+ var PATHS_RESOLVER_KIND_ENV = {
99
+ config: "HASNA_CONFIG_HOME",
100
+ data: "HASNA_DATA_HOME",
101
+ state: "HASNA_STATE_HOME",
102
+ cache: "HASNA_CACHE_HOME"
103
+ };
104
+ var PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
105
+ function pathsResolverAssertApp(app) {
106
+ if (typeof app !== "string" || app.length === 0) {
107
+ throw new TypeError("paths: app must be a non-empty string");
108
+ }
109
+ if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
110
+ throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
111
+ }
112
+ }
113
+ function pathsResolverAssertKind(kind) {
114
+ if (!Object.keys(PATHS_RESOLVER_KIND_ENV).includes(kind)) {
115
+ throw new TypeError(`paths: invalid path kind "${kind}" \u2014 expected one of ${Object.keys(PATHS_RESOLVER_KIND_ENV).join(", ")}`);
116
+ }
117
+ }
118
+ function pathsResolverBaseDir(kind, options) {
119
+ pathsResolverAssertKind(kind);
120
+ const env = options.env ?? process.env;
121
+ const override = env[PATHS_RESOLVER_KIND_ENV[kind]];
122
+ if (typeof override === "string" && override.length > 0)
123
+ return override;
124
+ const home = options.home ?? pathsResolverHomedir();
125
+ const platform = options.platform ?? process.platform;
126
+ if (platform === "darwin") {
127
+ switch (kind) {
128
+ case "config":
129
+ case "data":
130
+ return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
131
+ case "cache":
132
+ return pathsResolverJoin(home, "Library", "Caches", "Hasna");
133
+ case "state":
134
+ return pathsResolverJoin(home, "Library", "Logs", "Hasna");
135
+ }
136
+ }
137
+ switch (kind) {
138
+ case "config":
139
+ return pathsResolverJoin(home, ".config", "hasna");
140
+ case "data":
141
+ return pathsResolverJoin(home, ".local", "share", "hasna");
142
+ case "state":
143
+ return pathsResolverJoin(home, ".local", "state", "hasna");
144
+ case "cache":
145
+ return pathsResolverJoin(home, ".cache", "hasna");
146
+ }
147
+ }
148
+ function pathsResolverResolve(kind, options) {
149
+ pathsResolverAssertApp(options.app);
150
+ const appSegment = options.internal === true ? pathsResolverJoin("internal", options.app) : options.app;
151
+ return pathsResolverJoin(pathsResolverBaseDir(kind, options), appSegment);
152
+ }
153
+ function dataDir(options) {
154
+ return pathsResolverResolve("data", options);
155
+ }
156
+ var DATA_DIR_ENV = "HASNA_SKILLS_DIR";
157
+ var HASNA_SKILLS_HOME_ENV = "HASNA_SKILLS_HOME";
158
+ var SKILLS_HOME_ENV = "SKILLS_HOME";
159
+ var DEFAULT_SQLITE_FILENAME = "server.db";
160
+ var GLOBAL_CONFIG_FILENAME = "config.json";
161
+ function effectiveHome() {
162
+ return process.env["HOME"] || process.env["USERPROFILE"] || homedir() || "/tmp";
163
+ }
164
+ function legacyDataRoot() {
165
+ return join(effectiveHome(), ".hasna", "skills");
166
+ }
167
+ function resolverDataRoot(home = effectiveHome(), env) {
168
+ return dataDir({ app: "skills", home, env });
169
+ }
170
+ function adoptResolverDataRoot(resolved, env = process.env) {
171
+ const dataOverride = env.HASNA_DATA_HOME;
172
+ if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
173
+ return true;
174
+ return existsSync(join(resolved, DEFAULT_SQLITE_FILENAME)) || existsSync(join(resolved, GLOBAL_CONFIG_FILENAME));
175
+ }
176
+ function exactDataRoot() {
177
+ for (const key of [DATA_DIR_ENV, HASNA_SKILLS_HOME_ENV, SKILLS_HOME_ENV]) {
178
+ const dir = process.env[key]?.trim();
179
+ if (dir)
180
+ return resolve(dir);
181
+ }
182
+ return;
183
+ }
184
+ function hasExactOverride(env = process.env) {
185
+ return Boolean(env[DATA_DIR_ENV]?.trim()) || Boolean(env[HASNA_SKILLS_HOME_ENV]?.trim()) || Boolean(env[SKILLS_HOME_ENV]?.trim());
186
+ }
187
+ function hasOperatorOverride(env = process.env) {
188
+ return hasExactOverride(env) || Boolean(env.HASNA_DATA_HOME?.trim());
189
+ }
190
+ function getDataRoot() {
191
+ const exact = exactDataRoot();
192
+ if (exact)
193
+ return exact;
194
+ const resolved = resolverDataRoot();
195
+ return adoptResolverDataRoot(resolved) ? resolve(resolved) : resolve(legacyDataRoot());
196
+ }
197
+ // src/lib/config.ts
91
198
  function mergeDirectoryContents(sourceDir, targetDir) {
92
- if (!existsSync(sourceDir))
199
+ if (!existsSync2(sourceDir))
93
200
  return;
94
201
  mkdirSync(targetDir, { recursive: true });
95
202
  for (const entry of readdirSync(sourceDir)) {
96
- const sourcePath = join(sourceDir, entry);
97
- const targetPath = join(targetDir, entry);
203
+ const sourcePath = join2(sourceDir, entry);
204
+ const targetPath = join2(targetDir, entry);
98
205
  try {
99
206
  const sourceStat = statSync(sourcePath);
100
207
  if (sourceStat.isDirectory()) {
101
208
  mergeDirectoryContents(sourcePath, targetPath);
102
209
  continue;
103
210
  }
104
- if (!existsSync(targetPath))
211
+ if (!existsSync2(targetPath))
105
212
  copyFileSync(sourcePath, targetPath);
106
213
  } catch {}
107
214
  }
108
215
  }
109
- var DATA_DIR_ENV = "HASNA_SKILLS_DIR";
110
216
  function getDataDir() {
111
- const override = process.env[DATA_DIR_ENV];
112
- if (override) {
113
- try {
114
- mkdirSync(override, { recursive: true });
115
- } catch {}
116
- return override;
117
- }
118
- const home = process.env["HOME"] || process.env["USERPROFILE"] || homedir();
119
- const newDir = join(home, ".hasna", "skills");
120
- const oldDir = join(home, ".skills");
121
- const oldConfigFile = join(home, ".skillsrc");
122
- mkdirSync(newDir, { recursive: true });
217
+ const root = getDataRoot();
123
218
  try {
124
- mergeDirectoryContents(oldDir, newDir);
219
+ mkdirSync(root, { recursive: true });
125
220
  } catch {}
126
- if (existsSync(oldConfigFile) && !existsSync(join(newDir, "config.json"))) {
221
+ if (hasOperatorOverride())
222
+ return root;
223
+ const home = effectiveHome();
224
+ const oldDir = join2(home, ".skills");
225
+ const oldConfigFile = join2(home, ".skillsrc");
226
+ try {
227
+ mergeDirectoryContents(oldDir, root);
228
+ } catch {}
229
+ if (existsSync2(oldConfigFile) && !existsSync2(join2(root, "config.json"))) {
127
230
  try {
128
- copyFileSync(oldConfigFile, join(newDir, "config.json"));
231
+ copyFileSync(oldConfigFile, join2(root, "config.json"));
129
232
  } catch {}
130
233
  }
131
- return newDir;
234
+ return root;
132
235
  }
133
236
 
134
237
  // src/server/database-url.ts
135
- var DEFAULT_SQLITE_FILENAME = "server.db";
238
+ var DEFAULT_SQLITE_FILENAME2 = "server.db";
136
239
  var SQLITE_MEMORY_PATH = ":memory:";
137
240
  var POSTGRES_SCHEMES = new Set(["postgres", "postgresql"]);
138
241
  var SQLITE_SCHEMES = new Set(["sqlite", "sqlite3", "file"]);
139
242
  var MEMORY_SCHEMES = new Set(["memory"]);
140
243
  var SQLITE_EXTENSIONS = [".db", ".sqlite", ".sqlite3", ".db3"];
141
244
  function defaultSqlitePath() {
142
- return join2(getDataDir(), DEFAULT_SQLITE_FILENAME);
245
+ return join3(getDataDir(), DEFAULT_SQLITE_FILENAME2);
143
246
  }
144
247
  function resolveDatabaseTarget(raw) {
145
248
  const value = raw?.trim();
@@ -166,7 +269,7 @@ function resolveDatabaseTarget(raw) {
166
269
  throw new Error(`unsupported database scheme "${scheme}:". Supported: postgres://, postgresql://, sqlite:, file:, ` + `an absolute or relative path to a .db/.sqlite file, ":memory:", or "memory:" (non-durable, tests only). ` + `Leave the setting empty to use the default SQLite database at ${defaultSqlitePath()}.`);
167
270
  }
168
271
  if (looksLikeSqlitePath(value)) {
169
- const path = isAbsolute(value) ? value : join2(process.cwd(), value);
272
+ const path = isAbsolute(value) ? value : join3(process.cwd(), value);
170
273
  return { kind: "sqlite", path, durable: true, label: `sqlite (${path})` };
171
274
  }
172
275
  throw new Error(`could not resolve a database backend from "${value}". Use postgres://\u2026, sqlite:\u2026, an absolute or ` + `relative path ending in ${SQLITE_EXTENSIONS.join("/")}, ":memory:", or leave it empty for the ` + `default SQLite database at ${defaultSqlitePath()}.`);
@@ -190,7 +293,7 @@ function sqlitePathFromUrl(value, scheme) {
190
293
  }
191
294
  return rest.slice(2).replace(/^\/\/+/, "/");
192
295
  }
193
- return isAbsolute(rest) ? rest : join2(process.cwd(), rest);
296
+ return isAbsolute(rest) ? rest : join3(process.cwd(), rest);
194
297
  }
195
298
  function looksLikeSqlitePath(value) {
196
299
  if (value.includes("/"))
@@ -199,16 +302,16 @@ function looksLikeSqlitePath(value) {
199
302
  }
200
303
 
201
304
  // src/server/migrations-dir.ts
202
- import { existsSync as existsSync2 } from "fs";
203
- import { dirname as dirname2, join as join3 } from "path";
305
+ import { existsSync as existsSync3 } from "fs";
306
+ import { dirname as dirname2, join as join4 } from "path";
204
307
  var MIGRATION_DIALECTS = ["postgres", "sqlite"];
205
308
  var MAX_WALK_UP = 6;
206
309
  function findMigrationsRoot(startDirs = defaultStartDirs()) {
207
310
  for (const start of startDirs) {
208
311
  let dir = start;
209
312
  for (let level = 0;level < MAX_WALK_UP; level += 1) {
210
- const candidate = join3(dir, "migrations");
211
- if (MIGRATION_DIALECTS.some((dialect) => existsSync2(join3(candidate, dialect))))
313
+ const candidate = join4(dir, "migrations");
314
+ if (MIGRATION_DIALECTS.some((dialect) => existsSync3(join4(candidate, dialect))))
212
315
  return candidate;
213
316
  const parent = dirname2(dir);
214
317
  if (parent === dir)
@@ -222,8 +325,8 @@ function resolveMigrationsDir(dialect, root = findMigrationsRoot()) {
222
325
  if (!root) {
223
326
  throw new Error(`could not locate the migrations directory for dialect "${dialect}". ` + `Expected a migrations/${dialect}/ folder alongside the package root.`);
224
327
  }
225
- const dir = join3(root, dialect);
226
- if (!existsSync2(dir)) {
328
+ const dir = join4(root, dialect);
329
+ if (!existsSync3(dir)) {
227
330
  throw new Error(`migrations directory not found: ${dir}`);
228
331
  }
229
332
  return dir;
@@ -240,7 +343,7 @@ function defaultStartDirs() {
240
343
  import { Database } from "bun:sqlite";
241
344
  import { randomUUID as randomUUID2 } from "crypto";
242
345
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync as readdirSync2 } from "fs";
243
- import { dirname as dirname3, join as join4 } from "path";
346
+ import { dirname as dirname3, join as join5 } from "path";
244
347
 
245
348
  // src/server/auth.ts
246
349
  import { createHash } from "crypto";
@@ -532,6 +635,20 @@ function dateString(value) {
532
635
  return value.toISOString();
533
636
  return String(value);
534
637
  }
638
+ function rowToSkillVersion(row) {
639
+ return {
640
+ orgId: String(row.org_id),
641
+ slug: String(row.slug),
642
+ version: String(row.version),
643
+ bundleSha256: String(row.bundle_sha256),
644
+ bundleByteSize: Number(row.bundle_byte_size),
645
+ storageKind: String(row.storage_kind ?? "db"),
646
+ ...typeof row.storage_key === "string" ? { storageKey: row.storage_key } : {},
647
+ manifest: parseJsonObject(row.manifest_json),
648
+ ...typeof row.published_by_user_id === "string" ? { publishedByUserId: row.published_by_user_id } : {},
649
+ createdAt: dateString(row.created_at)
650
+ };
651
+ }
535
652
 
536
653
  // src/server/types.ts
537
654
  class StaleLeaseGenerationError extends Error {
@@ -562,6 +679,21 @@ class SkillRevisionConflictError extends Error {
562
679
  }
563
680
  }
564
681
 
682
+ class SkillVersionExistsError extends Error {
683
+ slug;
684
+ version;
685
+ existingSha256;
686
+ attemptedSha256;
687
+ constructor(slug, version, existingSha256, attemptedSha256) {
688
+ super(`version conflict for '${slug}@${version}': already published with bundle ${existingSha256}, ` + `refusing to overwrite it with ${attemptedSha256}. Publish a new version instead.`);
689
+ this.slug = slug;
690
+ this.version = version;
691
+ this.existingSha256 = existingSha256;
692
+ this.attemptedSha256 = attemptedSha256;
693
+ this.name = "SkillVersionExistsError";
694
+ }
695
+ }
696
+
565
697
  // src/lib/revision.ts
566
698
  import { createHash as createHash2 } from "crypto";
567
699
  function revisionIdOf(content) {
@@ -857,6 +989,13 @@ class SqliteSkillsStore {
857
989
  throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previousRevisionId);
858
990
  }
859
991
  const carriedSha = input.bundle?.sha256 ?? previousSha;
992
+ if (input.version && carriedSha) {
993
+ const existingVersion = this.get("SELECT bundle_sha256 FROM skills_versions WHERE org_id = ? AND slug = ? AND version = ? LIMIT 1", [orgId, input.slug, input.version]);
994
+ const existingSha = typeof existingVersion?.bundle_sha256 === "string" ? existingVersion.bundle_sha256 : null;
995
+ if (existingSha && existingSha !== carriedSha) {
996
+ throw new SkillVersionExistsError(input.slug, input.version, existingSha, carriedSha);
997
+ }
998
+ }
860
999
  const carriedSize = input.bundle?.byteSize ?? (previous?.bundle_byte_size == null ? null : Number(previous.bundle_byte_size));
861
1000
  const revisionId = revisionIdOfRecord({
862
1001
  slug: input.slug,
@@ -944,6 +1083,21 @@ class SqliteSkillsStore {
944
1083
  const currentId = typeof current?.revision_id === "string" ? current.revision_id : null;
945
1084
  throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, currentId);
946
1085
  }
1086
+ if (input.version && carriedSha) {
1087
+ this.db.run(`INSERT OR IGNORE INTO skills_versions (org_id, slug, version, bundle_sha256, bundle_byte_size, storage_kind, storage_key, manifest_json, published_by_user_id, created_at)
1088
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1089
+ orgId,
1090
+ input.slug,
1091
+ input.version,
1092
+ carriedSha,
1093
+ carriedSize ?? 0,
1094
+ input.versionStorage?.storageKind ?? "db",
1095
+ input.versionStorage?.storageKey ?? null,
1096
+ JSON.stringify(input.versionManifest ?? {}),
1097
+ input.principal.userId ?? null,
1098
+ now
1099
+ ]);
1100
+ }
947
1101
  if (previousSha && input.bundle && previousSha !== input.bundle.sha256)
948
1102
  this.collectOrphanBundle(orgId, previousSha);
949
1103
  this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
@@ -1051,6 +1205,13 @@ class SqliteSkillsStore {
1051
1205
  const row = this.get("SELECT * FROM skills_bundles WHERE org_id = ? AND sha256 = ? LIMIT 1", [principal.orgId, sha256]);
1052
1206
  return row ? rowToSkillBundle(row) : null;
1053
1207
  }
1208
+ async listSkillVersions(principal, slug) {
1209
+ return this.all("SELECT * FROM skills_versions WHERE org_id = ? AND slug = ? ORDER BY created_at DESC, version DESC", [principal.orgId, slug]).map(rowToSkillVersion);
1210
+ }
1211
+ async getSkillVersion(principal, slug, version) {
1212
+ const row = this.get("SELECT * FROM skills_versions WHERE org_id = ? AND slug = ? AND version = ? LIMIT 1", [principal.orgId, slug, version]);
1213
+ return row ? rowToSkillVersion(row) : null;
1214
+ }
1054
1215
  async pinSkill(principal, slug, metadata = {}) {
1055
1216
  const row = this.get(`INSERT INTO skills_pins (org_id, principal, slug, pinned_at, metadata_json)
1056
1217
  VALUES (?, ?, ?, ?, ?)
@@ -1090,7 +1251,7 @@ class SqliteSkillsStore {
1090
1251
  return this.all("SELECT slug FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NULL ORDER BY slug ASC", [principal.orgId]).map((row) => String(row.slug));
1091
1252
  }
1092
1253
  collectOrphanBundle(orgId, sha256) {
1093
- const referenced = this.get("SELECT 1 AS present FROM skills_registry WHERE org_id = ? AND bundle_sha256 = ? LIMIT 1", [orgId, sha256]);
1254
+ const referenced = this.get("SELECT 1 AS present FROM skills_registry WHERE org_id = ? AND bundle_sha256 = ? LIMIT 1", [orgId, sha256]) ?? this.get("SELECT 1 AS present FROM skills_versions WHERE org_id = ? AND bundle_sha256 = ? LIMIT 1", [orgId, sha256]);
1094
1255
  if (referenced)
1095
1256
  return;
1096
1257
  this.db.run("DELETE FROM skills_bundles WHERE org_id = ? AND sha256 = ?", [orgId, sha256]);
@@ -1119,7 +1280,7 @@ function applySqliteMigrations(db, migrationsDir = resolveMigrationsDir("sqlite"
1119
1280
  const appliedNow = [];
1120
1281
  for (const file of files) {
1121
1282
  const version = file.replace(/\.sql$/, "");
1122
- const text = readFileSync2(join4(migrationsDir, file), "utf8");
1283
+ const text = readFileSync2(join5(migrationsDir, file), "utf8");
1123
1284
  const apply = db.transaction(() => {
1124
1285
  const already = db.query("SELECT 1 AS present FROM schema_migrations WHERE version = ? LIMIT 1").get(version);
1125
1286
  if (already)
@@ -1192,7 +1353,7 @@ async function runMigrations(databaseUrl, migrationsDir) {
1192
1353
  return { backend: "postgres", applied: await runPostgresMigrations(target.url, migrationsDir ?? resolveMigrationsDir("postgres")) };
1193
1354
  }
1194
1355
  async function runPostgresMigrations(databaseUrl, migrationsDir) {
1195
- if (!existsSync3(migrationsDir))
1356
+ if (!existsSync4(migrationsDir))
1196
1357
  throw new Error(`migrations directory not found: ${migrationsDir}`);
1197
1358
  const bunWithSql = Bun;
1198
1359
  const sql = new bunWithSql.SQL(databaseUrl, { max: 1 });
@@ -1208,7 +1369,7 @@ async function runPostgresMigrations(databaseUrl, migrationsDir) {
1208
1369
  const version = file.replace(/\.sql$/, "");
1209
1370
  if (applied.has(version))
1210
1371
  continue;
1211
- const sqlText = readFileSync3(join5(migrationsDir, file), "utf8");
1372
+ const sqlText = readFileSync3(join6(migrationsDir, file), "utf8");
1212
1373
  await sql.unsafe("BEGIN");
1213
1374
  try {
1214
1375
  await sql.unsafe(sqlText);