@astrale-os/cli 1.0.0-beta.3 → 1.0.0-beta.30
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 +80 -6
- package/dist/astrale.js +55471 -58674
- package/dist/public/connect-core.js +2158 -30706
- package/dist/public/keys/index.js +1016 -29541
- package/dist/public/paths/index.js +370 -28654
- package/dist/types/admin/contract.d.ts +26 -0
- package/dist/types/admin/instance/client.d.ts +20 -0
- package/dist/types/admin/instance/index.d.ts +2 -0
- package/dist/types/admin/instance/model.d.ts +6 -10
- package/dist/types/connection/auth.d.ts +3 -0
- package/dist/types/connection/call.d.ts +3 -0
- package/dist/types/connection/command.d.ts +27 -0
- package/dist/types/connection/credential.d.ts +14 -0
- package/dist/types/connection/errors.d.ts +1 -0
- package/dist/types/connection/exchange.d.ts +9 -0
- package/dist/types/connection/failure/classify.d.ts +2 -0
- package/dist/types/connection/failure/index.d.ts +5 -0
- package/dist/types/connection/failure/model.d.ts +25 -0
- package/dist/types/connection/failure/render.d.ts +3 -0
- package/dist/types/connection/index.d.ts +8 -0
- package/dist/types/connection/reasons.d.ts +37 -0
- package/dist/types/connection/self.d.ts +37 -0
- package/dist/types/connection/session.d.ts +31 -0
- package/dist/types/connection/target.d.ts +38 -0
- package/dist/types/errors.d.ts +1 -1
- package/dist/types/lib/admin-instance.d.ts +17 -0
- package/dist/types/lib/admin-target.d.ts +11 -36
- package/dist/types/lib/config.d.ts +13 -59
- package/dist/types/lib/failure-debug.d.ts +1 -0
- package/dist/types/lib/idp.d.ts +25 -173
- package/dist/types/lib/instance-target.d.ts +1 -1
- package/dist/types/lib/instance.d.ts +30 -88
- package/dist/types/lib/log.d.ts +5 -3
- package/dist/types/lib/output.d.ts +1 -1
- package/dist/types/lib/proc.d.ts +43 -0
- package/dist/types/lib/self.d.ts +9 -0
- package/dist/types/lib/update.d.ts +109 -0
- package/dist/types/lib/validation.d.ts +4 -1
- package/dist/types/state/exchange-credentials.d.ts +6 -2
- package/dist/types/state/files.d.ts +2 -0
- package/dist/types/state/index.d.ts +3 -2
- package/dist/types/state/paths.d.ts +2 -0
- package/dist/types/state/session-routes.d.ts +10 -0
- package/package.json +16 -19
- package/src/__tests__/fixtures/publication.ts +20 -0
- package/src/admin/.spec/architecture.md +12 -5
- package/src/admin/__tests__/fixture.ts +25 -0
- package/src/admin/catalog/.spec/api.d.ts +1 -3
- package/src/admin/catalog/.spec/architecture.md +5 -4
- package/src/admin/catalog/__tests__/client.test.ts +143 -69
- package/src/admin/catalog/client.ts +40 -54
- package/src/admin/catalog/model.ts +3 -4
- package/src/admin/contract.ts +47 -0
- package/src/admin/graph/.spec/api.d.ts +4 -10
- package/src/admin/graph/nodes.ts +1 -1
- package/src/admin/instance/.spec/api.d.ts +5 -9
- package/src/admin/instance/.spec/architecture.md +7 -7
- package/src/admin/instance/__tests__/client.test.ts +157 -175
- package/src/admin/instance/client.ts +39 -128
- package/src/admin/instance/index.ts +1 -2
- package/src/admin/instance/model.ts +12 -18
- package/src/commands/__tests__/admin-instance.test.ts +11 -8
- package/src/commands/__tests__/call-describe.test.ts +46 -33
- package/src/commands/__tests__/call.test.ts +34 -0
- package/src/commands/__tests__/domain-install-operation.test.ts +30 -22
- package/src/commands/__tests__/domain-install-owned.test.ts +1 -1
- package/src/commands/__tests__/domain-list.test.ts +5 -25
- package/src/commands/__tests__/install-direct.test.ts +115 -8
- package/src/commands/__tests__/install-identity-override.test.ts +6 -17
- package/src/commands/__tests__/instance-list-rows.test.ts +1 -1
- package/src/commands/__tests__/instance-status.test.ts +139 -0
- package/src/commands/__tests__/introspect-parse.test.ts +80 -1
- package/src/commands/__tests__/logs.test.ts +250 -2
- package/src/commands/__tests__/read-commands.test.ts +118 -10
- package/src/commands/__tests__/token-ttl.test.ts +49 -4
- package/src/commands/__tests__/update.test.ts +25 -3
- package/src/commands/__tests__/view.test.ts +50 -2
- package/src/commands/auth/logout.ts +2 -1
- package/src/commands/browser.ts +29 -0
- package/src/commands/call-describe.ts +60 -68
- package/src/commands/call.ts +71 -59
- package/src/commands/domain/install.ts +73 -46
- package/src/commands/domain/list.ts +1 -1
- package/src/commands/domain/publish.ts +14 -11
- package/src/commands/domain/uninstall.ts +2 -2
- package/src/commands/get.ts +15 -16
- package/src/commands/identity/__tests__/register.test.ts +3 -3
- package/src/commands/identity/register.ts +8 -8
- package/src/commands/instance/create.ts +8 -16
- package/src/commands/instance/delete.ts +12 -9
- package/src/commands/instance/list.ts +2 -2
- package/src/commands/instance/status.ts +66 -20
- package/src/commands/instance/use.ts +16 -12
- package/src/commands/introspect.ts +53 -41
- package/src/commands/logs.ts +123 -36
- package/src/commands/mutate.ts +6 -5
- package/src/commands/query.ts +35 -32
- package/src/commands/session/analyze.ts +26 -4
- package/src/commands/token.ts +42 -27
- package/src/commands/ui/__tests__/commands.test.ts +152 -0
- package/src/commands/ui/add.ts +51 -0
- package/src/commands/ui/doctor.ts +21 -0
- package/src/commands/ui/init.ts +38 -0
- package/src/commands/ui/list.ts +26 -0
- package/src/commands/ui/preset-apply.ts +19 -0
- package/src/commands/ui/preset-list.ts +12 -0
- package/src/commands/ui/shared.ts +25 -0
- package/src/commands/update.ts +89 -48
- package/src/commands/view.ts +82 -37
- package/src/connection/.spec/api.d.ts +7 -8
- package/src/connection/.spec/architecture.md +14 -5
- package/src/connection/.spec/flows/session.ts +2 -2
- package/src/connection/.spec/laws/connection.ts +6 -2
- package/src/connection/.spec/layout.ts +1 -0
- package/src/connection/__tests__/auth.test.ts +1 -0
- package/src/connection/__tests__/credential.test.ts +21 -1
- package/src/connection/__tests__/errors.test.ts +27 -20
- package/src/connection/__tests__/exchange.test.ts +64 -11
- package/src/connection/__tests__/failure-fixtures.ts +39 -0
- package/src/connection/__tests__/failure-safety.test.ts +75 -0
- package/src/connection/__tests__/self.test.ts +31 -11
- package/src/connection/__tests__/session.test.ts +8 -4
- package/src/connection/__tests__/target.test.ts +2 -0
- package/src/connection/auth.ts +13 -0
- package/src/connection/call.ts +1 -1
- package/src/connection/credential.ts +2 -2
- package/src/connection/errors.ts +1 -355
- package/src/connection/exchange.ts +110 -61
- package/src/connection/failure/classify.ts +110 -0
- package/src/connection/failure/index.ts +18 -0
- package/src/connection/failure/model.ts +28 -0
- package/src/connection/failure/render.ts +116 -0
- package/src/connection/reasons.ts +16 -0
- package/src/connection/self.ts +39 -21
- package/src/connection/session.ts +27 -9
- package/src/errors.ts +3 -2
- package/src/graph/.spec/api.d.ts +12 -5
- package/src/graph/.spec/layout.ts +9 -1
- package/src/graph/__tests__/mutation.test.ts +2 -2
- package/src/graph/__tests__/query.test.ts +25 -6
- package/src/graph/class.ts +30 -0
- package/src/graph/index.ts +1 -0
- package/src/graph/query.ts +21 -32
- package/src/identity/.spec/api.d.ts +2 -2
- package/src/identity/__tests__/fixtures/registry-journey.ts +13 -1
- package/src/identity/__tests__/registration.test.ts +1 -1
- package/src/identity/__tests__/registry.test.ts +4 -0
- package/src/identity/registration.ts +3 -3
- package/src/identity/registry.ts +2 -0
- package/src/keys/__tests__/keys.test.ts +4 -0
- package/src/keys/credential.ts +1 -0
- package/src/lib/__tests__/admin-target.test.ts +12 -0
- package/src/lib/__tests__/binary.test.ts +16 -1
- package/src/lib/__tests__/browser-retention.test.ts +212 -0
- package/src/lib/__tests__/command-dx.test.ts +1 -1
- package/src/lib/__tests__/config.test.ts +27 -0
- package/src/lib/__tests__/domain-publication.test.ts +10 -16
- package/src/lib/__tests__/idp.test.ts +34 -4
- package/src/lib/__tests__/instance-candidates.test.ts +1 -3
- package/src/lib/__tests__/instance-target.test.ts +7 -39
- package/src/lib/__tests__/log-errors.test.ts +64 -0
- package/src/lib/__tests__/skills.test.ts +809 -0
- package/src/lib/__tests__/studio-server-deps.test.ts +1 -1
- package/src/lib/__tests__/update.test.ts +264 -11
- package/src/lib/__tests__/view-assets.test.ts +25 -0
- package/src/lib/__tests__/view-external-open-intent.test.ts +150 -0
- package/src/lib/__tests__/view-external-open-origins.test.ts +27 -0
- package/src/lib/__tests__/view-open-intent.test.ts +0 -2
- package/src/lib/__tests__/view-server.test.ts +26 -3
- package/src/lib/__tests__/view-session.test.ts +2 -1
- package/src/lib/admin-instance.ts +14 -8
- package/src/lib/admin-target.ts +21 -2
- package/src/lib/binary.ts +22 -5
- package/src/lib/browser-retention.ts +210 -0
- package/src/lib/command-dx.ts +9 -9
- package/src/lib/config.ts +12 -1
- package/src/lib/domain-publication.ts +9 -7
- package/src/lib/failure-debug.ts +26 -0
- package/src/lib/idp.ts +34 -3
- package/src/lib/instance-candidates.ts +2 -2
- package/src/lib/instance-target.ts +3 -14
- package/src/lib/instance.ts +16 -11
- package/src/lib/log.ts +16 -9
- package/src/lib/output.ts +1 -1
- package/src/lib/proc.ts +7 -2
- package/src/lib/provision-instance.ts +34 -57
- package/src/lib/skills/lock.ts +117 -0
- package/src/lib/skills/sync.ts +814 -0
- package/src/lib/skills.ts +1 -52
- package/src/lib/update.ts +298 -45
- package/src/lib/validation.ts +2 -2
- package/src/lib/view/assets.ts +11 -2
- package/src/lib/view/external-open-intent.ts +41 -0
- package/src/lib/view/external-open-origins.ts +37 -0
- package/src/lib/view/host-capabilities.ts +17 -0
- package/src/lib/view/resolve.ts +1 -1
- package/src/lib/view/server.ts +18 -3
- package/src/lib/view/session.ts +2 -0
- package/src/program/.spec/api.d.ts +1 -0
- package/src/program/__tests__/program.test.ts +163 -6
- package/src/program/argv.ts +14 -0
- package/src/program/build.ts +26 -4
- package/src/program/command.ts +1 -0
- package/src/program/index.ts +1 -0
- package/src/program/registry.ts +2 -1
- package/src/setup/__tests__/instance-step.test.ts +27 -10
- package/src/setup/steps/instance.ts +22 -15
- package/src/setup/steps/skills.ts +40 -38
- package/src/state/.spec/api.d.ts +21 -2
- package/src/state/.spec/architecture.md +18 -4
- package/src/state/.spec/layout.ts +1 -0
- package/src/state/__tests__/exchange-credentials.test.ts +88 -42
- package/src/state/__tests__/files.test.ts +24 -1
- package/src/state/__tests__/fixtures/session-route-process.ts +93 -0
- package/src/state/__tests__/paths.test.ts +1 -0
- package/src/state/__tests__/session-routes.test.ts +138 -0
- package/src/state/exchange-credentials.ts +22 -9
- package/src/state/files.ts +41 -0
- package/src/state/index.ts +3 -1
- package/src/state/paths.ts +3 -0
- package/src/state/session-routes.ts +34 -0
- package/src/telemetry/__tests__/analyze-log.test.ts +38 -0
- package/src/telemetry/__tests__/recorder.test.ts +1 -0
- package/src/telemetry/__tests__/retention.test.ts +267 -0
- package/src/telemetry/__tests__/settings.test.ts +102 -0
- package/src/telemetry/__tests__/store-scan.test.ts +128 -0
- package/src/telemetry/__tests__/trigger.test.ts +33 -4
- package/src/telemetry/analyze.ts +24 -2
- package/src/telemetry/recorder.ts +15 -4
- package/src/telemetry/retention.ts +176 -0
- package/src/telemetry/session.ts +18 -12
- package/src/telemetry/settings.ts +64 -11
- package/src/telemetry/store.ts +92 -9
- package/src/telemetry/trigger.ts +16 -28
- package/src/ui/.spec/api.d.ts +14 -0
- package/src/ui/.spec/architecture.md +14 -0
- package/src/ui/.spec/laws.ts +50 -0
- package/src/ui/.spec/layout.ts +16 -0
- package/src/ui/__tests__/ui.test.ts +1742 -0
- package/src/ui/index.ts +13 -0
- package/src/ui/lock.ts +89 -0
- package/src/ui/model.ts +83 -0
- package/src/ui/operations.ts +1044 -0
- package/src/ui/project.ts +333 -0
- package/src/ui/release.ts +398 -0
- package/src/ui/runner.ts +18 -0
- package/studio/client/dist/assets/elk-api-DjmKsHXc.js +1 -0
- package/studio/client/dist/assets/index-BFVFs_8x.js +81 -0
- package/studio/client/dist/assets/index-DuB9iUdu.css +2 -0
- package/studio/client/dist/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
- package/studio/client/dist/assets/schema-studio--a0kX3QU.css +1 -0
- package/studio/client/dist/assets/schema-studio-DH6oEWME.js +8 -0
- package/studio/client/dist/index.html +4 -3
- package/studio/package.json +8 -14
- package/studio/server/agent/ask.test.ts +1 -1
- package/studio/server/agent/bridge/grant.test.ts +1 -1
- package/studio/server/agent/bridge/routes.test.ts +1 -1
- package/studio/server/agent/harness/claude/adapter.test.ts +15 -5
- package/studio/server/agent/harness/gateway/token.test.ts +1 -1
- package/studio/server/agent/harness/gateway/token.ts +3 -3
- package/studio/server/agent/prompts/anchors.test.ts +41 -32
- package/studio/server/agent/prompts/anchors.ts +23 -51
- package/studio/server/agent/prompts/system.test.ts +4 -4
- package/studio/server/agent/prompts/system.ts +5 -5
- package/studio/server/agent/routes.test.ts +1 -1
- package/studio/server/agent/run/coordinator.test.ts +1 -1
- package/studio/server/agent/run/preparation.ts +1 -1
- package/studio/server/api/canvas.ts +0 -1
- package/studio/server/api/context.ts +1 -1
- package/studio/server/api/deployment.ts +1 -1
- package/studio/server/api/views.ts +2 -2
- package/studio/server/api/workspace.ts +1 -1
- package/studio/server/api-agent-loadout.test.ts +1 -1
- package/studio/server/api.test.ts +1 -1
- package/studio/server/cache.test.ts +24 -18
- package/studio/server/cache.ts +10 -54
- package/studio/server/cli-consumers.test.ts +31 -3
- package/studio/server/domain.test.ts +50 -68
- package/studio/server/domain.ts +82 -63
- package/studio/server/handoff/copy.ts +2 -2
- package/studio/server/index.ts +1 -1
- package/studio/server/instances/probe.test.ts +1 -1
- package/studio/server/introspect/anatomy/views/routes.ts +46 -37
- package/studio/server/introspect/anatomy/views.ts +10 -16
- package/studio/server/introspect/anatomy-extras.test.ts +29 -56
- package/studio/server/introspect/anatomy.test.ts +22 -14
- package/studio/server/introspect/anatomy.ts +6 -9
- package/studio/server/introspect/bundle.ts +0 -3
- package/studio/server/introspect/canonical-schema.test.ts +155 -424
- package/studio/server/introspect/canonical-schema.ts +248 -601
- package/studio/server/introspect/core.ts +14 -22
- package/studio/server/introspect/diff.test.ts +21 -9
- package/studio/server/introspect/diff.ts +19 -64
- package/studio/server/introspect/extractor.ts +23 -122
- package/studio/server/introspect/overlay-tsmorph.test.ts +116 -241
- package/studio/server/introspect/overlay-tsmorph.ts +0 -1
- package/studio/server/introspect/overlay.ts +3 -51
- package/studio/server/introspect/revision.test.ts +24 -28
- package/studio/server/introspect/revision.ts +11 -22
- package/studio/server/introspect/runtime.test.ts +77 -195
- package/studio/server/introspect/runtime.ts +5 -67
- package/studio/server/introspect/schema-ir-json.test.ts +81 -0
- package/studio/server/introspect/schema-ir-json.ts +39 -69
- package/studio/server/introspect/schema-refs.test.ts +43 -88
- package/studio/server/introspect/schema-refs.ts +14 -49
- package/studio/server/introspect/source-overlay/handlers.ts +116 -636
- package/studio/server/introspect/source-overlay/kernel-calls.ts +0 -3
- package/studio/server/introspect/source-overlay/spans.ts +15 -45
- package/studio/server/lifecycle.ts +4 -1
- package/studio/server/state/baseline.test.ts +14 -9
- package/studio/server/state/baseline.ts +3 -5
- package/studio/server/state/documents.test.ts +69 -0
- package/studio/server/state/documents.ts +57 -10
- package/studio/server/state/visibility.ts +1 -5
- package/studio/server/views/model.ts +3 -3
- package/studio/server/views/target.test.ts +18 -76
- package/studio/server/views/target.ts +24 -50
- package/studio/server/watch.test.ts +11 -11
- package/studio/server/watch.ts +8 -4
- package/studio/server/workspace/updates.ts +23 -1
- package/studio/server/workspace-watch.ts +2 -2
- package/studio/shared/contracts/runtime.ts +4 -0
- package/studio/shared/contracts/schema.ts +44 -131
- package/studio/shared/contracts/surface.test.ts +15 -17
- package/studio/shared/contracts/workspace.ts +5 -11
- package/studio/shared/schema/identity.test.ts +20 -44
- package/studio/shared/schema/identity.ts +9 -24
- package/studio/tsconfig.json +2 -1
- package/tsconfig.json +1 -1
- package/viewer/dist/index.html +21 -0
- package/viewer/dist/main.js +41 -62
- package/.check-workspace.cjs +0 -40
- package/src/admin/binding.ts +0 -168
- package/src/commands/__tests__/instance-create-hosts.test.ts +0 -29
- package/studio/client/dist/assets/elk-api-lwhFo7vB.js +0 -1
- package/studio/client/dist/assets/index-B-c-smo9.js +0 -8
- package/studio/client/dist/assets/index-BckHuAWk.js +0 -81
- package/studio/client/dist/assets/index-C4aNQ6dz.css +0 -1
- package/studio/client/dist/assets/index-ChOr3yP0.css +0 -1
- package/studio/server/introspect/anatomy/views/legacy.ts +0 -232
- package/studio/server/introspect/core-extractor.ts +0 -139
- package/studio/server/introspect/overlay.test.ts +0 -72
- package/studio/server/introspect/source-overlay/annotations.ts +0 -14
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
cp,
|
|
4
|
+
lstat,
|
|
5
|
+
mkdir,
|
|
6
|
+
mkdtemp,
|
|
7
|
+
readFile,
|
|
8
|
+
readdir,
|
|
9
|
+
readlink,
|
|
10
|
+
rename,
|
|
11
|
+
rm,
|
|
12
|
+
symlink,
|
|
13
|
+
writeFile,
|
|
14
|
+
} from 'node:fs/promises'
|
|
15
|
+
import { homedir, tmpdir } from 'node:os'
|
|
16
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
17
|
+
|
|
18
|
+
import { AstraleError } from '../../errors'
|
|
19
|
+
import { run, type RunResult } from '../proc'
|
|
20
|
+
import { withFileLock } from './lock'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Astrale-owned agent skill reconciliation. The ecosystem installer owns agent links;
|
|
24
|
+
* Astrale owns source freshness, local integrity, repair, rollback, and outcomes.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Published source whose top-level skill directories Astrale owns as one cohort. */
|
|
28
|
+
export const ASTRALE_CLI_SKILL_SOURCE = 'astrale-os/cli'
|
|
29
|
+
export const ASTRALE_SKILL_REPAIR_COMMAND = 'astrale update --yes --no-deps'
|
|
30
|
+
|
|
31
|
+
/** Deliberately pinned: installer behavior is part of the verified update path. */
|
|
32
|
+
export const SKILLS_INSTALLER_PACKAGE = 'skills@1.5.23'
|
|
33
|
+
|
|
34
|
+
/** Human-facing recovery stays on the Astrale-owned, verified path. */
|
|
35
|
+
export const SKILL_INSTALL_HINT = ASTRALE_SKILL_REPAIR_COMMAND
|
|
36
|
+
|
|
37
|
+
export type SkillCheckStatus =
|
|
38
|
+
| 'current'
|
|
39
|
+
| 'update-available'
|
|
40
|
+
| 'repair-needed'
|
|
41
|
+
| 'unavailable'
|
|
42
|
+
| 'skipped'
|
|
43
|
+
|
|
44
|
+
export type SkillApplyStatus =
|
|
45
|
+
| 'unchanged'
|
|
46
|
+
| 'installed'
|
|
47
|
+
| 'updated'
|
|
48
|
+
| 'repaired'
|
|
49
|
+
| 'failed'
|
|
50
|
+
| 'skipped'
|
|
51
|
+
|
|
52
|
+
export type SkillCheckResult = {
|
|
53
|
+
status: SkillCheckStatus
|
|
54
|
+
error?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type SkillApplyResult = {
|
|
58
|
+
status: SkillApplyStatus
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type SourceSkill = { name: string; path: string; tree: string }
|
|
62
|
+
export type AstraleSkillSourceSnapshot = { ref: 'main'; revision: string; skills: SourceSkill[] }
|
|
63
|
+
|
|
64
|
+
type SkillLockEntry = {
|
|
65
|
+
source?: string
|
|
66
|
+
sourceType?: string
|
|
67
|
+
sourceUrl?: string
|
|
68
|
+
ref?: string
|
|
69
|
+
skillPath?: string
|
|
70
|
+
skillFolderHash?: string
|
|
71
|
+
installedAt?: string
|
|
72
|
+
updatedAt?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type SkillLock = {
|
|
76
|
+
version: number
|
|
77
|
+
skills: Record<string, SkillLockEntry>
|
|
78
|
+
lastSelectedAgents?: string[]
|
|
79
|
+
dismissed?: Record<string, unknown>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type SkillState = 'absent' | 'current' | 'outdated' | 'unhealthy'
|
|
83
|
+
|
|
84
|
+
type SkillInspection = {
|
|
85
|
+
state: SkillState
|
|
86
|
+
managedNames: string[]
|
|
87
|
+
managedFolders: string[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type SkillSyncDependencies = {
|
|
91
|
+
home?: string
|
|
92
|
+
lockPath?: string
|
|
93
|
+
resolveSource?: () => Promise<AstraleSkillSourceSnapshot>
|
|
94
|
+
run?: (file: string, args?: string[]) => Promise<RunResult>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const SOURCE_REPOSITORY_URL = 'https://github.com/astrale-os/cli.git'
|
|
98
|
+
const SAFE_NAME = /^[a-z0-9][a-z0-9._-]*$/iu
|
|
99
|
+
|
|
100
|
+
function resolvedDependencies(overrides: SkillSyncDependencies = {}) {
|
|
101
|
+
const home = overrides.home ?? homedir()
|
|
102
|
+
const xdgStateHome = process.env.XDG_STATE_HOME
|
|
103
|
+
return {
|
|
104
|
+
home,
|
|
105
|
+
lockPath:
|
|
106
|
+
overrides.lockPath ??
|
|
107
|
+
(xdgStateHome
|
|
108
|
+
? join(xdgStateHome, 'skills', '.skill-lock.json')
|
|
109
|
+
: join(home, '.agents', '.skill-lock.json')),
|
|
110
|
+
run: overrides.run ?? run,
|
|
111
|
+
resolveSource: overrides.resolveSource,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sourceOwned(entry: SkillLockEntry): boolean {
|
|
116
|
+
return (
|
|
117
|
+
entry.source === ASTRALE_CLI_SKILL_SOURCE ||
|
|
118
|
+
entry.sourceUrl === SOURCE_REPOSITORY_URL ||
|
|
119
|
+
entry.sourceUrl === `https://github.com/${ASTRALE_CLI_SKILL_SOURCE}`
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function folderName(entry: SkillLockEntry): string | null {
|
|
124
|
+
const match = entry.skillPath?.match(/^skills\/([^/]+)\/SKILL\.md$/u)
|
|
125
|
+
return match?.[1] ?? null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function resolveAstraleSkillSource(
|
|
129
|
+
execute: ReturnType<typeof resolvedDependencies>['run'],
|
|
130
|
+
): Promise<AstraleSkillSourceSnapshot> {
|
|
131
|
+
const checkout = await mkdtemp(join(tmpdir(), 'astrale-skill-source-'))
|
|
132
|
+
try {
|
|
133
|
+
const cloned = await execute('git', [
|
|
134
|
+
'clone',
|
|
135
|
+
'--quiet',
|
|
136
|
+
'--depth',
|
|
137
|
+
'1',
|
|
138
|
+
'--filter=blob:none',
|
|
139
|
+
'--no-checkout',
|
|
140
|
+
'--single-branch',
|
|
141
|
+
'--branch',
|
|
142
|
+
'main',
|
|
143
|
+
SOURCE_REPOSITORY_URL,
|
|
144
|
+
checkout,
|
|
145
|
+
])
|
|
146
|
+
if (cloned.code !== 0) throw new Error(cloned.stderr || cloned.stdout || 'git clone failed')
|
|
147
|
+
const revision = await execute('git', ['-C', checkout, 'rev-parse', 'HEAD'])
|
|
148
|
+
if (revision.code !== 0) {
|
|
149
|
+
throw new Error(revision.stderr || revision.stdout || 'git rev-parse failed')
|
|
150
|
+
}
|
|
151
|
+
const folders = await execute('git', ['-C', checkout, 'ls-tree', 'HEAD:skills'])
|
|
152
|
+
const files = await execute('git', [
|
|
153
|
+
'-C',
|
|
154
|
+
checkout,
|
|
155
|
+
'ls-tree',
|
|
156
|
+
'-r',
|
|
157
|
+
'--name-only',
|
|
158
|
+
'HEAD:skills',
|
|
159
|
+
])
|
|
160
|
+
if (folders.code !== 0 || files.code !== 0) {
|
|
161
|
+
throw new Error(folders.stderr || files.stderr || 'git ls-tree failed')
|
|
162
|
+
}
|
|
163
|
+
const skillFiles = new Set(
|
|
164
|
+
files.stdout
|
|
165
|
+
.split('\n')
|
|
166
|
+
.filter((path) => /^[^/]+\/SKILL\.md$/u.test(path))
|
|
167
|
+
.map((path) => path.slice(0, -'/SKILL.md'.length)),
|
|
168
|
+
)
|
|
169
|
+
const skills = folders.stdout.split('\n').flatMap((line): SourceSkill[] => {
|
|
170
|
+
const match = line.match(/^040000 tree ([0-9a-f]{40})\t([^/]+)$/u)
|
|
171
|
+
if (!match || !SAFE_NAME.test(match[2]) || !skillFiles.has(match[2])) return []
|
|
172
|
+
return [{ name: match[2], path: `skills/${match[2]}/SKILL.md`, tree: match[1] }]
|
|
173
|
+
})
|
|
174
|
+
skills.sort((a, b) => a.name.localeCompare(b.name))
|
|
175
|
+
if (skills.length === 0) throw new Error('astrale-os/cli publishes no top-level skills')
|
|
176
|
+
return { ref: 'main', revision: revision.stdout.trim(), skills }
|
|
177
|
+
} finally {
|
|
178
|
+
await rm(checkout, { recursive: true, force: true })
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function readSkillLock(
|
|
183
|
+
path: string,
|
|
184
|
+
): Promise<{ lock: SkillLock | null; raw: string | null }> {
|
|
185
|
+
let raw: string
|
|
186
|
+
try {
|
|
187
|
+
raw = await readFile(path, 'utf8')
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if ((error as { code?: string }).code === 'ENOENT') return { lock: null, raw: null }
|
|
190
|
+
throw error
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const parsed: unknown = JSON.parse(raw)
|
|
194
|
+
if (
|
|
195
|
+
parsed === null ||
|
|
196
|
+
typeof parsed !== 'object' ||
|
|
197
|
+
!('skills' in parsed) ||
|
|
198
|
+
(parsed as { skills?: unknown }).skills === null ||
|
|
199
|
+
typeof (parsed as { skills?: unknown }).skills !== 'object'
|
|
200
|
+
) {
|
|
201
|
+
return { lock: null, raw }
|
|
202
|
+
}
|
|
203
|
+
return { lock: parsed as SkillLock, raw }
|
|
204
|
+
} catch {
|
|
205
|
+
return { lock: null, raw }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function writeSkillLock(path: string, lock: SkillLock): Promise<void> {
|
|
210
|
+
await mkdir(dirname(path), { recursive: true })
|
|
211
|
+
const next = `${path}.next`
|
|
212
|
+
await writeFile(next, `${JSON.stringify(lock, null, 2)}\n`, { mode: 0o600 })
|
|
213
|
+
await rename(next, path)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function gitObjectHash(type: 'blob' | 'tree', body: Buffer): Buffer {
|
|
217
|
+
return createHash('sha1')
|
|
218
|
+
.update(Buffer.from(`${type} ${body.length}\0`))
|
|
219
|
+
.update(body)
|
|
220
|
+
.digest()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Git tree identity of the bytes actually installed on disk. */
|
|
224
|
+
export async function computeSkillTreeHash(root: string): Promise<string> {
|
|
225
|
+
async function treeHash(directory: string): Promise<Buffer> {
|
|
226
|
+
const entries = await Promise.all(
|
|
227
|
+
(await readdir(directory)).map(async (name) => {
|
|
228
|
+
const path = join(directory, name)
|
|
229
|
+
const stat = await lstat(path)
|
|
230
|
+
if (stat.isDirectory()) {
|
|
231
|
+
return { name, sort: `${name}/`, mode: '40000', hash: await treeHash(path) }
|
|
232
|
+
}
|
|
233
|
+
if (stat.isSymbolicLink()) {
|
|
234
|
+
return {
|
|
235
|
+
name,
|
|
236
|
+
sort: name,
|
|
237
|
+
mode: '120000',
|
|
238
|
+
hash: gitObjectHash('blob', Buffer.from(await readlink(path))),
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
name,
|
|
243
|
+
sort: name,
|
|
244
|
+
mode: stat.mode & 0o111 ? '100755' : '100644',
|
|
245
|
+
hash: gitObjectHash('blob', await readFile(path)),
|
|
246
|
+
}
|
|
247
|
+
}),
|
|
248
|
+
)
|
|
249
|
+
entries.sort((a, b) => Buffer.compare(Buffer.from(a.sort), Buffer.from(b.sort)))
|
|
250
|
+
const body = Buffer.concat(
|
|
251
|
+
entries.flatMap((entry) => [Buffer.from(`${entry.mode} ${entry.name}\0`), entry.hash]),
|
|
252
|
+
)
|
|
253
|
+
return gitObjectHash('tree', body)
|
|
254
|
+
}
|
|
255
|
+
return (await treeHash(root)).toString('hex')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function computeInstallerFolderHash(root: string): Promise<string> {
|
|
259
|
+
const files: Array<{ path: string; content: Buffer }> = []
|
|
260
|
+
async function collect(directory: string): Promise<void> {
|
|
261
|
+
await Promise.all(
|
|
262
|
+
(await readdir(directory, { withFileTypes: true })).map(async (entry) => {
|
|
263
|
+
if (entry.name === '.git' || entry.name === 'node_modules') return
|
|
264
|
+
const path = join(directory, entry.name)
|
|
265
|
+
if (entry.isDirectory()) await collect(path)
|
|
266
|
+
else if (entry.isFile()) {
|
|
267
|
+
files.push({
|
|
268
|
+
path: relative(root, path).split('\\').join('/'),
|
|
269
|
+
content: await readFile(path),
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
}),
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
await collect(root)
|
|
276
|
+
files.sort((a, b) => a.path.localeCompare(b.path))
|
|
277
|
+
const hash = createHash('sha256')
|
|
278
|
+
for (const file of files) hash.update(file.path).update(file.content)
|
|
279
|
+
return hash.digest('hex')
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function inspectAstraleSkills(
|
|
283
|
+
snapshot: AstraleSkillSourceSnapshot,
|
|
284
|
+
home: string,
|
|
285
|
+
lockPath: string,
|
|
286
|
+
): Promise<SkillInspection> {
|
|
287
|
+
const { lock } = await readSkillLock(lockPath)
|
|
288
|
+
const managed = Object.entries(lock?.skills ?? {}).filter(([, entry]) => sourceOwned(entry))
|
|
289
|
+
const expected = new Map(snapshot.skills.map((skill) => [skill.name, skill]))
|
|
290
|
+
const expectedPresence = await Promise.all(
|
|
291
|
+
snapshot.skills.map(async (skill) => {
|
|
292
|
+
try {
|
|
293
|
+
return (await lstat(join(home, '.agents', 'skills', skill.name))).isDirectory()
|
|
294
|
+
} catch {
|
|
295
|
+
return false
|
|
296
|
+
}
|
|
297
|
+
}),
|
|
298
|
+
)
|
|
299
|
+
if (managed.length === 0 && expectedPresence.every((present) => !present)) {
|
|
300
|
+
return { state: 'absent', managedNames: [], managedFolders: [] }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const folders = managed.flatMap(([name, entry]) => {
|
|
304
|
+
const folder = folderName(entry)
|
|
305
|
+
return folder ? [{ key: name, folder, entry }] : []
|
|
306
|
+
})
|
|
307
|
+
const uniqueFolders = new Set(folders.map((entry) => entry.folder))
|
|
308
|
+
let coherent = folders.length === managed.length && uniqueFolders.size === folders.length
|
|
309
|
+
const actualHashes = new Map<string, string>()
|
|
310
|
+
for (const item of folders) {
|
|
311
|
+
try {
|
|
312
|
+
const root = join(home, '.agents', 'skills', item.folder)
|
|
313
|
+
const actual = await computeSkillTreeHash(root)
|
|
314
|
+
actualHashes.set(item.folder, actual)
|
|
315
|
+
const receiptHash = item.entry.skillFolderHash
|
|
316
|
+
if (
|
|
317
|
+
!receiptHash ||
|
|
318
|
+
(receiptHash.length === 40
|
|
319
|
+
? actual !== receiptHash
|
|
320
|
+
: receiptHash.length === 64
|
|
321
|
+
? (await computeInstallerFolderHash(root)) !== receiptHash
|
|
322
|
+
: true)
|
|
323
|
+
) {
|
|
324
|
+
coherent = false
|
|
325
|
+
}
|
|
326
|
+
if (item.entry.skillPath !== `skills/${item.folder}/SKILL.md`) coherent = false
|
|
327
|
+
} catch {
|
|
328
|
+
coherent = false
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
for (const [index, skill] of snapshot.skills.entries()) {
|
|
332
|
+
if (expectedPresence[index] && !uniqueFolders.has(skill.name)) coherent = false
|
|
333
|
+
}
|
|
334
|
+
if (await directoryExists(join(home, '.claude'))) {
|
|
335
|
+
for (const skill of snapshot.skills) {
|
|
336
|
+
const link = join(home, '.claude', 'skills', skill.name)
|
|
337
|
+
try {
|
|
338
|
+
if (!(await lstat(link)).isSymbolicLink()) coherent = false
|
|
339
|
+
else if (
|
|
340
|
+
resolve(dirname(link), await readlink(link)) !==
|
|
341
|
+
join(home, '.agents', 'skills', skill.name)
|
|
342
|
+
) {
|
|
343
|
+
coherent = false
|
|
344
|
+
}
|
|
345
|
+
} catch {
|
|
346
|
+
coherent = false
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const exactCurrent =
|
|
352
|
+
coherent &&
|
|
353
|
+
managed.length === snapshot.skills.length &&
|
|
354
|
+
folders.every(({ key, folder, entry }) => {
|
|
355
|
+
const skill = expected.get(folder)
|
|
356
|
+
return (
|
|
357
|
+
key === folder &&
|
|
358
|
+
skill !== undefined &&
|
|
359
|
+
entry.ref === snapshot.ref &&
|
|
360
|
+
actualHashes.get(folder) === skill.tree
|
|
361
|
+
)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
state: exactCurrent ? 'current' : coherent ? 'outdated' : 'unhealthy',
|
|
366
|
+
managedNames: managed.map(([name]) => name),
|
|
367
|
+
managedFolders: [...uniqueFolders],
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function directoryExists(path: string): Promise<boolean> {
|
|
372
|
+
try {
|
|
373
|
+
return (await lstat(path)).isDirectory()
|
|
374
|
+
} catch {
|
|
375
|
+
return false
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function sourceSnapshot(dependencies: ReturnType<typeof resolvedDependencies>) {
|
|
380
|
+
return dependencies.resolveSource
|
|
381
|
+
? await dependencies.resolveSource()
|
|
382
|
+
: await resolveAstraleSkillSource(dependencies.run)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function installerArgs(...args: string[]): string[] {
|
|
386
|
+
return ['--yes', SKILLS_INSTALLER_PACKAGE, ...args]
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function installSnapshot(
|
|
390
|
+
snapshot: AstraleSkillSourceSnapshot,
|
|
391
|
+
execute: ReturnType<typeof resolvedDependencies>['run'],
|
|
392
|
+
selectedAgents: string[],
|
|
393
|
+
): Promise<RunResult> {
|
|
394
|
+
return await execute(
|
|
395
|
+
'npx',
|
|
396
|
+
installerArgs(
|
|
397
|
+
'add',
|
|
398
|
+
`${ASTRALE_CLI_SKILL_SOURCE}#${snapshot.ref}`,
|
|
399
|
+
'-g',
|
|
400
|
+
'-y',
|
|
401
|
+
'--skill',
|
|
402
|
+
...snapshot.skills.map((skill) => skill.name),
|
|
403
|
+
...(selectedAgents.length > 0 ? ['--agent', ...selectedAgents] : []),
|
|
404
|
+
),
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function selectedAgents(home: string, lockPath: string): Promise<string[]> {
|
|
409
|
+
const { lock } = await readSkillLock(lockPath)
|
|
410
|
+
const agents = new Set(
|
|
411
|
+
(lock?.lastSelectedAgents ?? []).filter(
|
|
412
|
+
(agent): agent is string => typeof agent === 'string' && SAFE_NAME.test(agent),
|
|
413
|
+
),
|
|
414
|
+
)
|
|
415
|
+
agents.add('codex')
|
|
416
|
+
if (await directoryExists(join(home, '.claude'))) agents.add('claude-code')
|
|
417
|
+
return [...agents]
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function cleanManagedState(names: string[], home: string, lockPath: string): Promise<void> {
|
|
421
|
+
for (const name of names) {
|
|
422
|
+
await rm(join(home, '.agents', 'skills', name), { recursive: true, force: true })
|
|
423
|
+
}
|
|
424
|
+
const { lock, raw } = await readSkillLock(lockPath)
|
|
425
|
+
if (!lock) {
|
|
426
|
+
if (raw !== null) await rm(lockPath, { force: true })
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
for (const [name, entry] of Object.entries(lock.skills)) {
|
|
430
|
+
if (sourceOwned(entry)) delete lock.skills[name]
|
|
431
|
+
}
|
|
432
|
+
await writeSkillLock(lockPath, lock)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function removeKnownAgentLinks(names: string[], home: string): Promise<void> {
|
|
436
|
+
const roots = await readdir(home, { withFileTypes: true }).catch(() => [])
|
|
437
|
+
for (const root of roots) {
|
|
438
|
+
if (!root.isDirectory() || !root.name.startsWith('.') || root.name === '.agents') continue
|
|
439
|
+
for (const name of names) {
|
|
440
|
+
const link = join(home, root.name, 'skills', name)
|
|
441
|
+
try {
|
|
442
|
+
const stat = await lstat(link)
|
|
443
|
+
if (!stat.isSymbolicLink()) continue
|
|
444
|
+
const target = await readlink(link)
|
|
445
|
+
if (resolve(dirname(link), target) === join(home, '.agents', 'skills', name)) {
|
|
446
|
+
await rm(link, { force: true })
|
|
447
|
+
}
|
|
448
|
+
} catch {
|
|
449
|
+
// Missing or foreign links are not part of the Astrale-owned repair.
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function pruneObsoleteEntries(
|
|
456
|
+
snapshot: AstraleSkillSourceSnapshot,
|
|
457
|
+
home: string,
|
|
458
|
+
lockPath: string,
|
|
459
|
+
execute: ReturnType<typeof resolvedDependencies>['run'],
|
|
460
|
+
knownRetired: string[],
|
|
461
|
+
): Promise<void> {
|
|
462
|
+
const expected = new Set(snapshot.skills.map((skill) => skill.name))
|
|
463
|
+
const { lock } = await readSkillLock(lockPath)
|
|
464
|
+
const retiredEntries = Object.entries(lock?.skills ?? {}).filter(([, entry]) => {
|
|
465
|
+
const folder = folderName(entry)
|
|
466
|
+
return sourceOwned(entry) && folder !== null && !expected.has(folder)
|
|
467
|
+
})
|
|
468
|
+
const retired = [
|
|
469
|
+
...new Set([
|
|
470
|
+
...knownRetired,
|
|
471
|
+
...retiredEntries.flatMap(([, entry]) => folderName(entry) ?? []),
|
|
472
|
+
]),
|
|
473
|
+
]
|
|
474
|
+
if (retired.length > 0) {
|
|
475
|
+
const result = await execute('npx', installerArgs('remove', ...retired, '-g', '-y'))
|
|
476
|
+
if (result.code !== 0) throw new Error(result.stderr || result.stdout || 'skill removal failed')
|
|
477
|
+
}
|
|
478
|
+
const refreshed = await readSkillLock(lockPath)
|
|
479
|
+
if (!refreshed.lock) return
|
|
480
|
+
let changed = false
|
|
481
|
+
for (const [name, entry] of Object.entries(refreshed.lock.skills)) {
|
|
482
|
+
if (!sourceOwned(entry)) continue
|
|
483
|
+
const folder = folderName(entry)
|
|
484
|
+
if (folder === null || name !== folder || !expected.has(folder)) {
|
|
485
|
+
delete refreshed.lock.skills[name]
|
|
486
|
+
changed = true
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (changed) await writeSkillLock(lockPath, refreshed.lock)
|
|
490
|
+
|
|
491
|
+
await removeKnownAgentLinks(retired, home)
|
|
492
|
+
for (const name of retired) {
|
|
493
|
+
await rm(join(home, '.agents', 'skills', name), { recursive: true, force: true })
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
type SkillBackup = {
|
|
498
|
+
root: string
|
|
499
|
+
names: string[]
|
|
500
|
+
copied: string[]
|
|
501
|
+
links: AgentLinkBackup[]
|
|
502
|
+
lockRaw: string | null
|
|
503
|
+
createdAt: string
|
|
504
|
+
phase: 'prepared' | 'verified'
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
type AgentLinkBackup = { root: string; name: string; target: string }
|
|
508
|
+
|
|
509
|
+
const BACKUP_PREFIX = '.astrale-skill-backup-'
|
|
510
|
+
const BACKUP_MANIFEST = '.manifest.json'
|
|
511
|
+
|
|
512
|
+
async function captureAgentLinks(home: string, names: string[]): Promise<AgentLinkBackup[]> {
|
|
513
|
+
const links: AgentLinkBackup[] = []
|
|
514
|
+
const roots = await readdir(home, { withFileTypes: true }).catch(() => [])
|
|
515
|
+
for (const root of roots) {
|
|
516
|
+
if (!root.isDirectory() || !root.name.startsWith('.') || root.name === '.agents') continue
|
|
517
|
+
for (const name of names) {
|
|
518
|
+
const path = join(home, root.name, 'skills', name)
|
|
519
|
+
try {
|
|
520
|
+
if ((await lstat(path)).isSymbolicLink()) {
|
|
521
|
+
links.push({ root: root.name, name, target: await readlink(path) })
|
|
522
|
+
}
|
|
523
|
+
} catch {
|
|
524
|
+
// Only existing symlinks need transactional restoration.
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return links
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function writeBackupManifest(backup: SkillBackup): Promise<void> {
|
|
532
|
+
const next = join(backup.root, `${BACKUP_MANIFEST}.next`)
|
|
533
|
+
await writeFile(next, JSON.stringify(backup), { mode: 0o600 })
|
|
534
|
+
await rename(next, join(backup.root, BACKUP_MANIFEST))
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function captureBackup(home: string, lockPath: string, names: string[]) {
|
|
538
|
+
const agentsRoot = join(home, '.agents')
|
|
539
|
+
await mkdir(agentsRoot, { recursive: true })
|
|
540
|
+
const root = await mkdtemp(join(agentsRoot, BACKUP_PREFIX))
|
|
541
|
+
const copied: string[] = []
|
|
542
|
+
for (const name of names) {
|
|
543
|
+
const source = join(agentsRoot, 'skills', name)
|
|
544
|
+
try {
|
|
545
|
+
await cp(source, join(root, name), { recursive: true, dereference: false })
|
|
546
|
+
copied.push(name)
|
|
547
|
+
} catch (error) {
|
|
548
|
+
if ((error as { code?: string }).code !== 'ENOENT') throw error
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const lock = await readSkillLock(lockPath)
|
|
552
|
+
const backup: SkillBackup = {
|
|
553
|
+
root,
|
|
554
|
+
names,
|
|
555
|
+
copied,
|
|
556
|
+
links: await captureAgentLinks(home, names),
|
|
557
|
+
lockRaw: lock.raw,
|
|
558
|
+
createdAt: new Date().toISOString(),
|
|
559
|
+
phase: 'prepared',
|
|
560
|
+
}
|
|
561
|
+
await writeBackupManifest(backup)
|
|
562
|
+
return backup
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function extendBackup(backup: SkillBackup, home: string, names: string[]): Promise<void> {
|
|
566
|
+
const additions = names.filter((name) => !backup.names.includes(name))
|
|
567
|
+
if (additions.length === 0) return
|
|
568
|
+
for (const name of additions) {
|
|
569
|
+
const source = join(home, '.agents', 'skills', name)
|
|
570
|
+
try {
|
|
571
|
+
await cp(source, join(backup.root, name), { recursive: true, dereference: false })
|
|
572
|
+
backup.copied.push(name)
|
|
573
|
+
} catch (error) {
|
|
574
|
+
if ((error as { code?: string }).code !== 'ENOENT') throw error
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
backup.names.push(...additions)
|
|
578
|
+
backup.links.push(...(await captureAgentLinks(home, additions)))
|
|
579
|
+
await writeBackupManifest(backup)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function restoreAgentLinks(
|
|
583
|
+
backup: SkillBackup,
|
|
584
|
+
home: string,
|
|
585
|
+
onlyForeign = false,
|
|
586
|
+
): Promise<void> {
|
|
587
|
+
for (const link of backup.links) {
|
|
588
|
+
const path = join(home, link.root, 'skills', link.name)
|
|
589
|
+
const canonical = join(home, '.agents', 'skills', link.name)
|
|
590
|
+
if (onlyForeign && resolve(dirname(path), link.target) === canonical) continue
|
|
591
|
+
await mkdir(dirname(path), { recursive: true })
|
|
592
|
+
await rm(path, { recursive: true, force: true })
|
|
593
|
+
await symlink(link.target, path)
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function restoreBackup(
|
|
598
|
+
backup: Awaited<ReturnType<typeof captureBackup>>,
|
|
599
|
+
home: string,
|
|
600
|
+
lockPath: string,
|
|
601
|
+
): Promise<void> {
|
|
602
|
+
for (const name of backup.names) {
|
|
603
|
+
await rm(join(home, '.agents', 'skills', name), { recursive: true, force: true })
|
|
604
|
+
}
|
|
605
|
+
await mkdir(join(home, '.agents', 'skills'), { recursive: true })
|
|
606
|
+
for (const name of backup.copied) {
|
|
607
|
+
await cp(join(backup.root, name), join(home, '.agents', 'skills', name), {
|
|
608
|
+
recursive: true,
|
|
609
|
+
dereference: false,
|
|
610
|
+
})
|
|
611
|
+
}
|
|
612
|
+
if (backup.lockRaw === null) await rm(lockPath, { force: true })
|
|
613
|
+
else {
|
|
614
|
+
await mkdir(dirname(lockPath), { recursive: true })
|
|
615
|
+
const next = `${lockPath}.restore`
|
|
616
|
+
await writeFile(next, backup.lockRaw, { mode: 0o600 })
|
|
617
|
+
await rename(next, lockPath)
|
|
618
|
+
}
|
|
619
|
+
await removeKnownAgentLinks(backup.names, home)
|
|
620
|
+
await restoreAgentLinks(backup, home)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function recoverInterruptedBackup(home: string, lockPath: string): Promise<void> {
|
|
624
|
+
const agentsRoot = join(home, '.agents')
|
|
625
|
+
const candidates = (await readdir(agentsRoot, { withFileTypes: true }).catch(() => []))
|
|
626
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith(BACKUP_PREFIX))
|
|
627
|
+
.map((entry) => join(agentsRoot, entry.name))
|
|
628
|
+
const backups: SkillBackup[] = []
|
|
629
|
+
for (const root of candidates) {
|
|
630
|
+
try {
|
|
631
|
+
const parsed = JSON.parse(await readFile(join(root, BACKUP_MANIFEST), 'utf8')) as SkillBackup
|
|
632
|
+
if (
|
|
633
|
+
Array.isArray(parsed.names) &&
|
|
634
|
+
parsed.names.every((name) => typeof name === 'string' && SAFE_NAME.test(name)) &&
|
|
635
|
+
Array.isArray(parsed.copied) &&
|
|
636
|
+
parsed.copied.every((name) => typeof name === 'string' && parsed.names.includes(name)) &&
|
|
637
|
+
Array.isArray(parsed.links) &&
|
|
638
|
+
(typeof parsed.lockRaw === 'string' || parsed.lockRaw === null) &&
|
|
639
|
+
typeof parsed.createdAt === 'string' &&
|
|
640
|
+
(parsed.phase === 'prepared' || parsed.phase === 'verified') &&
|
|
641
|
+
parsed.links.every(
|
|
642
|
+
(link) =>
|
|
643
|
+
link !== null &&
|
|
644
|
+
typeof link === 'object' &&
|
|
645
|
+
typeof link.root === 'string' &&
|
|
646
|
+
/^\.[^/]+$/u.test(link.root) &&
|
|
647
|
+
typeof link.name === 'string' &&
|
|
648
|
+
parsed.names.includes(link.name) &&
|
|
649
|
+
typeof link.target === 'string',
|
|
650
|
+
)
|
|
651
|
+
) {
|
|
652
|
+
const backup = { ...parsed, root }
|
|
653
|
+
if (backup.phase === 'verified') await rm(root, { recursive: true, force: true })
|
|
654
|
+
else backups.push(backup)
|
|
655
|
+
} else {
|
|
656
|
+
await rm(root, { recursive: true, force: true })
|
|
657
|
+
}
|
|
658
|
+
} catch {
|
|
659
|
+
// The live cohort is untouched until the manifest is complete.
|
|
660
|
+
await rm(root, { recursive: true, force: true })
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (backups.length === 0) return
|
|
664
|
+
backups.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
665
|
+
await restoreBackup(backups[0], home, lockPath)
|
|
666
|
+
await Promise.all(candidates.map((root) => rm(root, { recursive: true, force: true })))
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function markBackupVerified(backup: SkillBackup): Promise<void> {
|
|
670
|
+
backup.phase = 'verified'
|
|
671
|
+
await writeBackupManifest(backup)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function skillFailure(error: unknown): AstraleError {
|
|
675
|
+
const detail = error instanceof Error && error.message.trim() ? `: ${error.message}` : ''
|
|
676
|
+
return new AstraleError(
|
|
677
|
+
'SKILL_UPDATE_FAILED',
|
|
678
|
+
`Astrale could not install and verify its agent skills${detail}`,
|
|
679
|
+
`Retry with \`${ASTRALE_SKILL_REPAIR_COMMAND}\`.`,
|
|
680
|
+
error instanceof Error ? { cause: error } : undefined,
|
|
681
|
+
)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Read-only source, freshness, and local integrity check. */
|
|
685
|
+
export async function checkAstraleSkills(
|
|
686
|
+
overrides: SkillSyncDependencies = {},
|
|
687
|
+
): Promise<SkillCheckResult> {
|
|
688
|
+
const dependencies = resolvedDependencies(overrides)
|
|
689
|
+
try {
|
|
690
|
+
const snapshot = await sourceSnapshot(dependencies)
|
|
691
|
+
const inspection = await inspectAstraleSkills(
|
|
692
|
+
snapshot,
|
|
693
|
+
dependencies.home,
|
|
694
|
+
dependencies.lockPath,
|
|
695
|
+
)
|
|
696
|
+
return {
|
|
697
|
+
status:
|
|
698
|
+
inspection.state === 'current'
|
|
699
|
+
? 'current'
|
|
700
|
+
: inspection.state === 'unhealthy'
|
|
701
|
+
? 'repair-needed'
|
|
702
|
+
: 'update-available',
|
|
703
|
+
}
|
|
704
|
+
} catch (error) {
|
|
705
|
+
return { status: 'unavailable', error: error instanceof Error ? error.message : String(error) }
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Ensure the complete Astrale-owned cohort is current and internally consistent.
|
|
711
|
+
* A clean second attempt follows any failed ordinary refresh; a failed operation
|
|
712
|
+
* restores the exact prior cohort and never reports success.
|
|
713
|
+
*/
|
|
714
|
+
export async function syncAstraleSkills(
|
|
715
|
+
overrides: SkillSyncDependencies = {},
|
|
716
|
+
): Promise<SkillApplyResult> {
|
|
717
|
+
const dependencies = resolvedDependencies(overrides)
|
|
718
|
+
let snapshot: AstraleSkillSourceSnapshot
|
|
719
|
+
try {
|
|
720
|
+
snapshot = await sourceSnapshot(dependencies)
|
|
721
|
+
} catch (error) {
|
|
722
|
+
throw skillFailure(error)
|
|
723
|
+
}
|
|
724
|
+
const lockFile = join(
|
|
725
|
+
process.env.ASTRALE_HOME ?? join(dependencies.home, '.astrale'),
|
|
726
|
+
'locks',
|
|
727
|
+
'skills-update.lock',
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
try {
|
|
731
|
+
return await withFileLock(lockFile, async () => {
|
|
732
|
+
await recoverInterruptedBackup(dependencies.home, dependencies.lockPath)
|
|
733
|
+
const initial = await inspectAstraleSkills(snapshot, dependencies.home, dependencies.lockPath)
|
|
734
|
+
if (initial.state === 'current') return { status: 'unchanged' }
|
|
735
|
+
|
|
736
|
+
let expectedNames = snapshot.skills.map((skill) => skill.name)
|
|
737
|
+
let managedFolders = [...new Set([...expectedNames, ...initial.managedFolders])]
|
|
738
|
+
let knownRetired = initial.managedFolders.filter((name) => !expectedNames.includes(name))
|
|
739
|
+
const agents = await selectedAgents(dependencies.home, dependencies.lockPath)
|
|
740
|
+
const backup = await captureBackup(dependencies.home, dependencies.lockPath, managedFolders)
|
|
741
|
+
let lastError: unknown
|
|
742
|
+
try {
|
|
743
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
744
|
+
try {
|
|
745
|
+
if (attempt === 1) {
|
|
746
|
+
snapshot = await sourceSnapshot(dependencies)
|
|
747
|
+
expectedNames = snapshot.skills.map((skill) => skill.name)
|
|
748
|
+
managedFolders = [...new Set([...managedFolders, ...expectedNames])]
|
|
749
|
+
await extendBackup(backup, dependencies.home, managedFolders)
|
|
750
|
+
knownRetired = [
|
|
751
|
+
...new Set([
|
|
752
|
+
...knownRetired,
|
|
753
|
+
...managedFolders.filter((name) => !expectedNames.includes(name)),
|
|
754
|
+
]),
|
|
755
|
+
]
|
|
756
|
+
}
|
|
757
|
+
if (initial.state === 'unhealthy' || attempt === 1) {
|
|
758
|
+
await cleanManagedState(managedFolders, dependencies.home, dependencies.lockPath)
|
|
759
|
+
}
|
|
760
|
+
const installed = await installSnapshot(snapshot, dependencies.run, agents)
|
|
761
|
+
if (installed.code !== 0) {
|
|
762
|
+
throw new Error(installed.stderr || installed.stdout || 'skill installer failed')
|
|
763
|
+
}
|
|
764
|
+
await pruneObsoleteEntries(
|
|
765
|
+
snapshot,
|
|
766
|
+
dependencies.home,
|
|
767
|
+
dependencies.lockPath,
|
|
768
|
+
dependencies.run,
|
|
769
|
+
knownRetired,
|
|
770
|
+
)
|
|
771
|
+
const verified = await inspectAstraleSkills(
|
|
772
|
+
snapshot,
|
|
773
|
+
dependencies.home,
|
|
774
|
+
dependencies.lockPath,
|
|
775
|
+
)
|
|
776
|
+
if (verified.state !== 'current') {
|
|
777
|
+
throw new Error('installed Astrale skills did not pass verification')
|
|
778
|
+
}
|
|
779
|
+
const latest = await sourceSnapshot(dependencies)
|
|
780
|
+
if (latest.revision !== snapshot.revision) {
|
|
781
|
+
throw new Error('Astrale skill source changed during installation')
|
|
782
|
+
}
|
|
783
|
+
await markBackupVerified(backup)
|
|
784
|
+
await rm(backup.root, { recursive: true, force: true })
|
|
785
|
+
return {
|
|
786
|
+
status:
|
|
787
|
+
initial.state === 'absent'
|
|
788
|
+
? 'installed'
|
|
789
|
+
: initial.state === 'outdated'
|
|
790
|
+
? 'updated'
|
|
791
|
+
: 'repaired',
|
|
792
|
+
}
|
|
793
|
+
} catch (error) {
|
|
794
|
+
lastError = error
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
throw lastError
|
|
798
|
+
} catch (error) {
|
|
799
|
+
if (initial.state === 'absent' || initial.state === 'unhealthy') {
|
|
800
|
+
await cleanManagedState(managedFolders, dependencies.home, dependencies.lockPath)
|
|
801
|
+
await removeKnownAgentLinks(managedFolders, dependencies.home)
|
|
802
|
+
await restoreAgentLinks(backup, dependencies.home, true)
|
|
803
|
+
} else {
|
|
804
|
+
await restoreBackup(backup, dependencies.home, dependencies.lockPath)
|
|
805
|
+
}
|
|
806
|
+
await rm(backup.root, { recursive: true, force: true })
|
|
807
|
+
throw skillFailure(error)
|
|
808
|
+
}
|
|
809
|
+
})
|
|
810
|
+
} catch (error) {
|
|
811
|
+
if (error instanceof AstraleError) throw error
|
|
812
|
+
throw skillFailure(error)
|
|
813
|
+
}
|
|
814
|
+
}
|