@hasna/skills 0.1.72 → 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
@@ -56,11 +56,11 @@ import {
56
56
  statSync as statSync2,
57
57
  writeFileSync as writeFileSync3
58
58
  } from "fs";
59
- import { dirname as dirname2, join as join5, 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
62
  import { existsSync as existsSync2, readFileSync, writeFileSync, mkdirSync, copyFileSync, readdirSync, statSync } from "fs";
63
- import { join as join3, dirname } from "path";
63
+ import { join as join2, dirname } from "path";
64
64
 
65
65
  // src/lib/retired-settings.ts
66
66
  var RETIRED_ENV_SUFFIXES = ["_STORAGE_MODE", "_DEPLOYMENT_MODE", "_CLOUD_MODE"];
@@ -110,94 +110,87 @@ function assertNoRetiredConfigKeys(config, source) {
110
110
 
111
111
  // src/lib/app-home.ts
112
112
  import { existsSync } from "fs";
113
- import { homedir as homedir2 } from "os";
114
- import { join as join2, resolve } from "path";
115
-
116
- // ../../node_modules/.bun/@hasna+paths@0.1.0/node_modules/@hasna/paths/dist/index.js
117
113
  import { homedir } from "os";
118
- import { join } from "path";
119
- var KIND_ENV = {
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 = {
120
118
  config: "HASNA_CONFIG_HOME",
121
119
  data: "HASNA_DATA_HOME",
122
120
  state: "HASNA_STATE_HOME",
123
121
  cache: "HASNA_CACHE_HOME"
124
122
  };
125
- var APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
126
- function assertApp(app) {
123
+ var PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
124
+ function pathsResolverAssertApp(app) {
127
125
  if (typeof app !== "string" || app.length === 0) {
128
126
  throw new TypeError("paths: app must be a non-empty string");
129
127
  }
130
- if (!APP_SLUG_RE.test(app)) {
128
+ if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
131
129
  throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
132
130
  }
133
131
  }
134
- function envOf(options) {
135
- return options.env ?? process.env;
136
- }
137
- function envValue(options, kind) {
138
- const value = envOf(options)[KIND_ENV[kind]];
139
- return typeof value === "string" && value.length > 0 ? value : undefined;
140
- }
141
- function isMacOS(platform) {
142
- return platform === "darwin";
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
+ }
143
136
  }
144
- function baseDir(kind, options) {
145
- const override = envValue(options, kind);
146
- if (override)
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)
147
142
  return override;
148
- const home = options.home ?? homedir();
143
+ const home = options.home ?? pathsResolverHomedir();
149
144
  const platform = options.platform ?? process.platform;
150
- if (isMacOS(platform)) {
145
+ if (platform === "darwin") {
151
146
  switch (kind) {
152
147
  case "config":
153
148
  case "data":
154
- return join(home, "Library", "Application Support", "Hasna");
149
+ return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
155
150
  case "cache":
156
- return join(home, "Library", "Caches", "Hasna");
151
+ return pathsResolverJoin(home, "Library", "Caches", "Hasna");
157
152
  case "state":
158
- return join(home, "Library", "Logs", "Hasna");
153
+ return pathsResolverJoin(home, "Library", "Logs", "Hasna");
159
154
  }
160
155
  }
161
156
  switch (kind) {
162
157
  case "config":
163
- return join(home, ".config", "hasna");
158
+ return pathsResolverJoin(home, ".config", "hasna");
164
159
  case "data":
165
- return join(home, ".local", "share", "hasna");
160
+ return pathsResolverJoin(home, ".local", "share", "hasna");
166
161
  case "state":
167
- return join(home, ".local", "state", "hasna");
162
+ return pathsResolverJoin(home, ".local", "state", "hasna");
168
163
  case "cache":
169
- return join(home, ".cache", "hasna");
164
+ return pathsResolverJoin(home, ".cache", "hasna");
170
165
  }
171
166
  }
172
- function resolvePath(kind, options) {
173
- assertApp(options.app);
174
- const appSegment = options.internal === true ? join("internal", options.app) : options.app;
175
- return join(baseDir(kind, options), appSegment);
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);
176
171
  }
177
172
  function dataDir(options) {
178
- return resolvePath("data", options);
173
+ return pathsResolverResolve("data", options);
179
174
  }
180
-
181
- // src/lib/app-home.ts
182
175
  var DATA_DIR_ENV = "HASNA_SKILLS_DIR";
183
176
  var HASNA_SKILLS_HOME_ENV = "HASNA_SKILLS_HOME";
184
177
  var SKILLS_HOME_ENV = "SKILLS_HOME";
185
178
  var DEFAULT_SQLITE_FILENAME = "server.db";
186
179
  var GLOBAL_CONFIG_FILENAME = "config.json";
187
180
  function effectiveHome() {
188
- return process.env["HOME"] || process.env["USERPROFILE"] || homedir2() || "/tmp";
181
+ return process.env["HOME"] || process.env["USERPROFILE"] || homedir() || "/tmp";
189
182
  }
190
183
  function legacyDataRoot() {
191
- return join2(effectiveHome(), ".hasna", "skills");
184
+ return join(effectiveHome(), ".hasna", "skills");
192
185
  }
193
- function resolverDataRoot(home = effectiveHome()) {
194
- return dataDir({ app: "skills", home });
186
+ function resolverDataRoot(home = effectiveHome(), env) {
187
+ return dataDir({ app: "skills", home, env });
195
188
  }
196
189
  function adoptResolverDataRoot(resolved, env = process.env) {
197
190
  const dataOverride = env.HASNA_DATA_HOME;
198
191
  if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
199
192
  return true;
200
- return existsSync(join2(resolved, DEFAULT_SQLITE_FILENAME)) || existsSync(join2(resolved, GLOBAL_CONFIG_FILENAME));
193
+ return existsSync(join(resolved, DEFAULT_SQLITE_FILENAME)) || existsSync(join(resolved, GLOBAL_CONFIG_FILENAME));
201
194
  }
202
195
  function exactDataRoot() {
203
196
  for (const key of [DATA_DIR_ENV, HASNA_SKILLS_HOME_ENV, SKILLS_HOME_ENV]) {
@@ -221,8 +214,16 @@ function getDataRoot() {
221
214
  return adoptResolverDataRoot(resolved) ? resolve(resolved) : resolve(legacyDataRoot());
222
215
  }
223
216
  function skillsDataRootForHome(home) {
224
- const resolved = resolverDataRoot(home);
225
- return adoptResolverDataRoot(resolved) ? resolve(resolved) : resolve(join2(home, ".hasna", "skills"));
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"));
226
227
  }
227
228
 
228
229
  // src/lib/config.ts
@@ -243,8 +244,8 @@ function mergeDirectoryContents(sourceDir, targetDir) {
243
244
  return;
244
245
  mkdirSync(targetDir, { recursive: true });
245
246
  for (const entry of readdirSync(sourceDir)) {
246
- const sourcePath = join3(sourceDir, entry);
247
- const targetPath = join3(targetDir, entry);
247
+ const sourcePath = join2(sourceDir, entry);
248
+ const targetPath = join2(targetDir, entry);
248
249
  try {
249
250
  const sourceStat = statSync(sourcePath);
250
251
  if (sourceStat.isDirectory()) {
@@ -280,7 +281,7 @@ var INSTALLED_SKILLS_DIRNAME = "installed";
280
281
  var SKILLS_CACHE_DIRNAME = "skills";
281
282
  var LAYOUT_MIGRATION_RECORD = ".layout-migration.json";
282
283
  function isOwnerLayoutMigrated(appDir) {
283
- return existsSync2(join3(appDir, SKILLS_CACHE_DIRNAME, LAYOUT_MIGRATION_RECORD));
284
+ return existsSync2(join2(appDir, SKILLS_CACHE_DIRNAME, LAYOUT_MIGRATION_RECORD));
284
285
  }
285
286
  function getDataDir() {
286
287
  const root = getDataRoot();
@@ -290,23 +291,23 @@ function getDataDir() {
290
291
  if (hasOperatorOverride())
291
292
  return root;
292
293
  const home = effectiveHome();
293
- const oldDir = join3(home, ".skills");
294
- const oldConfigFile = join3(home, ".skillsrc");
294
+ const oldDir = join2(home, ".skills");
295
+ const oldConfigFile = join2(home, ".skillsrc");
295
296
  try {
296
297
  mergeDirectoryContents(oldDir, root);
297
298
  } catch {}
298
- if (existsSync2(oldConfigFile) && !existsSync2(join3(root, "config.json"))) {
299
+ if (existsSync2(oldConfigFile) && !existsSync2(join2(root, "config.json"))) {
299
300
  try {
300
- copyFileSync(oldConfigFile, join3(root, "config.json"));
301
+ copyFileSync(oldConfigFile, join2(root, "config.json"));
301
302
  } catch {}
302
303
  }
303
304
  return root;
304
305
  }
305
306
  function getConfigPath(scope) {
306
307
  if (scope === "global") {
307
- return join3(getDataDir(), "config.json");
308
+ return join2(getDataDir(), "config.json");
308
309
  }
309
- return join3(process.cwd(), "skills.config.json");
310
+ return join2(process.cwd(), "skills.config.json");
310
311
  }
311
312
  function readConfigFile(path) {
312
313
  if (!existsSync2(path))
@@ -391,7 +392,7 @@ function unsetConfig(key, scope = "project") {
391
392
 
392
393
  // src/lib/project-state.ts
393
394
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
394
- import { join as join4 } from "path";
395
+ import { join as join3 } from "path";
395
396
 
396
397
  // src/lib/utils.ts
397
398
  function normalizeSkillName(name) {
@@ -413,10 +414,10 @@ var SKILLS_PROJECT_DIR = ".skills";
413
414
  var PROJECT_CONFIG_FILE = "project.json";
414
415
  var DEFAULT_EXPORT_DIR = ".skills/exports";
415
416
  function getProjectStateDir(targetDir = process.cwd()) {
416
- return join4(targetDir, SKILLS_PROJECT_DIR);
417
+ return join3(targetDir, SKILLS_PROJECT_DIR);
417
418
  }
418
419
  function getProjectConfigPath(targetDir = process.cwd()) {
419
- return join4(getProjectStateDir(targetDir), PROJECT_CONFIG_FILE);
420
+ return join3(getProjectStateDir(targetDir), PROJECT_CONFIG_FILE);
420
421
  }
421
422
  function loadProjectConfig(targetDir = process.cwd()) {
422
423
  const path = getProjectConfigPath(targetDir);
@@ -673,7 +674,7 @@ function getSkillsNativeStorageStatus(options = {}) {
673
674
  local: {
674
675
  dataDir: getDataDir(),
675
676
  projectStateDir: getProjectStateDir(targetDir),
676
- feedbackDbPath: join5(getDataDir(), "skills.db")
677
+ feedbackDbPath: join4(getDataDir(), "skills.db")
677
678
  },
678
679
  remote: {
679
680
  databaseConfigured: Boolean(config.databaseUrl),
@@ -1030,7 +1031,7 @@ function parsePositiveInteger(value) {
1030
1031
  function walkFiles(dir) {
1031
1032
  const files = [];
1032
1033
  for (const entry of readdirSync2(dir)) {
1033
- const full = join5(dir, entry);
1034
+ const full = join4(dir, entry);
1034
1035
  const stats = statSync2(full);
1035
1036
  if (stats.isDirectory())
1036
1037
  files.push(...walkFiles(full));
@@ -1047,7 +1048,7 @@ function resolveSnapshotPath(targetDir, snapshotPath) {
1047
1048
  if (!toPosix(normalizedPath).startsWith(".skills/")) {
1048
1049
  throw new Error(`Snapshot path must stay inside .skills: ${snapshotPath}`);
1049
1050
  }
1050
- return join5(targetDir, normalizedPath);
1051
+ return join4(targetDir, normalizedPath);
1051
1052
  }
1052
1053
  function normalizeS3Prefix(prefix) {
1053
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.72",
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",
@@ -92,7 +92,6 @@
92
92
  "@aws-sdk/client-ecs": "^3.1079.0",
93
93
  "@aws-sdk/client-s3": "^3.1079.0",
94
94
  "@hasna/events": "0.1.16",
95
- "@hasna/paths": "0.1.0",
96
95
  "@modelcontextprotocol/sdk": "^1.26.0",
97
96
  "chalk": "^5.3.0",
98
97
  "commander": "^12.1.0",