@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.
@@ -1,8 +1,11 @@
1
1
  import { type GovernanceStore } from "../sdk/governance-store.js";
2
+ import { ArtifactStorage } from "./artifact-storage.js";
2
3
  import { type SkillsServerConfig } from "./config.js";
3
4
  import { type MemorySkillsStore } from "./store.js";
4
5
  import { type SkillsProductStore } from "./types.js";
5
6
  export interface SkillsServerOptions {
7
+ /** Overrides the artifact storage (tests inject an in-memory S3 stand-in). */
8
+ artifactStorage?: ArtifactStorage;
6
9
  config?: Partial<SkillsServerConfig>;
7
10
  store?: SkillsProductStore;
8
11
  /** Lifecycle ledger and ceiling reads for governance surfaces (cancellation). Defaults to the store's database. */
@@ -1,3 +1,4 @@
1
+ import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
1
2
  import { type OwnedBytes } from "../lib/skill-bundle.js";
2
3
  import type { BlobStorageKind, ServerArtifact, ServerRunRecord, ServerSkillBundle } from "./types.js";
3
4
  export interface ArtifactBody {
@@ -5,10 +6,18 @@ export interface ArtifactBody {
5
6
  bodyText: string;
6
7
  contentType: string;
7
8
  }
9
+ /** The slice of the AWS client the storage uses; injectable so tests can stand in an in-memory bucket. */
10
+ export interface S3ClientLike {
11
+ send(command: PutObjectCommand | GetObjectCommand | DeleteObjectCommand): Promise<{
12
+ Body?: unknown;
13
+ }>;
14
+ }
8
15
  export interface ArtifactStorageOptions {
9
16
  bucket?: string;
10
17
  prefix?: string;
11
18
  region?: string;
19
+ /** Overrides the real S3 client (tests). Ignored when no bucket is configured. */
20
+ client?: S3ClientLike;
12
21
  }
13
22
  export declare class ArtifactStorage {
14
23
  private bucket?;
@@ -98,5 +107,23 @@ export declare class ArtifactStorage {
98
107
  */
99
108
  moveToQuarantine(artifact: ServerArtifact): Promise<string | null>;
100
109
  private keyFor;
110
+ /**
111
+ * Version-addressed copy of a published bundle plus its manifest (hasna/apps#1630):
112
+ * <prefix>/skills/<org>/<slug>/<version>/bundle.tar.gz
113
+ * <prefix>/skills/<org>/<slug>/<version>/manifest.json
114
+ * The content-addressed object under bundles/ stays the read path (dedupe); these keys
115
+ * are the durable, browsable history and are never deleted by orphan collection.
116
+ * Returns the placement recorded on the version row; in db mode nothing is written.
117
+ */
118
+ putVersionObjects(orgId: string, slug: string, version: string, bytes: OwnedBytes, manifest: Record<string, unknown>, contentType?: string): Promise<{
119
+ storageKind: BlobStorageKind;
120
+ storageKey?: string;
121
+ }>;
122
+ /** Where putVersionObjects WOULD place a version, without writing: recorded on the row before the objects exist. */
123
+ versionPlacement(orgId: string, slug: string, version: string): {
124
+ storageKind: BlobStorageKind;
125
+ storageKey?: string;
126
+ };
127
+ versionKeyFor(orgId: string, slug: string, version: string, file: string): string;
101
128
  private bundleKeyFor;
102
129
  }
@@ -3,6 +3,8 @@ export interface SkillsServerConfig {
3
3
  port: number;
4
4
  databaseUrl?: string;
5
5
  bootstrapApiKey?: string;
6
+ /** Publish the bundled corpus as versioned skills on boot (default on; set HASNA_SKILLS_SEED_BUNDLED_CORPUS=0 to skip). */
7
+ seedBundledCorpus: boolean;
6
8
  artifactBucket?: string;
7
9
  artifactPrefix: string;
8
10
  inlineWorker: boolean;
@@ -1,4 +1,4 @@
1
- import type { ServerArtifact, ServerPin, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillRecord } from "./types.js";
1
+ import type { ServerSkillVersion, ServerArtifact, ServerPin, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillRecord } from "./types.js";
2
2
  export declare function nowIso(): string;
3
3
  /**
4
4
  * Coerce a caller-supplied row limit to a non-negative integer.
@@ -22,3 +22,4 @@ export declare function rowToSkillBundle(row: Record<string, unknown>): ServerSk
22
22
  export declare function parseJsonObject(value: unknown): Record<string, unknown>;
23
23
  export declare function parseJsonArray(value: unknown): string[];
24
24
  export declare function dateString(value: unknown): string;
25
+ export declare function rowToSkillVersion(row: Record<string, unknown>): ServerSkillVersion;
@@ -0,0 +1,20 @@
1
+ import type { ArtifactStorage } from "./artifact-storage.js";
2
+ import type { ApiPrincipal, SkillsProductStore } from "./types.js";
3
+ export interface SeedBundledCorpusOptions {
4
+ store: SkillsProductStore;
5
+ artifactStorage: ArtifactStorage;
6
+ principal: ApiPrincipal;
7
+ /** Version recorded for every seeded skill. Defaults to the package version. */
8
+ version?: string;
9
+ log?: (line: string) => void;
10
+ }
11
+ export interface SeedBundledCorpusResult {
12
+ version: string;
13
+ seeded: string[];
14
+ skipped: string[];
15
+ failed: Array<{
16
+ slug: string;
17
+ error: string;
18
+ }>;
19
+ }
20
+ export declare function seedBundledCorpus(options: SeedBundledCorpusOptions): Promise<SeedBundledCorpusResult>;
@@ -2,7 +2,7 @@ import { type OwnedBytes } from "../lib/skill-bundle.js";
2
2
  import type { SkillMeta } from "../lib/registry-types.js";
3
3
  import type { ArtifactStorage } from "./artifact-storage.js";
4
4
  import type { SkillsServerConfig } from "./config.js";
5
- import { type ApiPrincipal, type PublishSkillInput, type ServerPin, type ServerSkillRecord, type SkillsProductStore } from "./types.js";
5
+ import { type ApiPrincipal, type PublishSkillInput, type ServerPin, type ServerSkillRecord, type ServerSkillVersion, type SkillsProductStore } from "./types.js";
6
6
  /** Wire shape of a pin: the client-facing facts, without the storage columns. */
7
7
  export declare function pinPayload(pin: ServerPin): Record<string, unknown>;
8
8
  /**
@@ -167,4 +167,21 @@ export declare function readPublishedBundle(store: SkillsProductStore, artifactS
167
167
  }>;
168
168
  export declare function assertPublishableSlug(slug: string): void;
169
169
  export declare function assertSha256(value: string): void;
170
+ /**
171
+ * Versions surface (hasna/apps#1630).
172
+ *
173
+ * GET /api/v1/skills/:slug/versions -> { slug, current, versions: [...] }
174
+ * GET /api/v1/skills/:slug/versions/:version -> one version's manifest
175
+ * GET /api/v1/skills/:slug/versions/:version/bundle -> the exact bytes of that version
176
+ *
177
+ * The registry row is the "current" pointer; a version's bytes come from the
178
+ * content-addressed bundle store so db-mode instances serve history exactly like S3 ones.
179
+ */
180
+ export declare function skillVersionPayload(version: ServerSkillVersion, currentSha?: string): Record<string, unknown>;
181
+ export declare function listSkillVersionsPayload(store: SkillsProductStore, principal: ApiPrincipal, slug: string): Promise<Record<string, unknown>>;
182
+ export declare function readSkillVersion(store: SkillsProductStore, principal: ApiPrincipal, slug: string, version: string): Promise<ServerSkillVersion>;
183
+ export declare function readSkillVersionBundle(store: SkillsProductStore, artifactStorage: ArtifactStorage, principal: ApiPrincipal, slug: string, version: string): Promise<{
184
+ version: ServerSkillVersion;
185
+ bytes: OwnedBytes;
186
+ }>;
170
187
  export {};
@@ -13,7 +13,7 @@
13
13
  * requirement; changing Postgres behaviour is not this module's job.
14
14
  */
15
15
  import { Database } from "bun:sqlite";
16
- import type { ApiPrincipal, ClaimRunInput, CreateRunInput, PublishSkillInput, RunTransitionPatch, ServerArtifact, ServerPin, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillRecord, SkillsProductStore, StoreBackendInfo, UpdateSkillPatch } from "./types.js";
16
+ import type { ApiPrincipal, ClaimRunInput, CreateRunInput, PublishSkillInput, RunTransitionPatch, ServerArtifact, ServerPin, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillVersion, ServerSkillRecord, SkillsProductStore, StoreBackendInfo, UpdateSkillPatch } from "./types.js";
17
17
  export interface SqliteStoreOptions {
18
18
  /** Apply pending migrations on open. Default true - it is what makes zero-config work. */
19
19
  migrate?: boolean;
@@ -102,6 +102,8 @@ export declare class SqliteSkillsStore implements SkillsProductStore {
102
102
  deleteSkill(principal: ApiPrincipal, slug: string, tombstoneWindowMs: number): Promise<ServerSkillRecord | null>;
103
103
  purgeExpiredTombstones(principal: ApiPrincipal): Promise<ServerSkillRecord[]>;
104
104
  getSkillBundle(principal: ApiPrincipal, sha256: string): Promise<ServerSkillBundle | null>;
105
+ listSkillVersions(principal: ApiPrincipal, slug: string): Promise<ServerSkillVersion[]>;
106
+ getSkillVersion(principal: ApiPrincipal, slug: string, version: string): Promise<ServerSkillVersion | null>;
105
107
  pinSkill(principal: ApiPrincipal, slug: string, metadata?: Record<string, unknown>): Promise<ServerPin>;
106
108
  unpinSkill(principal: ApiPrincipal, slug: string): Promise<boolean>;
107
109
  listPins(principal: ApiPrincipal): Promise<ServerPin[]>;
@@ -1,4 +1,4 @@
1
- import type { ApiPrincipal, ClaimRunInput, CreateRunInput, PublishSkillInput, RunTransitionPatch, ServerArtifact, ServerPin, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillRecord, SkillsProductStore, StoreBackendInfo, UpdateSkillPatch } from "./types.js";
1
+ import type { ApiPrincipal, ClaimRunInput, CreateRunInput, PublishSkillInput, RunTransitionPatch, ServerArtifact, ServerPin, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillRecord, ServerSkillVersion, SkillsProductStore, StoreBackendInfo, UpdateSkillPatch } from "./types.js";
2
2
  import { type SqliteStoreOptions } from "./sqlite-store.js";
3
3
  export declare function createArtifactId(): string;
4
4
  export interface StoreOptions {
@@ -31,6 +31,7 @@ export declare class MemorySkillsStore implements SkillsProductStore {
31
31
  private idempotency;
32
32
  private skills;
33
33
  private bundles;
34
+ private versions;
34
35
  private pins;
35
36
  constructor(apiKeys?: Array<{
36
37
  token: string;
@@ -67,6 +68,8 @@ export declare class MemorySkillsStore implements SkillsProductStore {
67
68
  listArtifacts(principal: ApiPrincipal, runId: string): Promise<ServerArtifact[]>;
68
69
  getArtifact(principal: ApiPrincipal, runId: string, id: string): Promise<ServerArtifact | null>;
69
70
  publishSkill(input: PublishSkillInput): Promise<ServerSkillRecord>;
71
+ listSkillVersions(principal: ApiPrincipal, slug: string): Promise<ServerSkillVersion[]>;
72
+ getSkillVersion(principal: ApiPrincipal, slug: string, version: string): Promise<ServerSkillVersion | null>;
70
73
  listSkills(principal: ApiPrincipal): Promise<ServerSkillRecord[]>;
71
74
  getSkill(principal: ApiPrincipal, slug: string): Promise<ServerSkillRecord | null>;
72
75
  updateSkill(principal: ApiPrincipal, slug: string, patch: UpdateSkillPatch, expectedRevisionId?: string): Promise<ServerSkillRecord | null>;
@@ -158,6 +161,8 @@ export declare class PostgresSkillsStore implements SkillsProductStore {
158
161
  deleteSkill(principal: ApiPrincipal, slug: string, tombstoneWindowMs: number): Promise<ServerSkillRecord | null>;
159
162
  purgeExpiredTombstones(principal: ApiPrincipal): Promise<ServerSkillRecord[]>;
160
163
  getSkillBundle(principal: ApiPrincipal, sha256: string): Promise<ServerSkillBundle | null>;
164
+ listSkillVersions(principal: ApiPrincipal, slug: string): Promise<ServerSkillVersion[]>;
165
+ getSkillVersion(principal: ApiPrincipal, slug: string, version: string): Promise<ServerSkillVersion | null>;
161
166
  pinSkill(principal: ApiPrincipal, slug: string, metadata?: Record<string, unknown>): Promise<ServerPin>;
162
167
  unpinSkill(principal: ApiPrincipal, slug: string): Promise<boolean>;
163
168
  listPins(principal: ApiPrincipal): Promise<ServerPin[]>;
@@ -31,6 +31,18 @@ export declare class SkillRevisionConflictError extends Error {
31
31
  readonly currentRevisionId: string | null;
32
32
  constructor(slug: string, expectedRevisionId: string | null | undefined, currentRevisionId: string | null);
33
33
  }
34
+ /**
35
+ * A publish was refused because (org, slug, version) already exists with a DIFFERENT bundle
36
+ * digest. Versions are immutable (hasna/apps#1630): the same digest is idempotent, a new
37
+ * digest needs a new version. Carries the stored digest so the client can say which.
38
+ */
39
+ export declare class SkillVersionExistsError extends Error {
40
+ readonly slug: string;
41
+ readonly version: string;
42
+ readonly existingSha256: string;
43
+ readonly attemptedSha256: string;
44
+ constructor(slug: string, version: string, existingSha256: string, attemptedSha256: string);
45
+ }
34
46
  export interface ApiPrincipal {
35
47
  apiKeyId: string;
36
48
  orgId: string;
@@ -182,6 +194,24 @@ export interface ServerSkillBundle {
182
194
  bytes?: OwnedBytes;
183
195
  createdAt: string;
184
196
  }
197
+ /**
198
+ * One immutable published version of a skill (hasna/apps#1630). The registry row is the
199
+ * mutable "current" pointer; these rows are the history: each (org, slug, version) exactly once,
200
+ * pointing at a content-addressed bundle that orphan collection must keep alive.
201
+ */
202
+ export interface ServerSkillVersion {
203
+ orgId: string;
204
+ slug: string;
205
+ version: string;
206
+ bundleSha256: string;
207
+ bundleByteSize: number;
208
+ storageKind: BlobStorageKind;
209
+ storageKey?: string;
210
+ /** Files (path -> sha256), byte counts and provenance the publisher sent; never secret. */
211
+ manifest: Record<string, unknown>;
212
+ publishedByUserId?: string;
213
+ createdAt: string;
214
+ }
185
215
  export interface PublishSkillInput {
186
216
  principal: ApiPrincipal;
187
217
  slug: string;
@@ -195,6 +225,16 @@ export interface PublishSkillInput {
195
225
  skillMd?: string;
196
226
  /** Omitted for a metadata-only publish or update. */
197
227
  bundle?: Omit<ServerSkillBundle, "orgId" | "createdAt">;
228
+ /**
229
+ * Publisher-supplied version manifest (file digests, provenance). Stored verbatim on the
230
+ * version row when `version` is set; ignored otherwise.
231
+ */
232
+ versionManifest?: Record<string, unknown>;
233
+ /** Version-addressed object key the API wrote (S3 mode); recorded on the version row. */
234
+ versionStorage?: {
235
+ storageKind: BlobStorageKind;
236
+ storageKey?: string;
237
+ };
198
238
  /**
199
239
  * Optimistic-concurrency guard (todos d061fcda): the revision_id (ETag) the writer
200
240
  * read. A publish against an existing, LIVE row requires the guard to name that row's
@@ -320,6 +360,9 @@ export interface SkillsProductStore {
320
360
  */
321
361
  purgeExpiredTombstones(principal: ApiPrincipal): Promise<ServerSkillRecord[]>;
322
362
  getSkillBundle(principal: ApiPrincipal, sha256: string): Promise<ServerSkillBundle | null>;
363
+ /** Every published version of a slug, newest first (hasna/apps#1630). */
364
+ listSkillVersions(principal: ApiPrincipal, slug: string): Promise<ServerSkillVersion[]>;
365
+ getSkillVersion(principal: ApiPrincipal, slug: string, version: string): Promise<ServerSkillVersion | null>;
323
366
  pinSkill(principal: ApiPrincipal, slug: string, metadata?: Record<string, unknown>): Promise<ServerPin>;
324
367
  /** False when this principal has no pin by that slug. */
325
368
  unpinSkill(principal: ApiPrincipal, slug: string): Promise<boolean>;
package/dist/storage.js CHANGED
@@ -49,19 +49,18 @@ var __require = import.meta.require;
49
49
  // src/lib/native-storage.ts
50
50
  import { createHash, createHmac } from "crypto";
51
51
  import {
52
- existsSync as existsSync3,
52
+ existsSync as existsSync4,
53
53
  mkdirSync as mkdirSync3,
54
54
  readFileSync as readFileSync3,
55
55
  readdirSync as readdirSync2,
56
56
  statSync as statSync2,
57
57
  writeFileSync as writeFileSync3
58
58
  } from "fs";
59
- import { dirname as dirname2, join as join3, normalize, relative, sep } from "path";
59
+ import { dirname as dirname2, join as join4, normalize, relative, sep } from "path";
60
60
 
61
61
  // src/lib/config.ts
62
- import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, readdirSync, statSync } from "fs";
63
- import { join, dirname } from "path";
64
- import { homedir } from "os";
62
+ import { existsSync as existsSync2, readFileSync, writeFileSync, mkdirSync, copyFileSync, readdirSync, statSync } from "fs";
63
+ import { join as join2, dirname } from "path";
65
64
 
66
65
  // src/lib/retired-settings.ts
67
66
  var RETIRED_ENV_SUFFIXES = ["_STORAGE_MODE", "_DEPLOYMENT_MODE", "_CLOUD_MODE"];
@@ -109,6 +108,124 @@ function assertNoRetiredConfigKeys(config, source) {
109
108
  }
110
109
  }
111
110
 
111
+ // src/lib/app-home.ts
112
+ import { existsSync } from "fs";
113
+ import { homedir } from "os";
114
+ import { join, resolve } from "path";
115
+ import { homedir as pathsResolverHomedir } from "os";
116
+ import { join as pathsResolverJoin } from "path";
117
+ var PATHS_RESOLVER_KIND_ENV = {
118
+ config: "HASNA_CONFIG_HOME",
119
+ data: "HASNA_DATA_HOME",
120
+ state: "HASNA_STATE_HOME",
121
+ cache: "HASNA_CACHE_HOME"
122
+ };
123
+ var PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
124
+ function pathsResolverAssertApp(app) {
125
+ if (typeof app !== "string" || app.length === 0) {
126
+ throw new TypeError("paths: app must be a non-empty string");
127
+ }
128
+ if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
129
+ throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
130
+ }
131
+ }
132
+ function pathsResolverAssertKind(kind) {
133
+ if (!Object.keys(PATHS_RESOLVER_KIND_ENV).includes(kind)) {
134
+ throw new TypeError(`paths: invalid path kind "${kind}" \u2014 expected one of ${Object.keys(PATHS_RESOLVER_KIND_ENV).join(", ")}`);
135
+ }
136
+ }
137
+ function pathsResolverBaseDir(kind, options) {
138
+ pathsResolverAssertKind(kind);
139
+ const env = options.env ?? process.env;
140
+ const override = env[PATHS_RESOLVER_KIND_ENV[kind]];
141
+ if (typeof override === "string" && override.length > 0)
142
+ return override;
143
+ const home = options.home ?? pathsResolverHomedir();
144
+ const platform = options.platform ?? process.platform;
145
+ if (platform === "darwin") {
146
+ switch (kind) {
147
+ case "config":
148
+ case "data":
149
+ return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
150
+ case "cache":
151
+ return pathsResolverJoin(home, "Library", "Caches", "Hasna");
152
+ case "state":
153
+ return pathsResolverJoin(home, "Library", "Logs", "Hasna");
154
+ }
155
+ }
156
+ switch (kind) {
157
+ case "config":
158
+ return pathsResolverJoin(home, ".config", "hasna");
159
+ case "data":
160
+ return pathsResolverJoin(home, ".local", "share", "hasna");
161
+ case "state":
162
+ return pathsResolverJoin(home, ".local", "state", "hasna");
163
+ case "cache":
164
+ return pathsResolverJoin(home, ".cache", "hasna");
165
+ }
166
+ }
167
+ function pathsResolverResolve(kind, options) {
168
+ pathsResolverAssertApp(options.app);
169
+ const appSegment = options.internal === true ? pathsResolverJoin("internal", options.app) : options.app;
170
+ return pathsResolverJoin(pathsResolverBaseDir(kind, options), appSegment);
171
+ }
172
+ function dataDir(options) {
173
+ return pathsResolverResolve("data", options);
174
+ }
175
+ var DATA_DIR_ENV = "HASNA_SKILLS_DIR";
176
+ var HASNA_SKILLS_HOME_ENV = "HASNA_SKILLS_HOME";
177
+ var SKILLS_HOME_ENV = "SKILLS_HOME";
178
+ var DEFAULT_SQLITE_FILENAME = "server.db";
179
+ var GLOBAL_CONFIG_FILENAME = "config.json";
180
+ function effectiveHome() {
181
+ return process.env["HOME"] || process.env["USERPROFILE"] || homedir() || "/tmp";
182
+ }
183
+ function legacyDataRoot() {
184
+ return join(effectiveHome(), ".hasna", "skills");
185
+ }
186
+ function resolverDataRoot(home = effectiveHome(), env) {
187
+ return dataDir({ app: "skills", home, env });
188
+ }
189
+ function adoptResolverDataRoot(resolved, env = process.env) {
190
+ const dataOverride = env.HASNA_DATA_HOME;
191
+ if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
192
+ return true;
193
+ return existsSync(join(resolved, DEFAULT_SQLITE_FILENAME)) || existsSync(join(resolved, GLOBAL_CONFIG_FILENAME));
194
+ }
195
+ function exactDataRoot() {
196
+ for (const key of [DATA_DIR_ENV, HASNA_SKILLS_HOME_ENV, SKILLS_HOME_ENV]) {
197
+ const dir = process.env[key]?.trim();
198
+ if (dir)
199
+ return resolve(dir);
200
+ }
201
+ return;
202
+ }
203
+ function hasExactOverride(env = process.env) {
204
+ return Boolean(env[DATA_DIR_ENV]?.trim()) || Boolean(env[HASNA_SKILLS_HOME_ENV]?.trim()) || Boolean(env[SKILLS_HOME_ENV]?.trim());
205
+ }
206
+ function hasOperatorOverride(env = process.env) {
207
+ return hasExactOverride(env) || Boolean(env.HASNA_DATA_HOME?.trim());
208
+ }
209
+ function getDataRoot() {
210
+ const exact = exactDataRoot();
211
+ if (exact)
212
+ return exact;
213
+ const resolved = resolverDataRoot();
214
+ return adoptResolverDataRoot(resolved) ? resolve(resolved) : resolve(legacyDataRoot());
215
+ }
216
+ function skillsDataRootForHome(home) {
217
+ const isOwnHome = resolve(home) === resolve(effectiveHome()) || resolve(home) === resolve(homedir());
218
+ if (isOwnHome) {
219
+ const exact = exactDataRoot();
220
+ if (exact)
221
+ return exact;
222
+ const resolved2 = resolverDataRoot(home);
223
+ return adoptResolverDataRoot(resolved2) ? resolve(resolved2) : resolve(join(home, ".hasna", "skills"));
224
+ }
225
+ const resolved = resolverDataRoot(home, {});
226
+ return adoptResolverDataRoot(resolved, {}) ? resolve(resolved) : resolve(join(home, ".hasna", "skills"));
227
+ }
228
+
112
229
  // src/lib/config.ts
113
230
  var ENUM_KEYS = {
114
231
  defaultAgent: ["claude", "codex", "gemini", "pi", "opencode", "all"],
@@ -123,19 +240,19 @@ function allowedValues(key) {
123
240
  return ENUM_KEYS[key];
124
241
  }
125
242
  function mergeDirectoryContents(sourceDir, targetDir) {
126
- if (!existsSync(sourceDir))
243
+ if (!existsSync2(sourceDir))
127
244
  return;
128
245
  mkdirSync(targetDir, { recursive: true });
129
246
  for (const entry of readdirSync(sourceDir)) {
130
- const sourcePath = join(sourceDir, entry);
131
- const targetPath = join(targetDir, entry);
247
+ const sourcePath = join2(sourceDir, entry);
248
+ const targetPath = join2(targetDir, entry);
132
249
  try {
133
250
  const sourceStat = statSync(sourcePath);
134
251
  if (sourceStat.isDirectory()) {
135
252
  mergeDirectoryContents(sourcePath, targetPath);
136
253
  continue;
137
254
  }
138
- if (!existsSync(targetPath))
255
+ if (!existsSync2(targetPath))
139
256
  copyFileSync(sourcePath, targetPath);
140
257
  } catch {}
141
258
  }
@@ -160,44 +277,40 @@ function normalizeConfigValue(key, value) {
160
277
  return value.trim() ? value : undefined;
161
278
  return;
162
279
  }
163
- var DATA_DIR_ENV = "HASNA_SKILLS_DIR";
164
280
  var INSTALLED_SKILLS_DIRNAME = "installed";
165
281
  var SKILLS_CACHE_DIRNAME = "skills";
166
282
  var LAYOUT_MIGRATION_RECORD = ".layout-migration.json";
167
283
  function isOwnerLayoutMigrated(appDir) {
168
- return existsSync(join(appDir, SKILLS_CACHE_DIRNAME, LAYOUT_MIGRATION_RECORD));
284
+ return existsSync2(join2(appDir, SKILLS_CACHE_DIRNAME, LAYOUT_MIGRATION_RECORD));
169
285
  }
170
286
  function getDataDir() {
171
- const override = process.env[DATA_DIR_ENV];
172
- if (override) {
173
- try {
174
- mkdirSync(override, { recursive: true });
175
- } catch {}
176
- return override;
177
- }
178
- const home = process.env["HOME"] || process.env["USERPROFILE"] || homedir();
179
- const newDir = join(home, ".hasna", "skills");
180
- const oldDir = join(home, ".skills");
181
- const oldConfigFile = join(home, ".skillsrc");
182
- mkdirSync(newDir, { recursive: true });
287
+ const root = getDataRoot();
183
288
  try {
184
- mergeDirectoryContents(oldDir, newDir);
289
+ mkdirSync(root, { recursive: true });
185
290
  } catch {}
186
- if (existsSync(oldConfigFile) && !existsSync(join(newDir, "config.json"))) {
291
+ if (hasOperatorOverride())
292
+ return root;
293
+ const home = effectiveHome();
294
+ const oldDir = join2(home, ".skills");
295
+ const oldConfigFile = join2(home, ".skillsrc");
296
+ try {
297
+ mergeDirectoryContents(oldDir, root);
298
+ } catch {}
299
+ if (existsSync2(oldConfigFile) && !existsSync2(join2(root, "config.json"))) {
187
300
  try {
188
- copyFileSync(oldConfigFile, join(newDir, "config.json"));
301
+ copyFileSync(oldConfigFile, join2(root, "config.json"));
189
302
  } catch {}
190
303
  }
191
- return newDir;
304
+ return root;
192
305
  }
193
306
  function getConfigPath(scope) {
194
307
  if (scope === "global") {
195
- return join(getDataDir(), "config.json");
308
+ return join2(getDataDir(), "config.json");
196
309
  }
197
- return join(process.cwd(), "skills.config.json");
310
+ return join2(process.cwd(), "skills.config.json");
198
311
  }
199
312
  function readConfigFile(path) {
200
- if (!existsSync(path))
313
+ if (!existsSync2(path))
201
314
  return {};
202
315
  let parsed;
203
316
  try {
@@ -233,7 +346,7 @@ function saveConfig(key, value, scope = "project") {
233
346
  }
234
347
  const filePath = getConfigPath(scope);
235
348
  let existing = {};
236
- if (existsSync(filePath)) {
349
+ if (existsSync2(filePath)) {
237
350
  try {
238
351
  existing = JSON.parse(readFileSync(filePath, "utf-8"));
239
352
  if (typeof existing !== "object" || existing === null || Array.isArray(existing)) {
@@ -244,7 +357,7 @@ function saveConfig(key, value, scope = "project") {
244
357
  }
245
358
  } else {
246
359
  const dir = dirname(filePath);
247
- if (!existsSync(dir)) {
360
+ if (!existsSync2(dir)) {
248
361
  mkdirSync(dir, { recursive: true });
249
362
  }
250
363
  }
@@ -258,7 +371,7 @@ function unsetConfig(key, scope = "project") {
258
371
  throw new Error(`Unknown config key: ${key}. Valid keys: ${validKeys().join(", ")}`);
259
372
  }
260
373
  const filePath = getConfigPath(scope);
261
- if (!existsSync(filePath))
374
+ if (!existsSync2(filePath))
262
375
  return false;
263
376
  let existing;
264
377
  try {
@@ -278,8 +391,8 @@ function unsetConfig(key, scope = "project") {
278
391
  }
279
392
 
280
393
  // src/lib/project-state.ts
281
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
282
- import { join as join2 } from "path";
394
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
395
+ import { join as join3 } from "path";
283
396
 
284
397
  // src/lib/utils.ts
285
398
  function normalizeSkillName(name) {
@@ -301,14 +414,14 @@ var SKILLS_PROJECT_DIR = ".skills";
301
414
  var PROJECT_CONFIG_FILE = "project.json";
302
415
  var DEFAULT_EXPORT_DIR = ".skills/exports";
303
416
  function getProjectStateDir(targetDir = process.cwd()) {
304
- return join2(targetDir, SKILLS_PROJECT_DIR);
417
+ return join3(targetDir, SKILLS_PROJECT_DIR);
305
418
  }
306
419
  function getProjectConfigPath(targetDir = process.cwd()) {
307
- return join2(getProjectStateDir(targetDir), PROJECT_CONFIG_FILE);
420
+ return join3(getProjectStateDir(targetDir), PROJECT_CONFIG_FILE);
308
421
  }
309
422
  function loadProjectConfig(targetDir = process.cwd()) {
310
423
  const path = getProjectConfigPath(targetDir);
311
- if (!existsSync2(path))
424
+ if (!existsSync3(path))
312
425
  return null;
313
426
  try {
314
427
  return normalizeProjectConfig(JSON.parse(readFileSync2(path, "utf-8")));
@@ -561,7 +674,7 @@ function getSkillsNativeStorageStatus(options = {}) {
561
674
  local: {
562
675
  dataDir: getDataDir(),
563
676
  projectStateDir: getProjectStateDir(targetDir),
564
- feedbackDbPath: join3(getDataDir(), "skills.db")
677
+ feedbackDbPath: join4(getDataDir(), "skills.db")
565
678
  },
566
679
  remote: {
567
680
  databaseConfigured: Boolean(config.databaseUrl),
@@ -584,7 +697,7 @@ function getStorageStatus(options = {}) {
584
697
  function exportSkillsLocalSnapshot(targetDir = process.cwd(), options = {}) {
585
698
  const projectStateDir = getProjectStateDir(targetDir);
586
699
  const files = [];
587
- if (existsSync3(projectStateDir)) {
700
+ if (existsSync4(projectStateDir)) {
588
701
  for (const filePath of walkFiles(projectStateDir)) {
589
702
  const bytes = readFileSync3(filePath);
590
703
  const relativePath = toPosix(relative(targetDir, filePath));
@@ -611,7 +724,7 @@ function importSkillsLocalSnapshot(snapshot, targetDir = process.cwd(), options
611
724
  continue;
612
725
  }
613
726
  const absolutePath = resolveSnapshotPath(targetDir, file.path);
614
- if (existsSync3(absolutePath) && !options.overwrite) {
727
+ if (existsSync4(absolutePath) && !options.overwrite) {
615
728
  skipped += 1;
616
729
  continue;
617
730
  }
@@ -918,7 +1031,7 @@ function parsePositiveInteger(value) {
918
1031
  function walkFiles(dir) {
919
1032
  const files = [];
920
1033
  for (const entry of readdirSync2(dir)) {
921
- const full = join3(dir, entry);
1034
+ const full = join4(dir, entry);
922
1035
  const stats = statSync2(full);
923
1036
  if (stats.isDirectory())
924
1037
  files.push(...walkFiles(full));
@@ -935,7 +1048,7 @@ function resolveSnapshotPath(targetDir, snapshotPath) {
935
1048
  if (!toPosix(normalizedPath).startsWith(".skills/")) {
936
1049
  throw new Error(`Snapshot path must stay inside .skills: ${snapshotPath}`);
937
1050
  }
938
- return join3(targetDir, normalizedPath);
1051
+ return join4(targetDir, normalizedPath);
939
1052
  }
940
1053
  function normalizeS3Prefix(prefix) {
941
1054
  return (prefix ?? "").trim().replace(/^\/+|\/+$/g, "");
@@ -0,0 +1,30 @@
1
+ -- Immutable skill versions (hasna/apps#1630).
2
+ --
3
+ -- skills_registry keeps ONE row per (org, slug): the current revision. Every re-publish
4
+ -- replaced it and purged the previous bundle, so there was no history. This table records
5
+ -- each published name@version once: the content-addressed bundle it points at, the file
6
+ -- manifest and provenance the client sent, and where the version-addressed copy lives.
7
+ --
8
+ -- Rows are never updated. A publish of an existing (org, slug, version) with a different
9
+ -- bundle digest is refused (409 SKILL_VERSION_EXISTS); the same digest is idempotent.
10
+ -- bundle_sha256 references skills_bundles(org_id, sha256) softly, like skills_registry
11
+ -- does; orphan collection must consult this table so a version's bundle outlives the
12
+ -- registry row that first published it.
13
+ CREATE TABLE IF NOT EXISTS skills_versions (
14
+ org_id text NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
15
+ slug text NOT NULL,
16
+ version text NOT NULL,
17
+ bundle_sha256 text NOT NULL,
18
+ bundle_byte_size integer NOT NULL,
19
+ -- 'db' when the bytes live in skills_bundles.body_blob, 's3' when a version-addressed
20
+ -- object (<prefix>/skills/<org>/<slug>/<version>/bundle.tar.gz) was written.
21
+ storage_kind text NOT NULL DEFAULT 'db',
22
+ storage_key text,
23
+ manifest_json jsonb NOT NULL DEFAULT '{}'::jsonb,
24
+ published_by_user_id text REFERENCES users(id) ON DELETE SET NULL,
25
+ created_at timestamptz NOT NULL DEFAULT now(),
26
+ PRIMARY KEY (org_id, slug, version)
27
+ );
28
+
29
+ CREATE INDEX IF NOT EXISTS skills_versions_org_slug_created_idx ON skills_versions (org_id, slug, created_at DESC);
30
+ CREATE INDEX IF NOT EXISTS skills_versions_bundle_idx ON skills_versions (org_id, bundle_sha256);
@@ -0,0 +1,17 @@
1
+ -- Immutable skill versions (hasna/apps#1630). SQLite twin of migrations/postgres/0006.
2
+ CREATE TABLE IF NOT EXISTS skills_versions (
3
+ org_id text NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
4
+ slug text NOT NULL,
5
+ version text NOT NULL,
6
+ bundle_sha256 text NOT NULL,
7
+ bundle_byte_size integer NOT NULL,
8
+ storage_kind text NOT NULL DEFAULT 'db',
9
+ storage_key text,
10
+ manifest_json text NOT NULL DEFAULT '{}',
11
+ published_by_user_id text REFERENCES users(id) ON DELETE SET NULL,
12
+ created_at text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
13
+ PRIMARY KEY (org_id, slug, version)
14
+ );
15
+
16
+ CREATE INDEX IF NOT EXISTS skills_versions_org_slug_created_idx ON skills_versions (org_id, slug, created_at DESC);
17
+ CREATE INDEX IF NOT EXISTS skills_versions_bundle_idx ON skills_versions (org_id, bundle_sha256);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/skills",
3
- "version": "0.1.71",
3
+ "version": "0.2.0",
4
4
  "description": "Skills library for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -80,7 +80,7 @@
80
80
  "author": "Hasna",
81
81
  "license": "Apache-2.0",
82
82
  "devDependencies": {
83
- "@types/bun": "latest",
83
+ "@types/bun": "1.3.14",
84
84
  "@types/node": "25.2.3",
85
85
  "@types/react": "^18.2.0",
86
86
  "bun-types": "1.3.14",