@hivemagnus/hive-skills 1.1.0 → 1.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/README.md CHANGED
@@ -19,17 +19,18 @@ OS-backed sign-in. SQLite is provided by Node; there are no native npm add-ons.
19
19
  Agent deployment also needs Bash, OpenSSH, and permission to enable the target's user timer.
20
20
 
21
21
  ```bash
22
- npm install --prefix /opt/hive-skills/runtime @hivemagnus/hive-skills@1.1.0
22
+ npm install --prefix /opt/hive-skills/runtime @hivemagnus/hive-skills@1.2.0
23
23
  /opt/hive-skills/runtime/node_modules/.bin/hive-skills --env-file /opt/hive-skills/skills.env
24
24
  ```
25
25
 
26
26
  Or run `npx @hivemagnus/hive-skills --env-file skills.env`. The CLI accepts `start`
27
- (the default), `seed`, `check`, `env-schema`, `--help` and `--version`. `check` validates TLS,
27
+ (the default), `seed`, `check`, `env-schema`, `migrate`, `upgrade`, `rollback`, `--help` and `--version`. `check` validates TLS,
28
28
  configuration and an existing database without creating or migrating state. `env-schema` prints
29
29
  configuration metadata without loading runtime configuration. Environment-file paths may be absolute;
30
30
  existing environment variables win. Without `--env-file`, the historical `./.env` lookup
31
31
  is retained. Help/version do not initialize storage. A fresh install starts with an empty
32
- catalog; startup never seeds or replaces existing records.
32
+ catalog; startup never seeds or replaces existing records. Run `hive-skills migrate`
33
+ explicitly before first startup, or enable `HIVE_AUTO_MIGRATE=true` with backup access.
33
34
 
34
35
  Configure these values in a private environment file (mode 0600):
35
36
 
@@ -155,3 +156,13 @@ version in `package.json`. CI also checks the package allowlist and npm 12 insta
155
156
  The package publishes from `.github/workflows/publish.yml` alongside the other packages;
156
157
  configure its npm trusted publisher for that workflow and the `npm-publish` environment after
157
158
  the initial authenticated publication.
159
+
160
+ ## Managed upgrades
161
+
162
+ Use `hive upgrade --dry-run`, then `hive upgrade` for the enrolled release bundle. This server's
163
+ `upgrade` and `rollback` commands delegate to the same owner-run launcher. `migrate --plan --json`
164
+ is read-only; migration requires a verified backup. Set `HIVE_AUTO_MIGRATE=true` explicitly for
165
+ startup migrations outside a managed install. Slow or one-way automatic migrations are refused.
166
+ `check --json` reports configuration without initializing state. No upgrade rotates signing keys
167
+ or reseeds existing accounts. See [UPGRADING.md](https://github.com/ChefTaco/hive/blob/main/UPGRADING.md)
168
+ for adoption, compatibility, recovery, update notices and container controls.
package/dist/app.js CHANGED
@@ -1,3 +1,7 @@
1
+ import { maintenanceActive } from './release/runtime.js';
2
+ import { metadata } from './release/runtime.js';
3
+ import { packageRoot as releasePackageRoot } from './paths.js';
4
+ import { home as upgradeHome, updateStatus } from './release/runtime.js';
1
5
  import { bodyLimit } from 'hono/body-limit';
2
6
  import { existsSync, readFileSync } from 'node:fs';
3
7
  import { Hono } from 'hono';
@@ -50,6 +54,8 @@ const archiveUrl = (skillSlug, release, channel) => {
50
54
  const base = `${config.publicUrl}/v1/skills/${skillSlug}/releases/${release.version}/archive`;
51
55
  return channel === 'stable' ? base : `${base}?channel=${channel}`;
52
56
  };
57
+ app.use('*', async (c, next) => { if (maintenanceActive(new URL(c.req.url).pathname))
58
+ return c.json({ error: 'Hive is undergoing maintenance' }, 503); await next(); });
53
59
  app.use('*', bodyLimit({ maxSize: 70 * 1024 * 1024 }));
54
60
  for (const target of config.targets)
55
61
  store.seedTarget(target);
@@ -60,11 +66,11 @@ app.use('*', async (c, next) => {
60
66
  await next();
61
67
  });
62
68
  app.get('/', (c) => c.html(page));
63
- app.get('/health/live', (c) => c.json({ status: 'ok', service: 'hive-skills' }));
69
+ app.get('/health/live', (c) => c.json({ ...metadata(releasePackageRoot), status: 'ok', service: 'hive-skills' }));
64
70
  app.get('/healthz', (c) => {
65
71
  try {
66
72
  const health = store.readiness();
67
- return c.json({ status: health.ready ? 'ok' : 'degraded', service: 'hive-skills', ...health }, health.ready ? 200 : 503);
73
+ return c.json({ ...metadata(releasePackageRoot), status: health.ready ? 'ok' : 'degraded', service: 'hive-skills', ...health }, health.ready ? 200 : 503);
68
74
  }
69
75
  catch {
70
76
  return c.json({ status: 'degraded', service: 'hive-skills', reason: 'database_unavailable' }, 503);
@@ -179,6 +185,7 @@ app.post('/auth/logout', (c) => {
179
185
  });
180
186
  app.get('/auth/me', requireAuth(), (c) => c.json(c.get('auth')));
181
187
  app.get('/admin/skills', requireAuth(true), (c) => c.json(store.listAllSkills()));
188
+ app.get('/admin/updates', requireAuth(true), async (c) => c.json(await updateStatus(metadata(releasePackageRoot).release, upgradeHome())));
182
189
  app.post('/admin/skills', requireAuth(true), async (c) => {
183
190
  const body = await requestJson(c);
184
191
  const skill = store.createSkill({
package/dist/cli.js CHANGED
@@ -4,11 +4,15 @@ import { resolve } from 'node:path';
4
4
  import { packageRoot } from './paths.js';
5
5
  const usage = `Hive Skills: HTTPS skill registry and Hermes distribution bridge.
6
6
 
7
- Usage: hive-skills [start|seed|check|env-schema] [--env-file FILE]
7
+ Usage: hive-skills [start|seed|check|env-schema|migrate|upgrade|rollback] [--env-file FILE]
8
8
  hive-skills --help | --version
9
9
 
10
10
  start Start the registry (default); requires TLS key and certificate.
11
11
  check Validate configuration, TLS and existing storage without writing.
12
+ --json prints a JSON report.
13
+ migrate --plan [--json] inspects; otherwise back up and migrate (--backup-dir DIR).
14
+ upgrade Delegate a complete managed bundle upgrade to the Hive launcher.
15
+ rollback Delegate managed code rollback or explicitly confirmed data restore.
12
16
  env-schema Print configuration metadata as JSON without loading configuration.
13
17
  seed Explicitly seed the bundled HiveMem skill; never runs on start.
14
18
  --env-file Load a dotenv file; existing environment values win.
@@ -18,6 +22,27 @@ HIVE_SKILLS_ADMIN_USERS, HIVE_SKILLS_TLS_KEY_FILE and HIVE_SKILLS_TLS_CERT_FILE.
18
22
  See the package README for the complete configuration and migration guide.`;
19
23
  export async function main(args) {
20
24
  try {
25
+ const special = args.find(arg => ['upgrade', 'rollback', 'migrate'].includes(arg));
26
+ if (special) {
27
+ const rest = [...args];
28
+ rest.splice(rest.indexOf(special), 1);
29
+ for (let i = 0; i < rest.length; i++) {
30
+ if (rest[i] === '--env-file' || rest[i]?.startsWith('--env-file=')) {
31
+ const inline = rest[i].startsWith('--env-file=');
32
+ const file = inline ? rest[i].slice(11) : rest[i + 1];
33
+ if (!file)
34
+ throw new Error('--env-file requires a file');
35
+ process.loadEnvFile(resolve(file));
36
+ rest.splice(i, inline ? 1 : 2);
37
+ i--;
38
+ }
39
+ }
40
+ if (special === 'migrate') {
41
+ await (await import('./upgrade.js')).skillsMigration(rest);
42
+ return 0;
43
+ }
44
+ return (await import('./release/runtime.js')).delegate([special, ...rest]);
45
+ }
21
46
  if (args.length === 1 && ['--help', '-h'].includes(args[0])) {
22
47
  console.log(usage);
23
48
  return 0;
@@ -44,6 +69,8 @@ export async function main(args) {
44
69
  }
45
70
  else if (!command && (arg === 'start' || arg === 'seed' || arg === 'check'))
46
71
  command = arg;
72
+ else if (arg === '--json' && command === 'check')
73
+ continue;
47
74
  else
48
75
  throw new Error('Invalid arguments. Run hive-skills --help.');
49
76
  }
@@ -57,10 +84,16 @@ export async function main(args) {
57
84
  if (existsSync('.env'))
58
85
  process.loadEnvFile('.env');
59
86
  }
60
- return checkConfiguration(process.env);
87
+ if (args.includes('--json'))
88
+ return (await import('./release/runtime.js')).captureCheck(() => checkConfiguration(process.env));
89
+ const code = checkConfiguration(process.env);
90
+ await (await import('./release/runtime.js')).printUpdateNotice(JSON.parse(readFileSync(resolve(packageRoot, 'package.json'), 'utf8')).version);
91
+ return code;
61
92
  }
62
- if (command === 'seed')
93
+ if (command === 'seed') {
94
+ await (await import('./upgrade.js')).skillsMigration([]);
63
95
  await import('./seed.js');
96
+ }
64
97
  else
65
98
  await import('./server.js');
66
99
  return 0;
package/dist/db.js CHANGED
@@ -3,59 +3,9 @@ import { existsSync } from 'node:fs';
3
3
  import { DatabaseSync } from 'node:sqlite';
4
4
  import { config } from './config.js';
5
5
  import { compatibleVersion } from './compatibility.js';
6
+ if (!existsSync(config.databasePath))
7
+ throw new Error('Skills database is missing; run hive-skills migrate before opening storage');
6
8
  const db = new DatabaseSync(config.databasePath, { enableForeignKeyConstraints: true });
7
- db.exec(`
8
- PRAGMA journal_mode = WAL;
9
- CREATE TABLE IF NOT EXISTS skills (
10
- slug TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL, homepage TEXT,
11
- enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL
12
- ) STRICT;
13
- CREATE TABLE IF NOT EXISTS releases (
14
- skill_slug TEXT NOT NULL REFERENCES skills(slug) ON DELETE CASCADE, version TEXT NOT NULL,
15
- sha256 TEXT NOT NULL, archive_path TEXT NOT NULL, notes TEXT, created_at TEXT NOT NULL,
16
- min_hermes_version TEXT, max_hermes_version TEXT,
17
- PRIMARY KEY(skill_slug, version)
18
- ) STRICT;
19
- CREATE TABLE IF NOT EXISTS sessions (
20
- id TEXT PRIMARY KEY, username TEXT NOT NULL, expires_at TEXT NOT NULL, created_at TEXT NOT NULL
21
- ) STRICT;
22
- CREATE TABLE IF NOT EXISTS deployment_targets (
23
- name TEXT PRIMARY KEY, host TEXT NOT NULL, username TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1))
24
- ) STRICT;
25
- CREATE TABLE IF NOT EXISTS channel_releases (
26
- channel TEXT NOT NULL CHECK(channel IN ('stable','canary')), skill_slug TEXT NOT NULL REFERENCES skills(slug) ON DELETE CASCADE,
27
- version TEXT NOT NULL, PRIMARY KEY(channel,skill_slug), FOREIGN KEY(skill_slug,version) REFERENCES releases(skill_slug,version)
28
- ) STRICT;
29
- CREATE TABLE IF NOT EXISTS target_policies (
30
- target TEXT PRIMARY KEY REFERENCES deployment_targets(name), channel TEXT NOT NULL CHECK(channel IN ('stable','canary')),
31
- token_hash TEXT NOT NULL, skills_json TEXT, receipt_json TEXT, updated_at TEXT NOT NULL
32
- ) STRICT;
33
- CREATE TABLE IF NOT EXISTS registry_migrations (id TEXT PRIMARY KEY) STRICT;
34
- CREATE TABLE IF NOT EXISTS deployment_jobs (
35
- id TEXT PRIMARY KEY, target TEXT NOT NULL, actor TEXT NOT NULL, status TEXT NOT NULL,
36
- result TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
37
- ) STRICT;
38
- CREATE TABLE IF NOT EXISTS audit_log (
39
- id INTEGER PRIMARY KEY, actor TEXT NOT NULL, action TEXT NOT NULL, subject TEXT NOT NULL, details TEXT NOT NULL, created_at TEXT NOT NULL
40
- ) STRICT;
41
- `);
42
- const releaseColumns = new Set(db.prepare('PRAGMA table_info(releases)').all().map((column) => column.name));
43
- if (!releaseColumns.has('min_hermes_version'))
44
- db.exec('ALTER TABLE releases ADD COLUMN min_hermes_version TEXT');
45
- if (!releaseColumns.has('max_hermes_version'))
46
- db.exec('ALTER TABLE releases ADD COLUMN max_hermes_version TEXT');
47
- // Preserve the currently published version during the first channel migration.
48
- if (!db.prepare("SELECT id FROM registry_migrations WHERE id='release-channels'").get()) {
49
- db.exec("BEGIN IMMEDIATE");
50
- try {
51
- db.exec("INSERT OR IGNORE INTO channel_releases SELECT 'stable', skill_slug, version FROM releases r WHERE created_at=(SELECT MAX(created_at) FROM releases WHERE skill_slug=r.skill_slug)");
52
- db.exec("INSERT INTO registry_migrations VALUES ('release-channels'); COMMIT");
53
- }
54
- catch (error) {
55
- db.exec('ROLLBACK');
56
- throw error;
57
- }
58
- }
59
9
  const tokenHash = (token) => createHash('sha256').update(token).digest('hex');
60
10
  const now = () => new Date().toISOString();
61
11
  const mapSkill = (row) => ({ slug: row.slug, name: row.name, description: row.description, homepage: row.homepage, enabled: row.enabled === 1, createdAt: row.created_at, updatedAt: row.updated_at });
@@ -1,12 +1,14 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { config } from './config.js';
3
3
  import { store } from './db.js';
4
+ import { maintenanceActive } from './release/runtime.js';
4
5
  /** One local deployment at a time. Output may contain credentials: retain only exit codes. */
5
6
  export function startDeploymentWorker() {
6
7
  let busy = false;
8
+ let active = Promise.resolve();
7
9
  store.recoverDeployments();
8
10
  const timer = setInterval(() => {
9
- if (busy)
11
+ if (busy || maintenanceActive('/worker'))
10
12
  return;
11
13
  const job = store.claimDeployment();
12
14
  if (!job)
@@ -26,8 +28,10 @@ export function startDeploymentWorker() {
26
28
  HIVE_SKILLS_TARGET_USER: target.username, HIVE_SKILLS_KEY_ONLY: 'true' },
27
29
  });
28
30
  let finished = false;
31
+ let complete = () => { };
32
+ active = new Promise((done) => { complete = done; });
29
33
  const finish = (ok, code) => { if (finished)
30
- return; finished = true; store.finishDeployment(job.id, ok, code); busy = false; };
34
+ return; finished = true; store.finishDeployment(job.id, ok, code); busy = false; complete(); };
31
35
  child.once('error', () => finish(false, 'launch_failed'));
32
36
  child.once('close', (code) => finish(code === 0, code === 0 ? 'timer_installed' : `exit_${code ?? 'signal'}`));
33
37
  }
@@ -37,5 +41,5 @@ export function startDeploymentWorker() {
37
41
  }
38
42
  }, 1000);
39
43
  timer.unref();
40
- return () => clearInterval(timer);
44
+ return async () => { clearInterval(timer); await active; };
41
45
  }
@@ -1,3 +1,4 @@
1
+ import { commonEnv } from './release/runtime.js';
1
2
  import { readFileSync } from 'node:fs';
2
3
  import { resolve } from 'node:path';
3
4
  import { packageRoot } from './paths.js';
@@ -7,7 +8,7 @@ export function envSchemaDocument() {
7
8
  return {
8
9
  service: 'skills', package: '@hivemagnus/hive-skills', version, defaultPort: 4030,
9
10
  healthPath: '/healthz', consolePath: '/', dependencies: [],
10
- env: [
11
+ env: [...commonEnv(),
11
12
  { name: 'NODE_ENV', default: 'production', description: 'Runtime environment.' },
12
13
  { name: 'HIVE_SKILLS_HOST', default: '127.0.0.1', description: 'Listener bind address.' },
13
14
  { name: 'HIVE_SKILLS_PORT', default: '4030', description: 'HTTPS listener port.' },
@@ -0,0 +1,54 @@
1
+ export function initializeSchema(db) {
2
+ db.exec(`
3
+ PRAGMA journal_mode = WAL;
4
+ CREATE TABLE IF NOT EXISTS skills (
5
+ slug TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL, homepage TEXT,
6
+ enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL
7
+ ) STRICT;
8
+ CREATE TABLE IF NOT EXISTS releases (
9
+ skill_slug TEXT NOT NULL REFERENCES skills(slug) ON DELETE CASCADE, version TEXT NOT NULL,
10
+ sha256 TEXT NOT NULL, archive_path TEXT NOT NULL, notes TEXT, created_at TEXT NOT NULL,
11
+ min_hermes_version TEXT, max_hermes_version TEXT,
12
+ PRIMARY KEY(skill_slug, version)
13
+ ) STRICT;
14
+ CREATE TABLE IF NOT EXISTS sessions (
15
+ id TEXT PRIMARY KEY, username TEXT NOT NULL, expires_at TEXT NOT NULL, created_at TEXT NOT NULL
16
+ ) STRICT;
17
+ CREATE TABLE IF NOT EXISTS deployment_targets (
18
+ name TEXT PRIMARY KEY, host TEXT NOT NULL, username TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1))
19
+ ) STRICT;
20
+ CREATE TABLE IF NOT EXISTS channel_releases (
21
+ channel TEXT NOT NULL CHECK(channel IN ('stable','canary')), skill_slug TEXT NOT NULL REFERENCES skills(slug) ON DELETE CASCADE,
22
+ version TEXT NOT NULL, PRIMARY KEY(channel,skill_slug), FOREIGN KEY(skill_slug,version) REFERENCES releases(skill_slug,version)
23
+ ) STRICT;
24
+ CREATE TABLE IF NOT EXISTS target_policies (
25
+ target TEXT PRIMARY KEY REFERENCES deployment_targets(name), channel TEXT NOT NULL CHECK(channel IN ('stable','canary')),
26
+ token_hash TEXT NOT NULL, skills_json TEXT, receipt_json TEXT, updated_at TEXT NOT NULL
27
+ ) STRICT;
28
+ CREATE TABLE IF NOT EXISTS registry_migrations (id TEXT PRIMARY KEY) STRICT;
29
+ CREATE TABLE IF NOT EXISTS deployment_jobs (
30
+ id TEXT PRIMARY KEY, target TEXT NOT NULL, actor TEXT NOT NULL, status TEXT NOT NULL,
31
+ result TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
32
+ ) STRICT;
33
+ CREATE TABLE IF NOT EXISTS audit_log (
34
+ id INTEGER PRIMARY KEY, actor TEXT NOT NULL, action TEXT NOT NULL, subject TEXT NOT NULL, details TEXT NOT NULL, created_at TEXT NOT NULL
35
+ ) STRICT;
36
+ `);
37
+ const releaseColumns = new Set(db.prepare('PRAGMA table_info(releases)').all().map((column) => column.name));
38
+ if (!releaseColumns.has('min_hermes_version'))
39
+ db.exec('ALTER TABLE releases ADD COLUMN min_hermes_version TEXT');
40
+ if (!releaseColumns.has('max_hermes_version'))
41
+ db.exec('ALTER TABLE releases ADD COLUMN max_hermes_version TEXT');
42
+ // Preserve the currently published version during the first channel migration.
43
+ if (!db.prepare("SELECT id FROM registry_migrations WHERE id='release-channels'").get()) {
44
+ db.exec('BEGIN IMMEDIATE');
45
+ try {
46
+ db.exec("INSERT OR IGNORE INTO channel_releases SELECT 'stable', skill_slug, version FROM releases r WHERE created_at=(SELECT MAX(created_at) FROM releases WHERE skill_slug=r.skill_slug)");
47
+ db.exec("INSERT INTO registry_migrations VALUES ('release-channels'); COMMIT");
48
+ }
49
+ catch (error) {
50
+ db.exec('ROLLBACK');
51
+ throw error;
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Colin McDonnell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.