@hasna/skills 0.1.72 → 0.2.1

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
@@ -3,7 +3,7 @@
3
3
 
4
4
  // src/server/migrate.ts
5
5
  import { existsSync as existsSync4, readdirSync as readdirSync3, readFileSync as readFileSync3 } from "fs";
6
- import { join as join7 } from "path";
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,103 +82,96 @@ function normalizePrefix(value) {
81
82
  }
82
83
 
83
84
  // src/server/database-url.ts
84
- import { isAbsolute, join as join4 } 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
89
  import { existsSync as existsSync2, readFileSync, writeFileSync, mkdirSync, copyFileSync, readdirSync, statSync } from "fs";
89
- import { join as join3, dirname } from "path";
90
+ import { join as join2, dirname } from "path";
90
91
 
91
92
  // src/lib/app-home.ts
92
93
  import { existsSync } from "fs";
93
- import { homedir as homedir2 } from "os";
94
- import { join as join2, resolve } from "path";
95
-
96
- // ../../node_modules/.bun/@hasna+paths@0.1.0/node_modules/@hasna/paths/dist/index.js
97
94
  import { homedir } from "os";
98
- import { join } from "path";
99
- var KIND_ENV = {
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 = {
100
99
  config: "HASNA_CONFIG_HOME",
101
100
  data: "HASNA_DATA_HOME",
102
101
  state: "HASNA_STATE_HOME",
103
102
  cache: "HASNA_CACHE_HOME"
104
103
  };
105
- var APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
106
- function assertApp(app) {
104
+ var PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
105
+ function pathsResolverAssertApp(app) {
107
106
  if (typeof app !== "string" || app.length === 0) {
108
107
  throw new TypeError("paths: app must be a non-empty string");
109
108
  }
110
- if (!APP_SLUG_RE.test(app)) {
109
+ if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
111
110
  throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
112
111
  }
113
112
  }
114
- function envOf(options) {
115
- return options.env ?? process.env;
116
- }
117
- function envValue(options, kind) {
118
- const value = envOf(options)[KIND_ENV[kind]];
119
- return typeof value === "string" && value.length > 0 ? value : undefined;
120
- }
121
- function isMacOS(platform) {
122
- return platform === "darwin";
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
+ }
123
117
  }
124
- function baseDir(kind, options) {
125
- const override = envValue(options, kind);
126
- if (override)
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)
127
123
  return override;
128
- const home = options.home ?? homedir();
124
+ const home = options.home ?? pathsResolverHomedir();
129
125
  const platform = options.platform ?? process.platform;
130
- if (isMacOS(platform)) {
126
+ if (platform === "darwin") {
131
127
  switch (kind) {
132
128
  case "config":
133
129
  case "data":
134
- return join(home, "Library", "Application Support", "Hasna");
130
+ return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
135
131
  case "cache":
136
- return join(home, "Library", "Caches", "Hasna");
132
+ return pathsResolverJoin(home, "Library", "Caches", "Hasna");
137
133
  case "state":
138
- return join(home, "Library", "Logs", "Hasna");
134
+ return pathsResolverJoin(home, "Library", "Logs", "Hasna");
139
135
  }
140
136
  }
141
137
  switch (kind) {
142
138
  case "config":
143
- return join(home, ".config", "hasna");
139
+ return pathsResolverJoin(home, ".config", "hasna");
144
140
  case "data":
145
- return join(home, ".local", "share", "hasna");
141
+ return pathsResolverJoin(home, ".local", "share", "hasna");
146
142
  case "state":
147
- return join(home, ".local", "state", "hasna");
143
+ return pathsResolverJoin(home, ".local", "state", "hasna");
148
144
  case "cache":
149
- return join(home, ".cache", "hasna");
145
+ return pathsResolverJoin(home, ".cache", "hasna");
150
146
  }
151
147
  }
152
- function resolvePath(kind, options) {
153
- assertApp(options.app);
154
- const appSegment = options.internal === true ? join("internal", options.app) : options.app;
155
- return join(baseDir(kind, options), appSegment);
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);
156
152
  }
157
153
  function dataDir(options) {
158
- return resolvePath("data", options);
154
+ return pathsResolverResolve("data", options);
159
155
  }
160
-
161
- // src/lib/app-home.ts
162
156
  var DATA_DIR_ENV = "HASNA_SKILLS_DIR";
163
157
  var HASNA_SKILLS_HOME_ENV = "HASNA_SKILLS_HOME";
164
158
  var SKILLS_HOME_ENV = "SKILLS_HOME";
165
159
  var DEFAULT_SQLITE_FILENAME = "server.db";
166
160
  var GLOBAL_CONFIG_FILENAME = "config.json";
167
161
  function effectiveHome() {
168
- return process.env["HOME"] || process.env["USERPROFILE"] || homedir2() || "/tmp";
162
+ return process.env["HOME"] || process.env["USERPROFILE"] || homedir() || "/tmp";
169
163
  }
170
164
  function legacyDataRoot() {
171
- return join2(effectiveHome(), ".hasna", "skills");
165
+ return join(effectiveHome(), ".hasna", "skills");
172
166
  }
173
- function resolverDataRoot(home = effectiveHome()) {
174
- return dataDir({ app: "skills", home });
167
+ function resolverDataRoot(home = effectiveHome(), env) {
168
+ return dataDir({ app: "skills", home, env });
175
169
  }
176
170
  function adoptResolverDataRoot(resolved, env = process.env) {
177
171
  const dataOverride = env.HASNA_DATA_HOME;
178
172
  if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
179
173
  return true;
180
- return existsSync(join2(resolved, DEFAULT_SQLITE_FILENAME)) || existsSync(join2(resolved, GLOBAL_CONFIG_FILENAME));
174
+ return existsSync(join(resolved, DEFAULT_SQLITE_FILENAME)) || existsSync(join(resolved, GLOBAL_CONFIG_FILENAME));
181
175
  }
182
176
  function exactDataRoot() {
183
177
  for (const key of [DATA_DIR_ENV, HASNA_SKILLS_HOME_ENV, SKILLS_HOME_ENV]) {
@@ -206,8 +200,8 @@ function mergeDirectoryContents(sourceDir, targetDir) {
206
200
  return;
207
201
  mkdirSync(targetDir, { recursive: true });
208
202
  for (const entry of readdirSync(sourceDir)) {
209
- const sourcePath = join3(sourceDir, entry);
210
- const targetPath = join3(targetDir, entry);
203
+ const sourcePath = join2(sourceDir, entry);
204
+ const targetPath = join2(targetDir, entry);
211
205
  try {
212
206
  const sourceStat = statSync(sourcePath);
213
207
  if (sourceStat.isDirectory()) {
@@ -227,14 +221,14 @@ function getDataDir() {
227
221
  if (hasOperatorOverride())
228
222
  return root;
229
223
  const home = effectiveHome();
230
- const oldDir = join3(home, ".skills");
231
- const oldConfigFile = join3(home, ".skillsrc");
224
+ const oldDir = join2(home, ".skills");
225
+ const oldConfigFile = join2(home, ".skillsrc");
232
226
  try {
233
227
  mergeDirectoryContents(oldDir, root);
234
228
  } catch {}
235
- if (existsSync2(oldConfigFile) && !existsSync2(join3(root, "config.json"))) {
229
+ if (existsSync2(oldConfigFile) && !existsSync2(join2(root, "config.json"))) {
236
230
  try {
237
- copyFileSync(oldConfigFile, join3(root, "config.json"));
231
+ copyFileSync(oldConfigFile, join2(root, "config.json"));
238
232
  } catch {}
239
233
  }
240
234
  return root;
@@ -248,7 +242,7 @@ var SQLITE_SCHEMES = new Set(["sqlite", "sqlite3", "file"]);
248
242
  var MEMORY_SCHEMES = new Set(["memory"]);
249
243
  var SQLITE_EXTENSIONS = [".db", ".sqlite", ".sqlite3", ".db3"];
250
244
  function defaultSqlitePath() {
251
- return join4(getDataDir(), DEFAULT_SQLITE_FILENAME2);
245
+ return join3(getDataDir(), DEFAULT_SQLITE_FILENAME2);
252
246
  }
253
247
  function resolveDatabaseTarget(raw) {
254
248
  const value = raw?.trim();
@@ -275,7 +269,7 @@ function resolveDatabaseTarget(raw) {
275
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()}.`);
276
270
  }
277
271
  if (looksLikeSqlitePath(value)) {
278
- const path = isAbsolute(value) ? value : join4(process.cwd(), value);
272
+ const path = isAbsolute(value) ? value : join3(process.cwd(), value);
279
273
  return { kind: "sqlite", path, durable: true, label: `sqlite (${path})` };
280
274
  }
281
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()}.`);
@@ -299,7 +293,7 @@ function sqlitePathFromUrl(value, scheme) {
299
293
  }
300
294
  return rest.slice(2).replace(/^\/\/+/, "/");
301
295
  }
302
- return isAbsolute(rest) ? rest : join4(process.cwd(), rest);
296
+ return isAbsolute(rest) ? rest : join3(process.cwd(), rest);
303
297
  }
304
298
  function looksLikeSqlitePath(value) {
305
299
  if (value.includes("/"))
@@ -309,15 +303,15 @@ function looksLikeSqlitePath(value) {
309
303
 
310
304
  // src/server/migrations-dir.ts
311
305
  import { existsSync as existsSync3 } from "fs";
312
- import { dirname as dirname2, join as join5 } from "path";
306
+ import { dirname as dirname2, join as join4 } from "path";
313
307
  var MIGRATION_DIALECTS = ["postgres", "sqlite"];
314
308
  var MAX_WALK_UP = 6;
315
309
  function findMigrationsRoot(startDirs = defaultStartDirs()) {
316
310
  for (const start of startDirs) {
317
311
  let dir = start;
318
312
  for (let level = 0;level < MAX_WALK_UP; level += 1) {
319
- const candidate = join5(dir, "migrations");
320
- if (MIGRATION_DIALECTS.some((dialect) => existsSync3(join5(candidate, dialect))))
313
+ const candidate = join4(dir, "migrations");
314
+ if (MIGRATION_DIALECTS.some((dialect) => existsSync3(join4(candidate, dialect))))
321
315
  return candidate;
322
316
  const parent = dirname2(dir);
323
317
  if (parent === dir)
@@ -331,7 +325,7 @@ function resolveMigrationsDir(dialect, root = findMigrationsRoot()) {
331
325
  if (!root) {
332
326
  throw new Error(`could not locate the migrations directory for dialect "${dialect}". ` + `Expected a migrations/${dialect}/ folder alongside the package root.`);
333
327
  }
334
- const dir = join5(root, dialect);
328
+ const dir = join4(root, dialect);
335
329
  if (!existsSync3(dir)) {
336
330
  throw new Error(`migrations directory not found: ${dir}`);
337
331
  }
@@ -349,7 +343,7 @@ function defaultStartDirs() {
349
343
  import { Database } from "bun:sqlite";
350
344
  import { randomUUID as randomUUID2 } from "crypto";
351
345
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync as readdirSync2 } from "fs";
352
- import { dirname as dirname3, join as join6 } from "path";
346
+ import { dirname as dirname3, join as join5 } from "path";
353
347
 
354
348
  // src/server/auth.ts
355
349
  import { createHash } from "crypto";
@@ -641,6 +635,20 @@ function dateString(value) {
641
635
  return value.toISOString();
642
636
  return String(value);
643
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
+ }
644
652
 
645
653
  // src/server/types.ts
646
654
  class StaleLeaseGenerationError extends Error {
@@ -671,6 +679,21 @@ class SkillRevisionConflictError extends Error {
671
679
  }
672
680
  }
673
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
+
674
697
  // src/lib/revision.ts
675
698
  import { createHash as createHash2 } from "crypto";
676
699
  function revisionIdOf(content) {
@@ -966,6 +989,13 @@ class SqliteSkillsStore {
966
989
  throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previousRevisionId);
967
990
  }
968
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
+ }
969
999
  const carriedSize = input.bundle?.byteSize ?? (previous?.bundle_byte_size == null ? null : Number(previous.bundle_byte_size));
970
1000
  const revisionId = revisionIdOfRecord({
971
1001
  slug: input.slug,
@@ -1053,6 +1083,21 @@ class SqliteSkillsStore {
1053
1083
  const currentId = typeof current?.revision_id === "string" ? current.revision_id : null;
1054
1084
  throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, currentId);
1055
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
+ }
1056
1101
  if (previousSha && input.bundle && previousSha !== input.bundle.sha256)
1057
1102
  this.collectOrphanBundle(orgId, previousSha);
1058
1103
  this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
@@ -1160,6 +1205,13 @@ class SqliteSkillsStore {
1160
1205
  const row = this.get("SELECT * FROM skills_bundles WHERE org_id = ? AND sha256 = ? LIMIT 1", [principal.orgId, sha256]);
1161
1206
  return row ? rowToSkillBundle(row) : null;
1162
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
+ }
1163
1215
  async pinSkill(principal, slug, metadata = {}) {
1164
1216
  const row = this.get(`INSERT INTO skills_pins (org_id, principal, slug, pinned_at, metadata_json)
1165
1217
  VALUES (?, ?, ?, ?, ?)
@@ -1199,7 +1251,7 @@ class SqliteSkillsStore {
1199
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));
1200
1252
  }
1201
1253
  collectOrphanBundle(orgId, sha256) {
1202
- 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]);
1203
1255
  if (referenced)
1204
1256
  return;
1205
1257
  this.db.run("DELETE FROM skills_bundles WHERE org_id = ? AND sha256 = ?", [orgId, sha256]);
@@ -1228,7 +1280,7 @@ function applySqliteMigrations(db, migrationsDir = resolveMigrationsDir("sqlite"
1228
1280
  const appliedNow = [];
1229
1281
  for (const file of files) {
1230
1282
  const version = file.replace(/\.sql$/, "");
1231
- const text = readFileSync2(join6(migrationsDir, file), "utf8");
1283
+ const text = readFileSync2(join5(migrationsDir, file), "utf8");
1232
1284
  const apply = db.transaction(() => {
1233
1285
  const already = db.query("SELECT 1 AS present FROM schema_migrations WHERE version = ? LIMIT 1").get(version);
1234
1286
  if (already)
@@ -1317,7 +1369,7 @@ async function runPostgresMigrations(databaseUrl, migrationsDir) {
1317
1369
  const version = file.replace(/\.sql$/, "");
1318
1370
  if (applied.has(version))
1319
1371
  continue;
1320
- const sqlText = readFileSync3(join7(migrationsDir, file), "utf8");
1372
+ const sqlText = readFileSync3(join6(migrationsDir, file), "utf8");
1321
1373
  await sql.unsafe("BEGIN");
1322
1374
  try {
1323
1375
  await sql.unsafe(sqlText);