@hasna/instructions 0.6.1 → 0.7.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/README.md +141 -10
- package/dist/chunks/{apply-ynfn1av7.js → apply-c3sd4agz.js} +5 -3
- package/dist/chunks/{apply-sfzja1f3.js → apply-pnn7a3y7.js} +6 -3
- package/dist/chunks/database-wstmr56z.js +23 -0
- package/dist/chunks/{index-pkscjnj5.js → index-3djthcm6.js} +0 -1
- package/dist/chunks/index-5d7zdftc.js +186 -0
- package/dist/chunks/{index-1hpwskwd.js → index-6jncnnw7.js} +318 -329
- package/dist/chunks/index-8rb7ng2y.js +148 -0
- package/dist/chunks/{index-v7dkb384.js → index-aj66f6xr.js} +243 -100
- package/dist/chunks/index-fpeyzn3n.js +186 -0
- package/dist/chunks/{index-9p9gn7gh.js → index-jg8ptcwh.js} +5 -7
- package/dist/chunks/index-mvckkf15.js +363 -0
- package/dist/chunks/index-n3fjspr0.js +119 -0
- package/dist/chunks/{index-85n5wnd8.js → index-pdpg8a44.js} +5 -7
- package/dist/chunks/{index-e9epbsvs.js → index-r7nfef2g.js} +15 -4
- package/dist/chunks/{index-axhwg61p.js → index-ypcef3a2.js} +15 -4
- package/dist/chunks/legacy-store-migration-z2m5yzyk.js +322 -0
- package/dist/chunks/{local-a9nen65z.js → local-0a9gja58.js} +142 -2
- package/dist/chunks/{local-0d8qrc92.js → local-969ye538.js} +153 -188
- package/dist/chunks/s3-backup-hxe3zpfz.js +240 -0
- package/dist/chunks/s3-config-hhght91k.js +14 -0
- package/dist/chunks/s3-object-store-n0s82swt.js +14 -0
- package/dist/chunks/{sync-vy1yjvs6.js → sync-q5hvwq6e.js} +4 -4
- package/dist/chunks/{sync-m46fc257.js → sync-xtkbm5br.js} +5 -4
- package/dist/chunks/{template-02wjvv05.js → template-fsfxpe4p.js} +1 -1
- package/dist/chunks/{template-ke972qqe.js → template-wgnzdn7h.js} +1 -1
- package/dist/cli/index.js +1236 -256
- package/dist/data/config-store.d.ts +10 -1
- package/dist/data/config-store.d.ts.map +1 -1
- package/dist/db/configs.d.ts +4 -1
- package/dist/db/configs.d.ts.map +1 -1
- package/dist/db/local.d.ts +1 -1
- package/dist/db/local.d.ts.map +1 -1
- package/dist/index.js +1086 -215
- package/dist/lib/apply.d.ts +7 -0
- package/dist/lib/apply.d.ts.map +1 -1
- package/dist/lib/compact-output.d.ts +3 -18
- package/dist/lib/compact-output.d.ts.map +1 -1
- package/dist/lib/export.d.ts +22 -3
- package/dist/lib/export.d.ts.map +1 -1
- package/dist/lib/import.d.ts +18 -0
- package/dist/lib/import.d.ts.map +1 -1
- package/dist/lib/legacy-store-migration.d.ts +39 -0
- package/dist/lib/legacy-store-migration.d.ts.map +1 -0
- package/dist/lib/managed-skill-runtimes.d.ts +1 -0
- package/dist/lib/managed-skill-runtimes.d.ts.map +1 -1
- package/dist/lib/sync-dir.d.ts.map +1 -1
- package/dist/mcp/index.js +41 -30
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +229 -10
- package/dist/sdk/resolve.d.ts +1 -1
- package/dist/sdk/resolve.d.ts.map +1 -1
- package/dist/sdk/v1-client.d.ts +58 -0
- package/dist/sdk/v1-client.d.ts.map +1 -0
- package/dist/sdk/v1.generated.d.ts +242 -21
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.d.ts +13 -5
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1261 -427
- package/dist/server/openapi.d.ts +1379 -93
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/v1.d.ts +25 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage/cloud-store.d.ts +45 -2
- package/dist/storage/cloud-store.d.ts.map +1 -1
- package/dist/storage/index.d.ts +7 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +744 -0
- package/dist/storage/s3-backup.d.ts +76 -0
- package/dist/storage/s3-backup.d.ts.map +1 -0
- package/dist/storage/s3-config.d.ts +46 -0
- package/dist/storage/s3-config.d.ts.map +1 -0
- package/dist/storage/s3-object-store.d.ts +70 -0
- package/dist/storage/s3-object-store.d.ts.map +1 -0
- package/dist/types/index.d.ts +98 -3
- package/dist/types/index.d.ts.map +1 -1
- package/hasna.contract.json +110 -0
- package/migrations/0001_instructions.sql +63 -0
- package/migrations/0002_api_keys.sql +20 -0
- package/migrations/0003_idempotency_receipts.sql +24 -0
- package/package.json +24 -13
- package/assets/skills/inbox/SKILL.md +0 -86
package/README.md
CHANGED
|
@@ -18,9 +18,15 @@ bun add --global @hasna/instructions
|
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
20
|
instructions --help
|
|
21
|
-
|
|
21
|
+
|
|
22
|
+
# Hosted-by-default: resolves the approved key and uses
|
|
23
|
+
# https://api.hasna.com/instructions/v1.
|
|
24
|
+
instructions status
|
|
22
25
|
instructions list
|
|
23
|
-
|
|
26
|
+
|
|
27
|
+
# Explicit on-box SQLite mode; there is never an implicit fallback.
|
|
28
|
+
HASNA_INSTRUCTIONS_LOCAL=1 instructions init
|
|
29
|
+
HASNA_INSTRUCTIONS_LOCAL=1 instructions list
|
|
24
30
|
```
|
|
25
31
|
|
|
26
32
|
## Migrating from `@hasna/configs`
|
|
@@ -29,13 +35,30 @@ This package was formerly `@hasna/configs`. The `configs` CLI alias and
|
|
|
29
35
|
`configs-mcp` alias remain available for existing integrations; new usage can
|
|
30
36
|
use the `instructions` names.
|
|
31
37
|
|
|
38
|
+
Historical on-box rows can be migrated without overwriting the current store:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
export HASNA_INSTRUCTIONS_LOCAL=1
|
|
42
|
+
instructions migrate-legacy --confirm-local --json # no-write plan
|
|
43
|
+
instructions migrate-legacy --confirm-local --apply --json # backup + transaction
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The source defaults to `~/.hasna/configs/configs.db`; the destination defaults
|
|
47
|
+
to the current `instructions.db`. A non-empty destination is refused unless
|
|
48
|
+
`--merge-preserve-destination` is explicit, and that merge never overwrites an
|
|
49
|
+
existing row.
|
|
50
|
+
|
|
32
51
|
## CLI Usage
|
|
33
52
|
|
|
34
53
|
```bash
|
|
35
54
|
instructions --help
|
|
36
|
-
instructions list
|
|
37
|
-
instructions list --verbose
|
|
38
|
-
instructions list --json
|
|
55
|
+
instructions list # compact, paged summary
|
|
56
|
+
instructions list --verbose # expanded metadata, still paged
|
|
57
|
+
instructions list --json # legacy complete full-record array
|
|
58
|
+
instructions list --json --detail compact --limit 20 # bounded identity envelope
|
|
59
|
+
instructions list --json --detail compact --fields id,slug,name,version
|
|
60
|
+
instructions list --json --detail full --limit 20 # explicit bounded content read
|
|
61
|
+
instructions list --json --detail compact --all # explicit complete metadata read
|
|
39
62
|
instructions show <slug> # full metadata + content
|
|
40
63
|
instructions inspect <slug> # alias for show
|
|
41
64
|
instructions profile resolve
|
|
@@ -49,7 +72,9 @@ small. Human output is capped at 20 rows unless you pass `--limit`; use
|
|
|
49
72
|
`--cursor` to continue from the next page. Detail is explicit:
|
|
50
73
|
|
|
51
74
|
- `--verbose` expands list rows with descriptions, tags, and paths.
|
|
52
|
-
- `--json` preserves full
|
|
75
|
+
- Legacy `--json` with no modern detail flags preserves the complete full-record array for automation. Explicit `--limit`/`--cursor` now bound that legacy array.
|
|
76
|
+
- `--detail compact` returns a content-free `{ configs, _meta }` identity envelope through the already-deployed `/v1/configs?view=identity` projection. Add `--fields` (including immutable `id`), `--all`, or `--pretty` as needed.
|
|
77
|
+
- `--detail full` is the explicit content-bearing envelope; it is bounded unless `--all` is supplied.
|
|
53
78
|
- `show`/`inspect` and `snapshot show` print full config or snapshot content.
|
|
54
79
|
|
|
55
80
|
`instructions report --json` emits the stable `schema_version: 1` report
|
|
@@ -109,8 +134,9 @@ instructions-mcp --http # http://127.0.0.1:8807/mcp
|
|
|
109
134
|
MCP_HTTP=1 instructions-mcp
|
|
110
135
|
```
|
|
111
136
|
|
|
112
|
-
Health: `GET http://127.0.0.1:8807/health`.
|
|
113
|
-
`instructions-serve`
|
|
137
|
+
Health: `GET http://127.0.0.1:8807/health`. The production
|
|
138
|
+
`instructions-serve` process deliberately does **not** mount MCP; local MCP and
|
|
139
|
+
the authenticated `/v1` service remain separate authorities.
|
|
114
140
|
|
|
115
141
|
## HTTP API server (`instructions-serve`)
|
|
116
142
|
|
|
@@ -120,14 +146,20 @@ instructions-serve
|
|
|
120
146
|
|
|
121
147
|
Surfaces:
|
|
122
148
|
|
|
123
|
-
- `GET /health`, `GET /ready`, `GET /version` → `{ status, version,
|
|
149
|
+
- `GET /health`, `GET /ready`, `GET /version` → `{ status, version, backend }`
|
|
124
150
|
- `GET /openapi.json`, `GET /v1/openapi.json` → the OpenAPI 3.1 document the SDK
|
|
125
151
|
is generated from.
|
|
126
152
|
- `/v1/*` — versioned cloud API (configs, profiles, snapshots, stats).
|
|
127
153
|
- No `/api/*` — the former local REST surface is not mounted (the removed
|
|
128
154
|
bundled dashboard was its only consumer).
|
|
129
155
|
|
|
130
|
-
### Server data backend (
|
|
156
|
+
### Server data backend (PostgreSQL, fail closed)
|
|
157
|
+
|
|
158
|
+
`instructions-serve` requires `HASNA_INSTRUCTIONS_DATABASE_URL` (or its documented
|
|
159
|
+
alias) for a usable `/v1` service. Without a DSN, `/health` remains a liveness
|
|
160
|
+
probe but reports `backend: "unconfigured"`; `/ready` returns 503 and `/v1`
|
|
161
|
+
returns a stable unavailable error. It never exposes local SQLite over the
|
|
162
|
+
service boundary.
|
|
131
163
|
|
|
132
164
|
When `HASNA_INSTRUCTIONS_DATABASE_URL` is set the `/v1` API reads/writes the
|
|
133
165
|
shared Postgres **directly** (no local sync/cache in the service) and every
|
|
@@ -150,6 +182,16 @@ they resolve `HASNA_INSTRUCTIONS_API_KEY` (or the Keychain / credentials-file
|
|
|
150
182
|
tiers) through the one `@hasna/contracts` client resolver, and the authority
|
|
151
183
|
defaults to the fleet gateway `https://api.hasna.com/instructions`.
|
|
152
184
|
|
|
185
|
+
Collection responses are producer-bounded with `limit`/`cursor` envelopes.
|
|
186
|
+
Current clients follow every page; `view=identity` returns an allowlisted
|
|
187
|
+
metadata-only projection for configs, profiles, and machines, without loading
|
|
188
|
+
instruction content or private profile fields. Retryable create and binding
|
|
189
|
+
writes may send `Idempotency-Key`; PostgreSQL stores the authenticated
|
|
190
|
+
principal, operation, canonical request digest, and first committed response in
|
|
191
|
+
the same transaction as the domain mutation. A same-body retry replays that
|
|
192
|
+
response, while key reuse with different bytes returns
|
|
193
|
+
`409 IDEMPOTENCY_KEY_REUSED`.
|
|
194
|
+
|
|
153
195
|
## SDK
|
|
154
196
|
|
|
155
197
|
The importable module ships INSIDE `@hasna/instructions` at the `./sdk` export
|
|
@@ -215,6 +257,95 @@ transport is decided by what resolves, never by a mode word.
|
|
|
215
257
|
Clients never hold a database DSN. The raw Postgres connection is a server-only
|
|
216
258
|
concern (`instructions-serve`), selected by `HASNA_INSTRUCTIONS_DATABASE_URL`.
|
|
217
259
|
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
## Domain export and import
|
|
263
|
+
|
|
264
|
+
`instructions export` writes a restorable Instructions domain archive v2. The
|
|
265
|
+
archive contains config content and every retained config snapshot, profiles,
|
|
266
|
+
ordered profile membership and config-binding metadata, profile asset bindings,
|
|
267
|
+
and registered machines. Relationships are recorded by stable config/profile
|
|
268
|
+
slugs and machine hostnames, so import can map them safely onto destination IDs.
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
instructions export --output ./instructions-domain.tar.gz
|
|
272
|
+
instructions import ./instructions-domain.tar.gz # exact recovery into an empty destination
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
V2 is deliberately a recovery format, not a merge format. Import validates the
|
|
276
|
+
complete archive and requires a destination with zero configs, profiles, and
|
|
277
|
+
machines before the first mutation. `--overwrite` is rejected for v2 before
|
|
278
|
+
destination inspection or mutation. Any validation, mutation, or readback
|
|
279
|
+
failure throws and makes the CLI exit nonzero; discard that attempted
|
|
280
|
+
destination and retry from a newly empty database.
|
|
281
|
+
|
|
282
|
+
The manifest contains deterministic per-collection counts and SHA-256 logical
|
|
283
|
+
hashes for pre/post deployment comparison without placing instruction content
|
|
284
|
+
in logs. Exact deployment integrity includes config `created_at`, `updated_at`,
|
|
285
|
+
and `synced_at`; snapshot `created_at`; profile `created_at` and `updated_at`;
|
|
286
|
+
and machine `created_at` plus the exact `last_applied_at` value. After recovery,
|
|
287
|
+
import reads the complete domain back through `ConfigStore` and verifies every
|
|
288
|
+
field that interface can reproduce: config data and versions, retained snapshot
|
|
289
|
+
contents, profiles, ordered bindings, asset mappings, machines, and whether a
|
|
290
|
+
machine was ever applied.
|
|
291
|
+
|
|
292
|
+
`ConfigStore` cannot assign archived config/profile/snapshot/machine creation or
|
|
293
|
+
update timestamps, nor an exact machine `last_applied_at`; generated destination
|
|
294
|
+
IDs are also intentionally remapped by stable slugs and hostnames. These values
|
|
295
|
+
remain protected in the archive's exact integrity hashes but are not recreated
|
|
296
|
+
by recovery. ConfigStore also has no cross-entity transaction, so an operational
|
|
297
|
+
failure may leave a partial destination; the nonzero result is terminal and that
|
|
298
|
+
destination must not be reused. Legacy v1 config-only archives remain importable,
|
|
299
|
+
including their historical skip/overwrite conflict behavior.
|
|
300
|
+
|
|
301
|
+
API keys and idempotency receipts are intentionally excluded: they are security
|
|
302
|
+
and transport state that must be provisioned independently. Feedback is also
|
|
303
|
+
excluded because it is product telemetry, not part of the Instructions
|
|
304
|
+
configuration domain.
|
|
305
|
+
|
|
306
|
+
## Native S3 backup storage
|
|
307
|
+
|
|
308
|
+
S3 is an **adjunct immutable backup plane**, never a database selector. SQLite
|
|
309
|
+
remains the explicit local authority and PostgreSQL remains the hosted `/v1`
|
|
310
|
+
authority. Setting S3 variables alone never opens SQLite, never selects HTTP,
|
|
311
|
+
and never changes CRUD routing.
|
|
312
|
+
|
|
313
|
+
```bash
|
|
314
|
+
export HASNA_INSTRUCTIONS_S3_BUCKET=your-private-bucket
|
|
315
|
+
export HASNA_INSTRUCTIONS_AWS_REGION=us-east-1
|
|
316
|
+
# Optional: HASNA_INSTRUCTIONS_S3_PREFIX (default: instructions/)
|
|
317
|
+
# Optional local/S3-compatible endpoint and explicit static credentials.
|
|
318
|
+
|
|
319
|
+
instructions storage status --json
|
|
320
|
+
instructions export --output ./instructions-backup.tar.gz
|
|
321
|
+
instructions storage backup push ./instructions-backup.tar.gz --id 2026-09-15-pre-deploy --dry-run --json
|
|
322
|
+
instructions storage backup push ./instructions-backup.tar.gz --id 2026-09-15-pre-deploy --json
|
|
323
|
+
instructions storage backup verify 2026-09-15-pre-deploy --json
|
|
324
|
+
instructions storage backup pull 2026-09-15-pre-deploy --output ./restored-instructions.tar.gz --json
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Each backup uses traversal-safe deterministic keys, an atomically created
|
|
328
|
+
payload, and an atomically created manifest containing SHA-256, byte size,
|
|
329
|
+
content type, and creation time. Native S3 creation uses `If-None-Match: *`;
|
|
330
|
+
concurrent different-byte writers cannot replace the winner, identical replay
|
|
331
|
+
is idempotent, and an injected store without conditional-create support fails
|
|
332
|
+
closed. Pulls verify the payload before an owner-only local file is written.
|
|
333
|
+
Production backup buckets must enable versioning, encryption, public-access
|
|
334
|
+
blocking, and S3 Object Lock with a default retention period. AWS runtime
|
|
335
|
+
credentials may come from Bun's standard AWS chain (including an ECS task role);
|
|
336
|
+
explicit static credentials are optional and must be a complete pair. Status
|
|
337
|
+
never prints credential values or the bucket name.
|
|
338
|
+
|
|
339
|
+
The production deployment runs the immutable predeploy backup push and verify
|
|
340
|
+
as direct Bun commands in the exact candidate image. The GitHub runner alone
|
|
341
|
+
parses the push receipt, then supplies the recorded payload and manifest version
|
|
342
|
+
IDs to verification; it also checks the receipt digest and size, COMPLIANCE
|
|
343
|
+
Object Lock retention for both versions, and an exact versioned-payload
|
|
344
|
+
readback. Every backup gate completes before any ECR push, database migration,
|
|
345
|
+
or ECS update.
|
|
346
|
+
|
|
347
|
+
The public importable surface is available at `@hasna/instructions/storage`.
|
|
348
|
+
|
|
218
349
|
## Data Directory
|
|
219
350
|
|
|
220
351
|
Local data (the SQLite store and backups) lives under the configs store home,
|
|
@@ -3,20 +3,22 @@ import {
|
|
|
3
3
|
applyConfig,
|
|
4
4
|
applyConfigs,
|
|
5
5
|
applyConfigsWithReport,
|
|
6
|
+
compactPathForConfigHome,
|
|
6
7
|
expandPath,
|
|
7
8
|
getConfigHome,
|
|
8
9
|
normalizeTargetPath,
|
|
9
10
|
previewConfigs
|
|
10
|
-
} from "./index-
|
|
11
|
-
import"./index-
|
|
11
|
+
} from "./index-ypcef3a2.js";
|
|
12
|
+
import"./index-aj66f6xr.js";
|
|
12
13
|
import"./index-35z65jsa.js";
|
|
13
|
-
import"./index-
|
|
14
|
+
import"./index-3djthcm6.js";
|
|
14
15
|
import"./index-nnwp92tk.js";
|
|
15
16
|
export {
|
|
16
17
|
previewConfigs,
|
|
17
18
|
normalizeTargetPath,
|
|
18
19
|
getConfigHome,
|
|
19
20
|
expandPath,
|
|
21
|
+
compactPathForConfigHome,
|
|
20
22
|
applyConfigsWithReport,
|
|
21
23
|
applyConfigs,
|
|
22
24
|
applyConfig
|
|
@@ -3,19 +3,22 @@ import {
|
|
|
3
3
|
applyConfig,
|
|
4
4
|
applyConfigs,
|
|
5
5
|
applyConfigsWithReport,
|
|
6
|
+
compactPathForConfigHome,
|
|
6
7
|
expandPath,
|
|
7
8
|
getConfigHome,
|
|
8
9
|
normalizeTargetPath,
|
|
9
10
|
previewConfigs
|
|
10
|
-
} from "./index-
|
|
11
|
-
import"./index-
|
|
12
|
-
import"./index-
|
|
11
|
+
} from "./index-r7nfef2g.js";
|
|
12
|
+
import"./index-6jncnnw7.js";
|
|
13
|
+
import"./index-n3fjspr0.js";
|
|
14
|
+
import"./index-fpeyzn3n.js";
|
|
13
15
|
import"./index-bsan4c16.js";
|
|
14
16
|
export {
|
|
15
17
|
previewConfigs,
|
|
16
18
|
normalizeTargetPath,
|
|
17
19
|
getConfigHome,
|
|
18
20
|
expandPath,
|
|
21
|
+
compactPathForConfigHome,
|
|
19
22
|
applyConfigsWithReport,
|
|
20
23
|
applyConfigs,
|
|
21
24
|
applyConfig
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import {
|
|
3
|
+
getDatabase,
|
|
4
|
+
insertFeedback,
|
|
5
|
+
resetDatabase,
|
|
6
|
+
resetLocalDatabase
|
|
7
|
+
} from "./index-5d7zdftc.js";
|
|
8
|
+
import {
|
|
9
|
+
now,
|
|
10
|
+
slugify,
|
|
11
|
+
uuid
|
|
12
|
+
} from "./index-ma2xmp6c.js";
|
|
13
|
+
import"./index-fpeyzn3n.js";
|
|
14
|
+
import"./index-bsan4c16.js";
|
|
15
|
+
export {
|
|
16
|
+
uuid,
|
|
17
|
+
slugify,
|
|
18
|
+
resetLocalDatabase,
|
|
19
|
+
resetDatabase,
|
|
20
|
+
now,
|
|
21
|
+
insertFeedback,
|
|
22
|
+
getDatabase
|
|
23
|
+
};
|
|
@@ -28,7 +28,6 @@ var ASSET_DESTINATION_STRATEGIES = ["emit-file", "install-local", "install-marke
|
|
|
28
28
|
var ASSET_SCOPES = ["global", "project", "session"];
|
|
29
29
|
var ASSET_UNINSTALL_POLICIES = ["remove-managed", "retain"];
|
|
30
30
|
var ASSET_ROLLBACK_POLICIES = ["snapshot", "installer-receipt", "none"];
|
|
31
|
-
|
|
32
31
|
class ConfigNotFoundError extends Error {
|
|
33
32
|
constructor(id) {
|
|
34
33
|
super(`Config not found: ${id}`);
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import {
|
|
3
|
+
getRawStoreRoot,
|
|
4
|
+
hasInstructionsEnvAuthorityIntent
|
|
5
|
+
} from "./index-fpeyzn3n.js";
|
|
6
|
+
|
|
7
|
+
// src/db/database.ts
|
|
8
|
+
import { Database } from "bun:sqlite";
|
|
9
|
+
import { existsSync, mkdirSync, rmSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
function getDbPath() {
|
|
12
|
+
if (process.env["HASNA_INSTRUCTIONS_DB_PATH"]) {
|
|
13
|
+
return process.env["HASNA_INSTRUCTIONS_DB_PATH"];
|
|
14
|
+
}
|
|
15
|
+
const dir = getRawStoreRoot();
|
|
16
|
+
mkdirSync(dir, { recursive: true });
|
|
17
|
+
return join(dir, "instructions.db");
|
|
18
|
+
}
|
|
19
|
+
var MIGRATIONS = [
|
|
20
|
+
`
|
|
21
|
+
CREATE TABLE IF NOT EXISTS configs (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
name TEXT NOT NULL,
|
|
24
|
+
slug TEXT NOT NULL UNIQUE,
|
|
25
|
+
kind TEXT NOT NULL DEFAULT 'file',
|
|
26
|
+
category TEXT NOT NULL,
|
|
27
|
+
agent TEXT NOT NULL DEFAULT 'global',
|
|
28
|
+
target_path TEXT,
|
|
29
|
+
format TEXT NOT NULL DEFAULT 'text',
|
|
30
|
+
content TEXT NOT NULL DEFAULT '',
|
|
31
|
+
description TEXT,
|
|
32
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
33
|
+
is_template INTEGER NOT NULL DEFAULT 0,
|
|
34
|
+
version INTEGER NOT NULL DEFAULT 1,
|
|
35
|
+
created_at TEXT NOT NULL,
|
|
36
|
+
updated_at TEXT NOT NULL,
|
|
37
|
+
synced_at TEXT
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS config_snapshots (
|
|
41
|
+
id TEXT PRIMARY KEY,
|
|
42
|
+
config_id TEXT NOT NULL REFERENCES configs(id) ON DELETE CASCADE,
|
|
43
|
+
content TEXT NOT NULL,
|
|
44
|
+
version INTEGER NOT NULL,
|
|
45
|
+
created_at TEXT NOT NULL
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE TABLE IF NOT EXISTS profiles (
|
|
49
|
+
id TEXT PRIMARY KEY,
|
|
50
|
+
name TEXT NOT NULL,
|
|
51
|
+
slug TEXT NOT NULL UNIQUE,
|
|
52
|
+
description TEXT,
|
|
53
|
+
created_at TEXT NOT NULL,
|
|
54
|
+
updated_at TEXT NOT NULL
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE IF NOT EXISTS profile_configs (
|
|
58
|
+
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
59
|
+
config_id TEXT NOT NULL REFERENCES configs(id) ON DELETE CASCADE,
|
|
60
|
+
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
61
|
+
PRIMARY KEY (profile_id, config_id)
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
CREATE TABLE IF NOT EXISTS machines (
|
|
65
|
+
id TEXT PRIMARY KEY,
|
|
66
|
+
hostname TEXT NOT NULL UNIQUE,
|
|
67
|
+
os TEXT,
|
|
68
|
+
last_applied_at TEXT,
|
|
69
|
+
created_at TEXT NOT NULL
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
73
|
+
version INTEGER PRIMARY KEY
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
INSERT OR IGNORE INTO schema_version (version) VALUES (1);
|
|
77
|
+
`,
|
|
78
|
+
`
|
|
79
|
+
ALTER TABLE profiles ADD COLUMN selectors TEXT NOT NULL DEFAULT '{}';
|
|
80
|
+
ALTER TABLE profiles ADD COLUMN variables TEXT NOT NULL DEFAULT '{}';
|
|
81
|
+
ALTER TABLE machines ADD COLUMN arch TEXT;
|
|
82
|
+
`,
|
|
83
|
+
`
|
|
84
|
+
ALTER TABLE configs ADD COLUMN outputs TEXT NOT NULL DEFAULT '[]';
|
|
85
|
+
`,
|
|
86
|
+
`
|
|
87
|
+
ALTER TABLE profile_configs ADD COLUMN binding TEXT NOT NULL DEFAULT '{"schema":"hasna.instructions.profile-config-binding/v1","activation":{"mode":"always"},"required":true,"fallback":"fail"}';
|
|
88
|
+
`,
|
|
89
|
+
`
|
|
90
|
+
CREATE TABLE IF NOT EXISTS profile_assets (
|
|
91
|
+
profile_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
92
|
+
source_config_id TEXT NOT NULL REFERENCES configs(id) ON DELETE CASCADE,
|
|
93
|
+
asset_key TEXT NOT NULL,
|
|
94
|
+
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
95
|
+
binding TEXT NOT NULL,
|
|
96
|
+
PRIMARY KEY (profile_id, asset_key)
|
|
97
|
+
);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS profile_assets_source_config_idx ON profile_assets (source_config_id);
|
|
99
|
+
`
|
|
100
|
+
];
|
|
101
|
+
var _db = null;
|
|
102
|
+
function getDatabase(path) {
|
|
103
|
+
if (_db)
|
|
104
|
+
return _db;
|
|
105
|
+
if (!path && hasInstructionsEnvAuthorityIntent(process.env)) {
|
|
106
|
+
throw new Error("instructions: refusing to open the on-box SQLite store \u2014 the environment configures a hosted " + "Instructions authority or credential (HASNA_INSTRUCTIONS_*), and reading or writing the local store " + "here would silently drift from the shared dataset. Pass an explicit database path (or an injected " + "Database) to work against the on-box store deliberately.");
|
|
107
|
+
}
|
|
108
|
+
const dbPath = path || getDbPath();
|
|
109
|
+
const db = new Database(dbPath);
|
|
110
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
111
|
+
db.run("PRAGMA foreign_keys = ON");
|
|
112
|
+
applyMigrations(db);
|
|
113
|
+
ensureFeedbackTable(db);
|
|
114
|
+
_db = db;
|
|
115
|
+
return db;
|
|
116
|
+
}
|
|
117
|
+
function resetDatabase() {
|
|
118
|
+
if (_db) {
|
|
119
|
+
try {
|
|
120
|
+
_db.close();
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
|
123
|
+
_db = null;
|
|
124
|
+
}
|
|
125
|
+
function resetLocalDatabase() {
|
|
126
|
+
resetDatabase();
|
|
127
|
+
const dbPath = getDbPath();
|
|
128
|
+
if (dbPath === ":memory:")
|
|
129
|
+
return;
|
|
130
|
+
for (const p of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
131
|
+
if (existsSync(p))
|
|
132
|
+
rmSync(p);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function applyMigrations(db) {
|
|
136
|
+
let currentVersion = 0;
|
|
137
|
+
try {
|
|
138
|
+
const row = db.query("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1").get();
|
|
139
|
+
currentVersion = row?.version ?? 0;
|
|
140
|
+
} catch {
|
|
141
|
+
currentVersion = 0;
|
|
142
|
+
}
|
|
143
|
+
const applyOne = db.transaction((index) => {
|
|
144
|
+
if (!migrationEffectAlreadyPresent(db, index))
|
|
145
|
+
db.exec(MIGRATIONS[index]);
|
|
146
|
+
db.run(`INSERT OR REPLACE INTO schema_version (version) VALUES (${index + 1})`);
|
|
147
|
+
});
|
|
148
|
+
for (let i = currentVersion;i < MIGRATIONS.length; i++)
|
|
149
|
+
applyOne(i);
|
|
150
|
+
}
|
|
151
|
+
function migrationEffectAlreadyPresent(db, index) {
|
|
152
|
+
if (index !== 3)
|
|
153
|
+
return false;
|
|
154
|
+
return db.query("PRAGMA table_info(profile_configs)").all().some((column) => column.name === "binding");
|
|
155
|
+
}
|
|
156
|
+
function ensureFeedbackTable(db) {
|
|
157
|
+
db.exec(`
|
|
158
|
+
CREATE TABLE IF NOT EXISTS feedback (
|
|
159
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
160
|
+
message TEXT NOT NULL,
|
|
161
|
+
email TEXT,
|
|
162
|
+
category TEXT DEFAULT 'general',
|
|
163
|
+
version TEXT,
|
|
164
|
+
machine_id TEXT,
|
|
165
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
166
|
+
)
|
|
167
|
+
`);
|
|
168
|
+
const existing = new Set(db.query("PRAGMA table_info(feedback)").all().map((r) => r.name));
|
|
169
|
+
const required = [
|
|
170
|
+
["email", "TEXT"],
|
|
171
|
+
["category", "TEXT DEFAULT 'general'"],
|
|
172
|
+
["version", "TEXT"],
|
|
173
|
+
["machine_id", "TEXT"],
|
|
174
|
+
["created_at", "TEXT"]
|
|
175
|
+
];
|
|
176
|
+
for (const [name, def] of required) {
|
|
177
|
+
if (!existing.has(name))
|
|
178
|
+
db.exec(`ALTER TABLE feedback ADD COLUMN ${name} ${def}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function insertFeedback(input, db) {
|
|
182
|
+
const d = db || getDatabase();
|
|
183
|
+
d.run("INSERT INTO feedback (message, email, category, version) VALUES (?, ?, ?, ?)", [input.message, input.email ?? null, input.category ?? "general", input.version ?? null]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export { getDatabase, resetDatabase, resetLocalDatabase, insertFeedback };
|