akm-cli 0.9.16 → 0.9.17-alpha.10
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/CHANGELOG.md +2101 -0
- package/STABILITY.md +11 -10
- package/dist/akm +124 -193
- package/dist/akm-migrate +38 -19
- package/dist/assets/hints/cli-hints-full.md +6 -7
- package/dist/assets/improve-strategies/catchup.json +0 -3
- package/dist/assets/improve-strategies/consolidate.json +0 -1
- package/dist/assets/improve-strategies/default.json +1 -2
- package/dist/assets/improve-strategies/proactive-maintenance.json +1 -2
- package/dist/assets/improve-strategies/quick.json +1 -2
- package/dist/assets/improve-strategies/reflect-distill.json +1 -2
- package/dist/assets/improve-strategies/thorough.json +0 -3
- package/dist/assets/prompts/consolidate-pair.md +20 -0
- package/dist/assets/prompts/consolidate-system.md +4 -11
- package/dist/assets/prompts/retrieval-relevance-judge.md +6 -0
- package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +20 -20
- package/dist/assets/stash-skeleton/facts/conventions/domains.md +2 -2
- package/dist/assets/templates/html/health.html +3 -5
- package/dist/cli/retired-commands.js +1 -1
- package/dist/cli/shared.js +6 -2
- package/dist/cli/unknown-flags.js +24 -1
- package/dist/cli.js +68 -10
- package/dist/commands/agent/agent-dispatch.js +1 -1
- package/dist/commands/command/command-execution.js +24 -62
- package/dist/commands/feedback-cli.js +0 -1
- package/dist/commands/health/accept-rate.js +6 -0
- package/dist/commands/health/archive-usage.js +92 -0
- package/dist/commands/health/checks.js +83 -74
- package/dist/commands/health/config-skew.js +38 -0
- package/dist/commands/health/data-dir-usage.js +25 -13
- package/dist/commands/health/egress.js +54 -0
- package/dist/commands/health/html-report.js +1 -42
- package/dist/commands/health/improve-metrics.js +136 -591
- package/dist/commands/health/md-report.js +1 -6
- package/dist/commands/health/plugin-staleness.js +53 -3
- package/dist/commands/health/renderers.js +12 -4
- package/dist/commands/health/report-view-model.js +14 -120
- package/dist/commands/health/types-improve.js +4 -19
- package/dist/commands/health/windows.js +64 -74
- package/dist/commands/health.js +145 -143
- package/dist/commands/improve/consolidate/chunking.js +26 -117
- package/dist/commands/improve/consolidate/continuity-check.js +137 -0
- package/dist/commands/improve/consolidate/pair-pass.js +791 -0
- package/dist/commands/improve/consolidate/sanitize.js +54 -149
- package/dist/commands/improve/consolidate.js +589 -1127
- package/dist/commands/improve/content-hash.js +16 -24
- package/dist/commands/improve/distill/content-repair.js +18 -100
- package/dist/commands/improve/distill-guards.js +20 -81
- package/dist/commands/improve/distill-promotion-policy.js +23 -243
- package/dist/commands/improve/distill.js +608 -1041
- package/dist/commands/improve/eligibility.js +126 -390
- package/dist/commands/improve/execution.js +8 -10
- package/dist/commands/improve/extract-prompt.js +1 -2
- package/dist/commands/improve/extract.js +487 -1046
- package/dist/commands/improve/feedback-valence.js +0 -25
- package/dist/commands/improve/improve-cli.js +75 -169
- package/dist/commands/improve/improve-result-file.js +10 -66
- package/dist/commands/improve/improve-strategies.js +52 -4
- package/dist/commands/improve/improve-usage-report.js +18 -64
- package/dist/commands/improve/improve.js +480 -1074
- package/dist/commands/improve/ledger.js +119 -0
- package/dist/commands/improve/locks.js +2 -8
- package/dist/commands/improve/loop-stages.js +415 -1073
- package/dist/commands/improve/memory/derived-ref.js +12 -77
- package/dist/commands/improve/memory/memory-belief.js +16 -118
- package/dist/commands/improve/memory/memory-improve.js +266 -14
- package/dist/commands/improve/outcome-loop.js +28 -156
- package/dist/commands/improve/planner.js +5 -15
- package/dist/commands/improve/preparation.js +779 -2319
- package/dist/commands/improve/proactive-maintenance.js +34 -101
- package/dist/commands/improve/reflect-noise.js +104 -280
- package/dist/commands/improve/reflect.js +642 -1353
- package/dist/commands/improve/retrieval-gate.js +127 -0
- package/dist/commands/improve/retrieval-scope.js +92 -0
- package/dist/commands/improve/salience.js +41 -240
- package/dist/commands/improve/session-asset.js +19 -100
- package/dist/commands/improve/stage.js +322 -0
- package/dist/commands/lint/base-linter.js +37 -15
- package/dist/commands/proposal/drain.js +261 -578
- package/dist/commands/proposal/proposal-cli.js +19 -20
- package/dist/commands/proposal/proposal-types.js +31 -24
- package/dist/commands/proposal/proposal.js +38 -8
- package/dist/commands/proposal/propose.js +134 -160
- package/dist/commands/proposal/repository.js +1097 -1394
- package/dist/commands/proposal/validators/proposal-quality-validators.js +71 -174
- package/dist/commands/proposal/validators/proposal-validators.js +1 -1
- package/dist/commands/proposal/validators/proposals.js +22 -89
- package/dist/commands/read/curate.js +105 -462
- package/dist/commands/read/knowledge.js +3 -2
- package/dist/commands/read/search-cli.js +16 -33
- package/dist/commands/read/search.js +17 -23
- package/dist/commands/read/show.js +57 -108
- package/dist/commands/sources/bundle-cli.js +25 -2
- package/dist/commands/sources/bundle-config-ops.js +4 -0
- package/dist/commands/sources/dangerous-env-audit.js +1 -2
- package/dist/commands/sources/info.js +127 -29
- package/dist/commands/sources/installed-stashes.js +197 -746
- package/dist/commands/sources/schema-repair.js +98 -129
- package/dist/commands/sources/source-add.js +62 -12
- package/dist/commands/sources/source-manage.js +9 -2
- package/dist/commands/sources/stash-cli.js +24 -4
- package/dist/commands/tasks/explain.js +10 -13
- package/dist/commands/tasks/tasks-cli.js +12 -13
- package/dist/commands/tasks/tasks.js +350 -936
- package/dist/commands/tasks/validate.js +26 -24
- package/dist/commands/workflow/plan.js +22 -29
- package/dist/commands/workflow-cli.js +4 -4
- package/dist/core/adapter/adapters/akm-adapter.js +2 -1
- package/dist/core/adapter/adapters/akm-lint.js +2 -3
- package/dist/core/adapter/adapters/akm-metadata.js +42 -12
- package/dist/core/adapter/adapters/akm-task-adapter.js +29 -8
- package/dist/core/adapter/adapters/akm-workflow-adapter.js +1 -1
- package/dist/core/adapter/execution-source.js +17 -29
- package/dist/core/asset/asset-placement.js +4 -13
- package/dist/core/asset/frontmatter.js +106 -1
- package/dist/core/asset/resolve-ref.js +1 -1
- package/dist/core/bundle-id.js +42 -5
- package/dist/core/bundle-rename.js +285 -0
- package/dist/core/config/config-io.js +1 -2
- package/dist/core/config/config-schema.js +9 -34
- package/dist/core/config/config-walker.js +1 -1
- package/dist/core/config/config.js +184 -111
- package/dist/core/config/engine-semantics.js +0 -2
- package/dist/core/config/legacy-source-shape-shim.js +38 -9
- package/dist/core/config/schema/embedding.js +20 -5
- package/dist/core/config/schema/engines.js +5 -0
- package/dist/core/config/schema/execution.js +1 -1
- package/dist/core/config/schema/experimental.js +1 -1
- package/dist/core/config/schema/improve-processes.js +54 -125
- package/dist/core/config/schema/improve.js +4 -42
- package/dist/core/config/schema/index-config.js +9 -48
- package/dist/core/config/schema/scheduler.js +12 -12
- package/dist/core/config/schema/search.js +6 -22
- package/dist/core/env-secret-ref.js +0 -1
- package/dist/core/errors.js +8 -9
- package/dist/core/file-change.js +13 -5
- package/dist/core/file-lock.js +76 -173
- package/dist/core/improve-result.js +35 -7
- package/dist/core/improve-types.js +0 -1
- package/dist/core/logs-db.js +2 -2
- package/dist/core/loopback.js +7 -12
- package/dist/core/non-task-input.js +20 -0
- package/dist/core/parse.js +13 -16
- package/dist/core/paths.js +0 -24
- package/dist/core/redaction.js +109 -2
- package/dist/core/run-lock.js +2 -5
- package/dist/core/spawn-env.js +1 -1
- package/dist/core/state/migrations.js +123 -61
- package/dist/core/state-db-scope.js +2 -4
- package/dist/core/state-db.js +126 -692
- package/dist/core/time.js +0 -20
- package/dist/core/type-presentation.js +1 -9
- package/dist/core/write-source.js +294 -1005
- package/dist/execution/input-contract.js +1 -1
- package/dist/execution/resolved-request.js +135 -689
- package/dist/execution/source.js +63 -257
- package/dist/execution/target-ref.js +1 -1
- package/dist/indexer/bundle-identity-guard.js +2 -2
- package/dist/indexer/db/llm-cache.js +2 -2
- package/dist/indexer/ensure-index.js +77 -73
- package/dist/indexer/index-rebuild-lock.js +3 -11
- package/dist/indexer/index-writer-lock.js +8 -17
- package/dist/indexer/index-written-assets.js +141 -154
- package/dist/indexer/indexer.js +400 -1124
- package/dist/indexer/links/declared-links.js +90 -0
- package/dist/indexer/materialize-embeddings.js +60 -397
- package/dist/indexer/passes/memory-inference.js +96 -90
- package/dist/indexer/passes/metadata.js +132 -219
- package/dist/indexer/read-preflight.js +0 -7
- package/dist/indexer/scan/doc-to-entry.js +2 -3
- package/dist/indexer/scan/drain-dir.js +1 -1
- package/dist/indexer/search/db-search.js +190 -590
- package/dist/indexer/search/fts-query.js +30 -41
- package/dist/indexer/search/ranking.js +28 -154
- package/dist/indexer/search/search-attribution.js +12 -32
- package/dist/indexer/search/search-fields.js +11 -15
- package/dist/indexer/search/search-hit-enrichers.js +54 -85
- package/dist/indexer/search/search-source.js +1 -4
- package/dist/indexer/usage/usage-events.js +36 -7
- package/dist/indexer/walk/walker.js +3 -4
- package/dist/integrations/agent/engine-fallback.js +23 -40
- package/dist/integrations/agent/engine-resolution.js +93 -183
- package/dist/integrations/agent/execution.js +507 -0
- package/dist/integrations/agent/model-map.js +28 -156
- package/dist/integrations/agent/request-lowering.js +66 -141
- package/dist/integrations/agent/runner-dispatch.js +143 -321
- package/dist/integrations/agent/runner.js +54 -14
- package/dist/integrations/lockfile.js +53 -101
- package/dist/llm/client.js +18 -6
- package/dist/llm/embedders/deterministic.js +2 -3
- package/dist/llm/embedders/profile.js +71 -0
- package/dist/llm/embedders/remote.js +11 -17
- package/dist/llm/feature-gate.js +0 -8
- package/dist/llm/index-passes.js +3 -5
- package/dist/llm/memory-infer.js +1 -2
- package/dist/llm/structured-call.js +5 -24
- package/dist/output/generic-render.js +23 -11
- package/dist/output/html-render.js +13 -10
- package/dist/output/render-registry.js +3 -32
- package/dist/output/shapes/helpers.js +25 -38
- package/dist/output/shapes/passthrough.js +1 -9
- package/dist/{indexer/graph/graph-types.js → output/text/bundle-rename.js} +4 -1
- package/dist/output/text/command-format.js +69 -31
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/migrate.js +5 -14
- package/dist/output/text/proposal-format.js +48 -3
- package/dist/output/text/show-format.js +13 -17
- package/dist/output/text/workflow-format.js +0 -32
- package/dist/output/text.js +2 -0
- package/dist/registry/factory.js +4 -19
- package/dist/registry/network.js +66 -220
- package/dist/registry/providers/index.js +0 -2
- package/dist/registry/providers/skills-sh.js +3 -14
- package/dist/registry/providers/static-index.js +24 -26
- package/dist/registry/resolve.js +55 -131
- package/dist/scripts/akm-migrate-node.js +42948 -92369
- package/dist/scripts/akm-migrate.js +42935 -92354
- package/dist/setup/registry-stash-loader.js +4 -13
- package/dist/setup/semantic-assets.js +3 -44
- package/dist/setup/setup.js +1 -1
- package/dist/setup/steps/connection.js +5 -6
- package/dist/setup/steps/platforms.js +2 -2
- package/dist/setup/steps/tasks.js +25 -15
- package/dist/sources/provider-factory.js +17 -18
- package/dist/sources/providers/filesystem.js +2 -3
- package/dist/sources/providers/git-install.js +7 -1
- package/dist/sources/providers/git-provider.js +0 -3
- package/dist/sources/providers/git-stash.js +83 -21
- package/dist/sources/providers/npm.js +2 -4
- package/dist/sources/providers/provider-utils.js +5 -10
- package/dist/sources/providers/website.js +0 -2
- package/dist/sources/snapshot-fetchers/website-ingest.js +1 -1
- package/dist/sources/website-url.js +2 -2
- package/dist/storage/database.js +9 -35
- package/dist/storage/repositories/improve-ledger-repository.js +209 -0
- package/dist/storage/repositories/index-connection.js +39 -72
- package/dist/storage/repositories/index-entries-repository.js +131 -129
- package/dist/storage/repositories/index-entry-mapper.js +1 -2
- package/dist/storage/repositories/index-entry-schema.js +101 -268
- package/dist/storage/repositories/index-fts-repository.js +86 -256
- package/dist/storage/repositories/index-links-repository.js +143 -0
- package/dist/storage/repositories/index-llm-cache-repository.js +7 -9
- package/dist/storage/repositories/index-meta-repository.js +6 -4
- package/dist/storage/repositories/index-schema.js +257 -325
- package/dist/storage/repositories/index-utility-repository.js +8 -29
- package/dist/storage/repositories/index-vec-repository.js +133 -414
- package/dist/storage/repositories/outcome-repository.js +2 -1
- package/dist/storage/repositories/proposals-repository.js +104 -1
- package/dist/storage/repositories/registry-index-cache-repository.js +100 -0
- package/dist/storage/repositories/salience-repository.js +1 -19
- package/dist/storage/repositories/task-history-repository.js +26 -4
- package/dist/storage/repositories/workflow-runs-repository.js +53 -244
- package/dist/storage/sqlite-migrations.js +136 -0
- package/dist/storage/sqlite-pragmas.js +11 -9
- package/dist/storage/sqlite-transaction.js +170 -0
- package/dist/storage/state-db-integrity.js +130 -0
- package/dist/tasks/activation-config.js +134 -62
- package/dist/tasks/backends/cron.js +191 -302
- package/dist/tasks/backends/exec-utils.js +2 -5
- package/dist/tasks/backends/launchd.js +141 -748
- package/dist/tasks/backends/schtasks.js +119 -623
- package/dist/tasks/prepare/prepare-support.js +5 -15
- package/dist/tasks/prepare/prepare.js +0 -2
- package/dist/tasks/resolve-akm-bin.js +20 -79
- package/dist/tasks/run/attempt-lifecycle.js +0 -1
- package/dist/tasks/run/load-task.js +1 -1
- package/dist/tasks/scheduler-binding.js +20 -238
- package/dist/tasks/scheduler-invocation.js +136 -244
- package/dist/tasks/scheduler-lock.js +53 -0
- package/dist/tasks/scheduler-sync.js +368 -679
- package/dist/tasks/source/parse-task-source.js +55 -9
- package/dist/tasks/source/task-source-v3-frozen.js +3 -4
- package/dist/tasks/source/task-to-v4.js +464 -88
- package/dist/workflows/authoring/authoring.js +3 -12
- package/dist/workflows/compile.js +211 -0
- package/dist/workflows/concurrency-policy.js +13 -74
- package/dist/workflows/exec/child-invocation.js +3 -17
- package/dist/workflows/exec/child-workflow.js +32 -141
- package/dist/workflows/exec/dispatch-redaction.js +13 -53
- package/dist/workflows/exec/environment.js +98 -0
- package/dist/workflows/exec/exec-unit.js +33 -140
- package/dist/workflows/exec/frozen-judge.js +7 -59
- package/dist/workflows/exec/native-executor.js +82 -341
- package/dist/workflows/exec/param-secrets.js +29 -47
- package/dist/workflows/exec/run-workflow.js +154 -387
- package/dist/workflows/exec/scheduler.js +9 -36
- package/dist/workflows/exec/step-work.js +127 -430
- package/dist/workflows/exec/unit-dispatch.js +11 -63
- package/dist/workflows/exec/unit-writer.js +8 -52
- package/dist/workflows/exec/worktree.js +39 -273
- package/dist/workflows/freeze/child-output-references.js +4 -15
- package/dist/workflows/freeze/environment.js +99 -92
- package/dist/workflows/freeze/freeze.js +172 -0
- package/dist/workflows/freeze/step-values.js +19 -21
- package/dist/workflows/freeze/targets/child-workflow.js +23 -92
- package/dist/workflows/freeze/targets/command.js +10 -33
- package/dist/workflows/freeze/targets/script.js +5 -12
- package/dist/workflows/freeze/targets/shell.js +3 -6
- package/dist/workflows/freeze/targets/task.js +25 -80
- package/dist/workflows/freeze/task-bindings.js +20 -67
- package/dist/workflows/{source-ir/github-yaml.js → github-yaml.js} +88 -206
- package/dist/workflows/ir/params.js +6 -51
- package/dist/workflows/ir/plan-hash.js +2 -34
- package/dist/workflows/parser.js +140 -43
- package/dist/{commands/improve/consolidate/types.js → workflows/plan.js} +2 -1
- package/dist/workflows/renderer.js +36 -69
- package/dist/workflows/resource-limits.js +12 -120
- package/dist/workflows/runtime/agent-identity.js +8 -40
- package/dist/workflows/runtime/run-outputs.js +3 -6
- package/dist/workflows/runtime/run-plan.js +316 -0
- package/dist/workflows/runtime/runs.js +48 -200
- package/dist/workflows/runtime/workflow-asset-loader.js +24 -57
- package/dist/workflows/{source-ir/semantics.js → source-semantics.js} +16 -20
- package/dist/workflows/validate-summary.js +2 -7
- package/docs/integration/bundling-akm.md +49 -42
- package/docs/migration/README.md +1 -0
- package/docs/migration/release-notes/0.9.17.md +43 -0
- package/docs/migration/v0.9.1-to-v0.9.2.md +23 -7
- package/docs/reference/cli.md +232 -135
- package/docs/reference/configuration.md +71 -57
- package/docs/reference/data-and-telemetry.md +20 -21
- package/docs/reference/tasks.md +105 -39
- package/docs/reference/workflow-schema.md +14 -18
- package/docs/reference/workflows.md +6 -9
- package/package.json +1 -1
- package/schemas/akm-config.json +115 -738
- package/schemas/akm-workflow.json +1 -0
- package/dist/assets/improve-strategies/graph-refresh.json +0 -15
- package/dist/assets/prompts/contradiction-judge.md +0 -33
- package/dist/assets/prompts/graph-extract-system.md +0 -1
- package/dist/assets/prompts/graph-extract-user-prompt.md +0 -35
- package/dist/assets/prompts/metadata-enhance-system.md +0 -1
- package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +0 -4
- package/dist/commands/health/advisories.js +0 -150
- package/dist/commands/health/metrics.js +0 -329
- package/dist/commands/health/surfaces.js +0 -102
- package/dist/commands/improve/anti-collapse.js +0 -83
- package/dist/commands/improve/collapse-detector.js +0 -432
- package/dist/commands/improve/consolidate/eligibility.js +0 -48
- package/dist/commands/improve/consolidate/merge.js +0 -149
- package/dist/commands/improve/distill/promote-memory.js +0 -291
- package/dist/commands/improve/distill/quality-gate.js +0 -337
- package/dist/commands/improve/eval-cases.js +0 -52
- package/dist/commands/improve/memory/memory-contradiction-detect.js +0 -291
- package/dist/commands/improve/proposal-envelope.js +0 -31
- package/dist/commands/improve/run-context.js +0 -123
- package/dist/commands/improve/shared.js +0 -31
- package/dist/commands/improve/source-identity.js +0 -28
- package/dist/commands/improve/triage.js +0 -96
- package/dist/commands/proposal/drain-policies.js +0 -151
- package/dist/commands/sources/update-transaction.js +0 -220
- package/dist/core/action-contributors.js +0 -28
- package/dist/core/config/config-version-shim.js +0 -101
- package/dist/core/fs-txn.js +0 -405
- package/dist/core/lexical-score.js +0 -25
- package/dist/core/maintenance-barrier.js +0 -167
- package/dist/execution/executable-identity.js +0 -105
- package/dist/execution/guarded-source.js +0 -427
- package/dist/indexer/db/graph-db.js +0 -444
- package/dist/indexer/graph/graph-boost.js +0 -427
- package/dist/indexer/graph/graph-dedup.js +0 -95
- package/dist/indexer/graph/graph-extraction.js +0 -1108
- package/dist/indexer/search/name-match.js +0 -35
- package/dist/indexer/search/ranking-contributors.js +0 -515
- package/dist/indexer/search/ranking-types.js +0 -4
- package/dist/indexer/walk/project-context.js +0 -192
- package/dist/integrations/agent/execution-cascade.js +0 -566
- package/dist/integrations/agent/execution-definitions.js +0 -202
- package/dist/integrations/agent/execution-lowering.js +0 -841
- package/dist/integrations/agent/execution-preparation.js +0 -98
- package/dist/integrations/agent/inline-execution.js +0 -74
- package/dist/llm/graph-extract.js +0 -728
- package/dist/llm/metadata-enhance.js +0 -96
- package/dist/registry/create-provider-registry.js +0 -29
- package/dist/registry/pinned-request-helper.js +0 -247
- package/dist/registry/pinned-transport.js +0 -717
- package/dist/sources/providers/index.js +0 -14
- package/dist/storage/engines/sqlite-migrations.js +0 -271
- package/dist/storage/repositories/canaries-repository.js +0 -107
- package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
- package/dist/storage/repositories/registry-cache.js +0 -113
- package/dist/tasks/scheduler-sync-preview.js +0 -52
- package/dist/tasks/source/task-to-v3.js +0 -507
- package/dist/workflows/freeze/resolve-steps.js +0 -86
- package/dist/workflows/freeze/source-freeze.js +0 -64
- package/dist/workflows/ir/compile.js +0 -321
- package/dist/workflows/ir/environment-v4.js +0 -330
- package/dist/workflows/ir/freeze-v4.js +0 -153
- package/dist/workflows/ir/schema-v4.js +0 -745
- package/dist/workflows/ir/schema.js +0 -354
- package/dist/workflows/program/schema.js +0 -77
- package/dist/workflows/runtime/checkin.js +0 -57
- package/dist/workflows/runtime/plan-classifier.js +0 -196
- package/dist/workflows/runtime/unit-checkin.js +0 -45
- package/dist/workflows/runtime/unit-phases.js +0 -20
- package/dist/workflows/schema.js +0 -4
- package/dist/workflows/source-ir/compile.js +0 -200
- package/dist/workflows/source-ir/program.js +0 -50
- package/dist/workflows/source-ir/result.js +0 -26
- package/dist/workflows/source-ir/schema.js +0 -786
- package/dist/workflows/source-ir/triggers.js +0 -79
- package/dist/workflows/source-ir/uses.js +0 -40
- package/dist/workflows/validator.js +0 -60
|
@@ -11,12 +11,15 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Platform notes:
|
|
13
13
|
* • The `bootstrap` / `bootout` / `enable` / `disable` subcommands require
|
|
14
|
-
* macOS 10.10 (Yosemite) or newer.
|
|
15
|
-
* are `launchctl load -w` / `unload -w`. We only target modern macOS.
|
|
14
|
+
* macOS 10.10 (Yosemite) or newer. We only target modern macOS.
|
|
16
15
|
* • `gui/<uid>` is the per-user GUI launchd domain — agents in this
|
|
17
|
-
* domain only run while the user is logged in
|
|
18
|
-
* the
|
|
19
|
-
*
|
|
16
|
+
* domain only run while the user is logged in. Tasks that need to run
|
|
17
|
+
* when the user is logged out should be installed as system Daemons,
|
|
18
|
+
* which is out of scope.
|
|
19
|
+
* • launchd strips the environment; the syncing shell's PATH goes into the
|
|
20
|
+
* plist's `EnvironmentVariables` so task bodies find the same binaries,
|
|
21
|
+
* next to the AKM directory environment the row sets (see
|
|
22
|
+
* `src/tasks/scheduler-invocation.ts`).
|
|
20
23
|
*
|
|
21
24
|
* Tests inject a fake exec + filesystem so the backend can be unit-tested
|
|
22
25
|
* without touching the host launchctl.
|
|
@@ -31,713 +34,133 @@ import { getTaskLogDir } from "../../core/paths.js";
|
|
|
31
34
|
import { warn } from "../../core/warn.js";
|
|
32
35
|
import { resolveAkmInvocation } from "../resolve-akm-bin.js";
|
|
33
36
|
import { parseSchedule, translateToLaunchd } from "../schedule.js";
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
37
|
+
import { schedulerBindingNativeId, schedulerLogicalBindingId } from "../scheduler-binding.js";
|
|
38
|
+
import { buildScheduledInvocation, parseScheduledInvocationArgv, scheduledRowEnvironmentEntries, scheduledRowEnvironmentFrom, } from "../scheduler-invocation.js";
|
|
36
39
|
import { escapeXml, nodeExec, nodeFs, runOrThrow } from "./exec-utils.js";
|
|
37
40
|
export const LAUNCHD_LABEL_PREFIX = "com.akm.task.";
|
|
38
|
-
const MAX_LAUNCHD_DOMAIN_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
39
|
-
const MAX_LAUNCHD_AKM_NAMESPACE_ENTRIES = 4096;
|
|
40
41
|
const LAUNCHD_AKM_LABEL_RE = /^com\.akm\.task\.[A-Za-z0-9._-]{1,1024}$/u;
|
|
41
|
-
const LAUNCHD_SNAPSHOT = Symbol("akm-launchd-binding-snapshot");
|
|
42
42
|
export function LAUNCHD_BACKEND(options = {}) {
|
|
43
43
|
const exec = options.exec ?? defaultLaunchdExec();
|
|
44
44
|
const fsLike = options.fs ?? defaultLaunchdFs();
|
|
45
45
|
const agentsDir = options.agentsDir ?? defaultAgentsDir();
|
|
46
46
|
const logDir = options.logDir ?? getTaskLogDir();
|
|
47
47
|
const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv;
|
|
48
|
-
const
|
|
48
|
+
const envPath = options.envPath === false ? undefined : typeof options.envPath === "string" ? options.envPath : process.env.PATH;
|
|
49
49
|
const plistPath = (nativeId) => path.join(agentsDir, `${LAUNCHD_LABEL_PREFIX}${nativeId}.plist`);
|
|
50
50
|
const label = (nativeId) => `${LAUNCHD_LABEL_PREFIX}${nativeId}`;
|
|
51
51
|
const target = (nativeId) => `gui/${exec.uid()}/${label(nativeId)}`;
|
|
52
|
-
const
|
|
53
|
-
|
|
52
|
+
const setEnableState = (nativeId, enabled) => {
|
|
53
|
+
const verb = enabled ? "enable" : "disable";
|
|
54
|
+
runOrThrow(exec, ["launchctl", verb, target(nativeId)], {
|
|
55
|
+
message: (result) => `launchctl ${verb} failed: ${result.stderr || result.stdout || "no output"}.`,
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
const bootout = (nativeId) => runOrThrow(exec, ["launchctl", "bootout", target(nativeId)], {
|
|
59
|
+
isOk: (r) => r.status === 0 || isServiceNotFoundResult(r),
|
|
60
|
+
message: (r) => `launchctl bootout failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`,
|
|
61
|
+
});
|
|
62
|
+
const xmlFor = (task, opts) => buildPlistXml(task, [...(opts?.binding ?? akmArgv)], logDir, opts?.environment, envPath);
|
|
54
63
|
return {
|
|
55
64
|
name: "launchd",
|
|
56
|
-
install(task, opts
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
target,
|
|
67
|
-
setEnableState,
|
|
68
|
-
});
|
|
69
|
-
},
|
|
70
|
-
uninstall(nativeId, expected) {
|
|
71
|
-
const inspectionContext = { exec, fsLike, agentsDir, plistPath, target, label };
|
|
72
|
-
const initialNamespace = inspectStableLaunchdNamespace([nativeId], inspectionContext);
|
|
73
|
-
let initialArtifact;
|
|
74
|
-
if (expected) {
|
|
75
|
-
assertSchedulerExpectationIdentity({ ...expected, state: "present" });
|
|
76
|
-
initialArtifact = assertSchedulerNativeArtifactCardinality(initialNamespace.inspection.artifacts, nativeId, 1);
|
|
77
|
-
if (!initialArtifact) {
|
|
78
|
-
throw new ConfigError(`launchd artifact ${JSON.stringify(nativeId)} disappeared during inspection.`, "INVALID_CONFIG_FILE");
|
|
79
|
-
}
|
|
80
|
-
assertSchedulerRemovalArtifact(initialArtifact.nativeId, expected, initialArtifact.invocation, initialArtifact.fingerprint);
|
|
81
|
-
}
|
|
82
|
-
else {
|
|
83
|
-
initialArtifact = assertLaunchdDirectRemovalOwner(initialNamespace.inspection.artifacts, nativeId);
|
|
84
|
-
}
|
|
85
|
-
let resolvedNativeId = initialArtifact?.nativeId ?? nativeId;
|
|
86
|
-
const file = plistPath(resolvedNativeId);
|
|
87
|
-
const snapshot = snapshotLaunchdBindings([resolvedNativeId], {
|
|
88
|
-
exec,
|
|
89
|
-
fsLike,
|
|
90
|
-
agentsDir,
|
|
91
|
-
plistPath,
|
|
92
|
-
target,
|
|
93
|
-
label,
|
|
94
|
-
});
|
|
95
|
-
const finalArtifact = expected
|
|
96
|
-
? assertSchedulerNativeArtifactCardinality(snapshot.artifacts, nativeId, 1)
|
|
97
|
-
: assertLaunchdDirectRemovalOwner(snapshot.artifacts, nativeId);
|
|
98
|
-
if (expected) {
|
|
99
|
-
if (!finalArtifact) {
|
|
100
|
-
throw new ConfigError(`launchd artifact ${JSON.stringify(nativeId)} disappeared during snapshot.`, "INVALID_CONFIG_FILE");
|
|
101
|
-
}
|
|
102
|
-
assertSchedulerRemovalArtifact(finalArtifact.nativeId, expected, finalArtifact.invocation, finalArtifact.fingerprint);
|
|
103
|
-
}
|
|
104
|
-
if (!finalArtifact)
|
|
105
|
-
return;
|
|
106
|
-
resolvedNativeId = finalArtifact.nativeId;
|
|
65
|
+
install(task, opts) {
|
|
66
|
+
const xml = xmlFor(task, opts);
|
|
67
|
+
const nativeId = schedulerBindingNativeId(task);
|
|
68
|
+
const file = plistPath(nativeId);
|
|
69
|
+
fsLike.ensureDir(agentsDir);
|
|
70
|
+
// launchd refuses to start a job whose StandardOutPath directory is
|
|
71
|
+
// missing; create it before touching launchd state.
|
|
72
|
+
fsLike.ensureDir(logDir);
|
|
73
|
+
const tempFile = path.join(agentsDir, `.${nativeId}.${Date.now()}.tmp`);
|
|
74
|
+
fsLike.writeFile(tempFile, xml);
|
|
107
75
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
76
|
+
bootout(nativeId);
|
|
77
|
+
fsLike.replaceFile(tempFile, file);
|
|
78
|
+
// A disable override survives bootout and plist replacement. Clear it
|
|
79
|
+
// before bootstrap, then apply the desired state after registration.
|
|
80
|
+
setEnableState(nativeId, true);
|
|
81
|
+
runOrThrow(exec, ["launchctl", "bootstrap", `gui/${exec.uid()}`, file], {
|
|
82
|
+
message: (r) => `launchctl bootstrap failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`,
|
|
83
|
+
hint: "Ensure `launchctl` is available; on macOS it is part of the base system.",
|
|
111
84
|
});
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (fsLike.exists(file))
|
|
115
|
-
fsLike.removeFile(file);
|
|
85
|
+
if (!task.enabled)
|
|
86
|
+
setEnableState(nativeId, false);
|
|
116
87
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
launchdRollbackExpectationForCurrent(resolvedNativeId, inspectionContext, initialArtifact),
|
|
121
|
-
];
|
|
122
|
-
restoreLaunchdBindings(snapshot, { exec, fsLike, agentsDir, plistPath, target, label, setEnableState }, rollbackExpected);
|
|
123
|
-
}
|
|
124
|
-
catch (rollbackError) {
|
|
125
|
-
throw new AggregateError([primaryError, rollbackError], `${errorMessage(primaryError)}; rollback for launchd removal ${JSON.stringify(nativeId)} was incomplete.`);
|
|
126
|
-
}
|
|
127
|
-
throw primaryError;
|
|
88
|
+
finally {
|
|
89
|
+
if (fsLike.exists(tempFile))
|
|
90
|
+
fsLike.removeFile(tempFile);
|
|
128
91
|
}
|
|
129
92
|
},
|
|
93
|
+
uninstall(nativeId) {
|
|
94
|
+
bootout(nativeId);
|
|
95
|
+
// launchctl disable overrides persist after the plist is removed.
|
|
96
|
+
setEnableState(nativeId, true);
|
|
97
|
+
const file = plistPath(nativeId);
|
|
98
|
+
if (fsLike.exists(file))
|
|
99
|
+
fsLike.removeFile(file);
|
|
100
|
+
},
|
|
130
101
|
setEnabled(nativeId, enabled) {
|
|
131
102
|
setEnableState(nativeId, enabled);
|
|
132
103
|
},
|
|
133
104
|
list() {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
105
|
+
const domain = exec.run(["launchctl", "print", `gui/${exec.uid()}`]);
|
|
106
|
+
if (domain.status !== 0) {
|
|
107
|
+
throw new ConfigError(`launchctl failed to enumerate the loaded user domain: ${domain.stderr || domain.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
|
|
108
|
+
}
|
|
109
|
+
const loadedLabels = parseLaunchdLoadedLabels(domain.stdout);
|
|
110
|
+
const disabledLabels = readDisabledLabels(exec);
|
|
111
|
+
const rows = [];
|
|
112
|
+
if (!fsLike.exists(agentsDir))
|
|
113
|
+
return rows;
|
|
114
|
+
for (const file of fsLike.list(agentsDir).sort()) {
|
|
115
|
+
if (!file.startsWith(LAUNCHD_LABEL_PREFIX) || !file.endsWith(".plist"))
|
|
116
|
+
continue;
|
|
117
|
+
const nativeId = file.slice(LAUNCHD_LABEL_PREFIX.length, -".plist".length);
|
|
118
|
+
const raw = fsLike.readFile(plistPath(nativeId));
|
|
119
|
+
const parsed = extractPlistInvocation(raw);
|
|
120
|
+
if (!parsed)
|
|
143
121
|
continue;
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
id:
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
122
|
+
const enabled = !disabledLabels.has(label(nativeId));
|
|
123
|
+
const loaded = loadedLabels.has(label(nativeId));
|
|
124
|
+
rows.push({
|
|
125
|
+
id: schedulerLogicalBindingId(nativeId, parsed.invocation),
|
|
126
|
+
nativeId,
|
|
127
|
+
enabled: enabled && loaded,
|
|
128
|
+
// An unloaded service or a disable override is drift: the signature
|
|
129
|
+
// carries both, so sync bootstraps/enables it again.
|
|
130
|
+
signature: launchdFingerprint(raw, enabled, loaded),
|
|
131
|
+
...(parsed.target !== undefined ? { target: parsed.target } : {}),
|
|
132
|
+
binding: parsed.binding,
|
|
133
|
+
...(parsed.contextPath !== undefined ? { contextPath: parsed.contextPath } : {}),
|
|
134
|
+
...(parsed.environment !== undefined ? { environment: parsed.environment } : {}),
|
|
135
|
+
invocation: parsed.invocation,
|
|
136
|
+
});
|
|
154
137
|
}
|
|
155
|
-
return
|
|
156
|
-
},
|
|
157
|
-
listNativeArtifacts() {
|
|
158
|
-
return [...inspectLaunchdState({ exec, fsLike, agentsDir, plistPath, target, label }).artifacts];
|
|
159
|
-
},
|
|
160
|
-
inspectBindings() {
|
|
161
|
-
return inspectLaunchdState({ exec, fsLike, agentsDir, plistPath, target, label });
|
|
162
|
-
},
|
|
163
|
-
snapshotBindings(ids) {
|
|
164
|
-
return snapshotLaunchdBindings(ids, { exec, fsLike, agentsDir, plistPath, target, label });
|
|
165
|
-
},
|
|
166
|
-
restoreBindings(snapshot, expectedCurrent) {
|
|
167
|
-
restoreLaunchdBindings(snapshot, { exec, fsLike, agentsDir, plistPath, target, label, setEnableState }, expectedCurrent);
|
|
138
|
+
return rows;
|
|
168
139
|
},
|
|
169
140
|
expectedSignature(task, opts) {
|
|
170
|
-
return launchdFingerprint(
|
|
141
|
+
return launchdFingerprint(xmlFor(task, opts), task.enabled, true);
|
|
171
142
|
},
|
|
172
143
|
};
|
|
173
144
|
}
|
|
174
|
-
function installLaunchdBinding(task, opts, expected, context) {
|
|
175
|
-
const { exec, fsLike, agentsDir, logDir, akmArgv, defaultContextPath, plistPath, label, target, setEnableState } = context;
|
|
176
|
-
if (expected)
|
|
177
|
-
assertSchedulerExpectationIdentity(expected, task);
|
|
178
|
-
// Capture PATH at install time so launchd (which strips the environment
|
|
179
|
-
// aggressively) can find the same binaries the user sees interactively.
|
|
180
|
-
const xml = buildPlistXml(task, [...(opts?.binding ?? akmArgv)], logDir, opts?.contextPath ?? defaultContextPath, opts?.target);
|
|
181
|
-
const nativeId = schedulerBindingNativeId(task);
|
|
182
|
-
const file = plistPath(nativeId);
|
|
183
|
-
fsLike.ensureDir(agentsDir);
|
|
184
|
-
// launchd refuses to start a job when StandardOutPath/StandardErrorPath
|
|
185
|
-
// points at a non-existent directory; create it before reading or mutating
|
|
186
|
-
// launchd state so a local preparation failure has no scheduler side effect.
|
|
187
|
-
fsLike.ensureDir(logDir);
|
|
188
|
-
const inspectionContext = { exec, fsLike, agentsDir, plistPath, target, label };
|
|
189
|
-
const initialNamespace = inspectStableLaunchdNamespace([nativeId], inspectionContext);
|
|
190
|
-
const initialArtifact = expected
|
|
191
|
-
? assertSchedulerNativeArtifactCardinality(initialNamespace.inspection.artifacts, nativeId, expected.state === "absent" ? 0 : 1)
|
|
192
|
-
: assertLaunchdDirectMutationOwner(initialNamespace.inspection.artifacts, nativeId, task);
|
|
193
|
-
if (expected)
|
|
194
|
-
assertSchedulerMutationArtifact(initialArtifact, expected);
|
|
195
|
-
const priorEntry = initialArtifact
|
|
196
|
-
? initialNamespace.entries.find((entry) => entry.nativeId === initialArtifact.nativeId)
|
|
197
|
-
: undefined;
|
|
198
|
-
const previousPlist = priorEntry?.plist;
|
|
199
|
-
const previousEnabled = priorEntry?.enabled ?? true;
|
|
200
|
-
const tempFile = path.join(agentsDir, `.${nativeId}.${Date.now()}.tmp`);
|
|
201
|
-
fsLike.writeFile(tempFile, xml);
|
|
202
|
-
let bootoutCompleted = false;
|
|
203
|
-
let previousWasLoaded = false;
|
|
204
|
-
let fileReplaced = false;
|
|
205
|
-
let enableStateTouched = false;
|
|
206
|
-
try {
|
|
207
|
-
if (expected) {
|
|
208
|
-
const finalInspection = inspectStableLaunchdNamespace([nativeId], inspectionContext).inspection;
|
|
209
|
-
const finalArtifact = assertSchedulerNativeArtifactCardinality(finalInspection.artifacts, nativeId, expected.state === "absent" ? 0 : 1);
|
|
210
|
-
assertSchedulerMutationArtifact(finalArtifact, expected);
|
|
211
|
-
}
|
|
212
|
-
else {
|
|
213
|
-
const finalArtifact = assertLaunchdDirectMutationOwner(inspectStableLaunchdNamespace([nativeId], inspectionContext).inspection.artifacts, nativeId, task);
|
|
214
|
-
if (finalArtifact?.nativeId !== initialArtifact?.nativeId ||
|
|
215
|
-
finalArtifact?.fingerprint !== initialArtifact?.fingerprint) {
|
|
216
|
-
throw new ConfigError(`launchd task ${JSON.stringify(nativeId)} changed while it was being prepared; refusing to replace an unverified owner.`, "INVALID_CONFIG_FILE");
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
const bootout = runOrThrow(exec, ["launchctl", "bootout", target(nativeId)], {
|
|
220
|
-
isOk: (r) => r.status === 0 || isServiceNotFoundResult(r),
|
|
221
|
-
message: (r) => `launchctl bootout failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`,
|
|
222
|
-
});
|
|
223
|
-
bootoutCompleted = true;
|
|
224
|
-
previousWasLoaded = previousPlist !== undefined && bootout.status === 0;
|
|
225
|
-
fsLike.replaceFile(tempFile, file);
|
|
226
|
-
fileReplaced = true;
|
|
227
|
-
// A disable override survives bootout and plist replacement. Clear it
|
|
228
|
-
// before bootstrap, then apply the desired state after registration.
|
|
229
|
-
enableStateTouched = true;
|
|
230
|
-
setEnableState(nativeId, true);
|
|
231
|
-
runOrThrow(exec, ["launchctl", "bootstrap", `gui/${exec.uid()}`, file], {
|
|
232
|
-
message: (r) => `launchctl bootstrap failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`,
|
|
233
|
-
hint: "Ensure `launchctl` is available; on macOS it is part of the base system.",
|
|
234
|
-
});
|
|
235
|
-
if (!task.enabled)
|
|
236
|
-
setEnableState(nativeId, false);
|
|
237
|
-
}
|
|
238
|
-
catch (err) {
|
|
239
|
-
if (!bootoutCompleted)
|
|
240
|
-
throw err;
|
|
241
|
-
const rollbackErrors = [];
|
|
242
|
-
let priorFileRestored = !fileReplaced;
|
|
243
|
-
if (fileReplaced) {
|
|
244
|
-
let replacementUnloaded = false;
|
|
245
|
-
try {
|
|
246
|
-
const rollbackBootout = exec.run(["launchctl", "bootout", target(nativeId)]);
|
|
247
|
-
replacementUnloaded = rollbackBootout.status === 0 || isServiceNotFoundResult(rollbackBootout);
|
|
248
|
-
if (!replacementUnloaded) {
|
|
249
|
-
rollbackErrors.push(new ConfigError(`launchctl bootout during rollback failed: ${rollbackBootout.stderr || rollbackBootout.stdout || "no output"}.`, "INVALID_CONFIG_FILE"));
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
catch (rollbackError) {
|
|
253
|
-
rollbackErrors.push(rollbackError);
|
|
254
|
-
}
|
|
255
|
-
if (replacementUnloaded) {
|
|
256
|
-
try {
|
|
257
|
-
if (previousPlist === undefined) {
|
|
258
|
-
if (fsLike.exists(file))
|
|
259
|
-
fsLike.removeFile(file);
|
|
260
|
-
}
|
|
261
|
-
else {
|
|
262
|
-
fsLike.writeFile(tempFile, previousPlist);
|
|
263
|
-
fsLike.replaceFile(tempFile, file);
|
|
264
|
-
}
|
|
265
|
-
priorFileRestored = true;
|
|
266
|
-
}
|
|
267
|
-
catch (rollbackError) {
|
|
268
|
-
rollbackErrors.push(rollbackError);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
restoreLaunchdInstallState({
|
|
273
|
-
exec,
|
|
274
|
-
nativeId,
|
|
275
|
-
file,
|
|
276
|
-
previousPlist,
|
|
277
|
-
previousEnabled,
|
|
278
|
-
previousWasLoaded,
|
|
279
|
-
priorFileRestored,
|
|
280
|
-
enableStateTouched,
|
|
281
|
-
setEnableState,
|
|
282
|
-
rollbackErrors,
|
|
283
|
-
});
|
|
284
|
-
if (rollbackErrors.length > 0) {
|
|
285
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
286
|
-
throw new AggregateError([err, ...rollbackErrors], `${message}; rollback for launchd task "${task.id}" was incomplete.`);
|
|
287
|
-
}
|
|
288
|
-
throw err;
|
|
289
|
-
}
|
|
290
|
-
finally {
|
|
291
|
-
if (fsLike.exists(tempFile))
|
|
292
|
-
fsLike.removeFile(tempFile);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
function restoreLaunchdInstallState(input) {
|
|
296
|
-
const { exec, nativeId, file, previousPlist, previousEnabled, previousWasLoaded, priorFileRestored, enableStateTouched, setEnableState, rollbackErrors, } = input;
|
|
297
|
-
if (previousPlist !== undefined && previousWasLoaded && priorFileRestored) {
|
|
298
|
-
try {
|
|
299
|
-
setEnableState(nativeId, true);
|
|
300
|
-
const restore = exec.run(["launchctl", "bootstrap", `gui/${exec.uid()}`, file]);
|
|
301
|
-
if (restore.status !== 0) {
|
|
302
|
-
rollbackErrors.push(new ConfigError(`launchctl bootstrap during rollback failed: ${restore.stderr || restore.stdout || "no output"}.`, "INVALID_CONFIG_FILE"));
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
catch (rollbackError) {
|
|
306
|
-
rollbackErrors.push(rollbackError);
|
|
307
|
-
}
|
|
308
|
-
if (!previousEnabled) {
|
|
309
|
-
try {
|
|
310
|
-
setEnableState(nativeId, false);
|
|
311
|
-
}
|
|
312
|
-
catch (rollbackError) {
|
|
313
|
-
rollbackErrors.push(rollbackError);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
else if (enableStateTouched) {
|
|
318
|
-
try {
|
|
319
|
-
setEnableState(nativeId, previousPlist === undefined || previousEnabled);
|
|
320
|
-
}
|
|
321
|
-
catch (rollbackError) {
|
|
322
|
-
rollbackErrors.push(rollbackError);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
function snapshotLaunchdBindings(ids, context) {
|
|
327
|
-
const requestedKeys = new Set(ids.map(schedulerNativeArtifactKey));
|
|
328
|
-
const namespace = inspectStableLaunchdNamespace(ids, context);
|
|
329
|
-
const matchingEntries = namespace.entries.filter((entry) => requestedKeys.has(schedulerNativeArtifactKey(entry.nativeId)));
|
|
330
|
-
const matchedKeys = new Set(matchingEntries.map((entry) => schedulerNativeArtifactKey(entry.nativeId)));
|
|
331
|
-
const entries = [
|
|
332
|
-
...matchingEntries.map((entry) => Object.freeze({
|
|
333
|
-
id: entry.nativeId,
|
|
334
|
-
...(entry.plist !== undefined ? { plist: entry.plist } : {}),
|
|
335
|
-
loaded: entry.loaded,
|
|
336
|
-
enabled: entry.enabled,
|
|
337
|
-
})),
|
|
338
|
-
...ids
|
|
339
|
-
.filter((id) => !matchedKeys.has(schedulerNativeArtifactKey(id)))
|
|
340
|
-
.map((id) => Object.freeze({ id, loaded: false, enabled: true })),
|
|
341
|
-
];
|
|
342
|
-
const artifacts = matchingEntries.map((entry) => entry.artifact);
|
|
343
|
-
return Object.freeze({
|
|
344
|
-
kind: LAUNCHD_SNAPSHOT,
|
|
345
|
-
nativeIds: Object.freeze([...ids]),
|
|
346
|
-
artifacts: Object.freeze(artifacts),
|
|
347
|
-
entries: Object.freeze(entries),
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
function restoreLaunchdBindings(snapshot, context, expectedCurrent) {
|
|
351
|
-
if (!isLaunchdBindingSnapshot(snapshot)) {
|
|
352
|
-
throw new ConfigError("Invalid launchd scheduler snapshot.", "INVALID_CONFIG_FILE");
|
|
353
|
-
}
|
|
354
|
-
const preflightErrors = [];
|
|
355
|
-
const duplicateSnapshotKeys = new Set();
|
|
356
|
-
const requestedIdsByKey = new Map();
|
|
357
|
-
for (const nativeId of snapshot.nativeIds) {
|
|
358
|
-
const key = schedulerNativeArtifactKey(nativeId);
|
|
359
|
-
const priorId = requestedIdsByKey.get(key);
|
|
360
|
-
if (priorId !== undefined) {
|
|
361
|
-
duplicateSnapshotKeys.add(key);
|
|
362
|
-
preflightErrors.push(new ConfigError(`Launchd scheduler snapshot requests normalized duplicate artifacts ${JSON.stringify(priorId)} and ${JSON.stringify(nativeId)}; refusing restore.`, "INVALID_CONFIG_FILE"));
|
|
363
|
-
continue;
|
|
364
|
-
}
|
|
365
|
-
requestedIdsByKey.set(key, nativeId);
|
|
366
|
-
}
|
|
367
|
-
const snapshotEntryIdsByKey = new Map();
|
|
368
|
-
for (const entry of snapshot.entries) {
|
|
369
|
-
const key = schedulerNativeArtifactKey(entry.id);
|
|
370
|
-
const priorId = snapshotEntryIdsByKey.get(key);
|
|
371
|
-
if (priorId !== undefined) {
|
|
372
|
-
if (duplicateSnapshotKeys.has(key))
|
|
373
|
-
continue;
|
|
374
|
-
duplicateSnapshotKeys.add(key);
|
|
375
|
-
preflightErrors.push(new ConfigError(`Launchd scheduler snapshot contains normalized duplicate artifacts ${JSON.stringify(priorId)} and ${JSON.stringify(entry.id)}; refusing restore.`, "INVALID_CONFIG_FILE"));
|
|
376
|
-
continue;
|
|
377
|
-
}
|
|
378
|
-
snapshotEntryIdsByKey.set(key, entry.id);
|
|
379
|
-
}
|
|
380
|
-
let rollbackInventory;
|
|
381
|
-
try {
|
|
382
|
-
rollbackInventory = inspectStableLaunchdNamespace(snapshot.nativeIds, context).inspection;
|
|
383
|
-
}
|
|
384
|
-
catch (error) {
|
|
385
|
-
preflightErrors.push(error);
|
|
386
|
-
}
|
|
387
|
-
if (preflightErrors.length > 0) {
|
|
388
|
-
throw new AggregateError(preflightErrors, `Failed to preflight ${preflightErrors.length} launchd scheduler restore operation(s).`);
|
|
389
|
-
}
|
|
390
|
-
const expectationsById = new Map();
|
|
391
|
-
const expectationIdsByKey = new Map();
|
|
392
|
-
if (expectedCurrent) {
|
|
393
|
-
for (const expected of expectedCurrent) {
|
|
394
|
-
const key = schedulerNativeArtifactKey(expected.nativeId);
|
|
395
|
-
const priorExact = expectationsById.get(expected.nativeId);
|
|
396
|
-
const priorKeyId = expectationIdsByKey.get(key);
|
|
397
|
-
if (priorExact !== undefined || priorKeyId !== undefined) {
|
|
398
|
-
preflightErrors.push(new ConfigError(`Launchd rollback expectations contain duplicate normalized artifact ${JSON.stringify(expected.nativeId)}.`, "INVALID_CONFIG_FILE"));
|
|
399
|
-
continue;
|
|
400
|
-
}
|
|
401
|
-
expectationsById.set(expected.nativeId, expected);
|
|
402
|
-
expectationIdsByKey.set(key, expected.nativeId);
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
const plannedEntries = [];
|
|
406
|
-
const coveredExpectationIds = new Set();
|
|
407
|
-
if (rollbackInventory) {
|
|
408
|
-
for (const entry of snapshot.entries) {
|
|
409
|
-
if (duplicateSnapshotKeys.has(schedulerNativeArtifactKey(entry.id)))
|
|
410
|
-
continue;
|
|
411
|
-
try {
|
|
412
|
-
let current;
|
|
413
|
-
if (expectedCurrent) {
|
|
414
|
-
const expected = expectationsById.get(entry.id);
|
|
415
|
-
if (!expected) {
|
|
416
|
-
throw new ConfigError(`Missing launchd rollback expectation for ${JSON.stringify(entry.id)}.`, "INVALID_CONFIG_FILE");
|
|
417
|
-
}
|
|
418
|
-
coveredExpectationIds.add(entry.id);
|
|
419
|
-
current = assertSchedulerRollbackArtifactCardinality(rollbackInventory.artifacts, expected);
|
|
420
|
-
}
|
|
421
|
-
else {
|
|
422
|
-
const currentCount = rollbackInventory.artifacts.filter((artifact) => schedulerNativeArtifactKey(artifact.nativeId) === schedulerNativeArtifactKey(entry.id)).length;
|
|
423
|
-
current = assertSchedulerNativeArtifactCardinality(rollbackInventory.artifacts, entry.id, currentCount === 0 ? 0 : 1);
|
|
424
|
-
if (current !== undefined && current.nativeId !== entry.id) {
|
|
425
|
-
throw new ConfigError(`Launchd scheduler artifact ${JSON.stringify(entry.id)} changed to case- or suffix-equivalent spelling ${JSON.stringify(current.nativeId)} before restore.`, "INVALID_CONFIG_FILE");
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
if (entry.loaded && entry.plist === undefined) {
|
|
429
|
-
assertUnrestorableLaunchdSnapshotUnchanged(entry, rollbackInventory.artifacts);
|
|
430
|
-
}
|
|
431
|
-
plannedEntries.push(Object.freeze({
|
|
432
|
-
entry,
|
|
433
|
-
immediateExpectation: launchdRollbackExpectationFromArtifact(entry.id, current),
|
|
434
|
-
noOp: entry.loaded && entry.plist === undefined,
|
|
435
|
-
}));
|
|
436
|
-
}
|
|
437
|
-
catch (error) {
|
|
438
|
-
preflightErrors.push(error);
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
if (expectedCurrent) {
|
|
443
|
-
for (const expected of expectedCurrent) {
|
|
444
|
-
if (!coveredExpectationIds.has(expected.nativeId)) {
|
|
445
|
-
preflightErrors.push(new ConfigError(`Unexpected launchd rollback expectation for ${JSON.stringify(expected.nativeId)} is not covered by the snapshot.`, "INVALID_CONFIG_FILE"));
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
if (preflightErrors.length > 0) {
|
|
450
|
-
throw new AggregateError(preflightErrors, `Failed to preflight ${preflightErrors.length} launchd scheduler restore operation(s).`);
|
|
451
|
-
}
|
|
452
|
-
const errors = [];
|
|
453
|
-
for (const planned of plannedEntries) {
|
|
454
|
-
if (planned.noOp)
|
|
455
|
-
continue;
|
|
456
|
-
try {
|
|
457
|
-
const immediateInventory = inspectStableLaunchdNamespace(snapshot.nativeIds, context).inspection;
|
|
458
|
-
assertSchedulerRollbackArtifactCardinality(immediateInventory.artifacts, planned.immediateExpectation);
|
|
459
|
-
}
|
|
460
|
-
catch (error) {
|
|
461
|
-
errors.push(error);
|
|
462
|
-
continue;
|
|
463
|
-
}
|
|
464
|
-
restoreLaunchdBindingEntry(planned.entry, context, errors);
|
|
465
|
-
}
|
|
466
|
-
if (errors.length > 0) {
|
|
467
|
-
throw new AggregateError(errors, `Failed to completely restore ${errors.length} launchd scheduler operation(s).`);
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
function launchdRollbackExpectationFromArtifact(nativeId, current) {
|
|
471
|
-
if (current === undefined) {
|
|
472
|
-
return Object.freeze({ nativeId, allowed: Object.freeze([{ state: "absent" }]) });
|
|
473
|
-
}
|
|
474
|
-
if (current.nativeId !== nativeId || current.fingerprint === undefined) {
|
|
475
|
-
throw new ConfigError(`Launchd scheduler artifact ${JSON.stringify(nativeId)} has no exact restorable fingerprint.`, "INVALID_CONFIG_FILE");
|
|
476
|
-
}
|
|
477
|
-
return Object.freeze({
|
|
478
|
-
nativeId,
|
|
479
|
-
allowed: Object.freeze([
|
|
480
|
-
Object.freeze({
|
|
481
|
-
state: "present",
|
|
482
|
-
...(current.bindingId !== undefined ? { bindingId: current.bindingId } : {}),
|
|
483
|
-
...(current.invocation !== undefined ? { invocation: current.invocation } : {}),
|
|
484
|
-
fingerprint: current.fingerprint,
|
|
485
|
-
}),
|
|
486
|
-
]),
|
|
487
|
-
});
|
|
488
|
-
}
|
|
489
|
-
function assertUnrestorableLaunchdSnapshotUnchanged(entry, currentArtifacts) {
|
|
490
|
-
const current = assertSchedulerNativeArtifactCardinality(currentArtifacts, entry.id, 1);
|
|
491
|
-
const expectedFingerprint = launchdMissingPlistArtifact(entry.id, entry.enabled, entry.loaded).fingerprint;
|
|
492
|
-
if (current?.nativeId !== entry.id ||
|
|
493
|
-
current.invocation !== undefined ||
|
|
494
|
-
current.bindingId !== undefined ||
|
|
495
|
-
current.fingerprint !== expectedFingerprint) {
|
|
496
|
-
throw new ConfigError(`Loaded launchd artifact ${JSON.stringify(entry.id)} has no restorable plist and changed after its snapshot; refusing mutation.`, "INVALID_CONFIG_FILE");
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
/**
|
|
500
|
-
* Enumerate the current launchd AKM service namespace (loaded domain members,
|
|
501
|
-
* print-disabled labels, and on-disk plists) in one pass.
|
|
502
|
-
*
|
|
503
|
-
* This used to run the enumeration twice and fail closed if the two passes
|
|
504
|
-
* disagreed, guarding against launchd state mutating between the two
|
|
505
|
-
* `launchctl` shell-outs (milliseconds apart, in-process). The only actor
|
|
506
|
-
* that could cause that is another concurrent akm process or the user
|
|
507
|
-
* running launchctl by hand at that exact instant; a subsequent `task sync`
|
|
508
|
-
* (idempotent by design) reconciles any such drift on its own, so the
|
|
509
|
-
* two-pass fencing added a TOCTOU-shaped guard around a race with a working
|
|
510
|
-
* self-heal already in place, not a way to avoid one.
|
|
511
|
-
*/
|
|
512
|
-
function inspectStableLaunchdNamespace(seedIds, context) {
|
|
513
|
-
const domain = context.exec.run(["launchctl", "print", `gui/${context.exec.uid()}`]);
|
|
514
|
-
if (domain.status !== 0) {
|
|
515
|
-
throw new ConfigError(`launchctl failed to enumerate the loaded user domain during scheduler state inspection: ${domain.stderr || domain.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
|
|
516
|
-
}
|
|
517
|
-
const loadedLabels = parseLaunchdLoadedLabels(domain.stdout);
|
|
518
|
-
const disabledLabels = readDisabledLabels(context.exec);
|
|
519
|
-
const akmDisabledLabels = [...disabledLabels].filter((label) => label.startsWith(LAUNCHD_LABEL_PREFIX)).sort();
|
|
520
|
-
const plistEntries = [];
|
|
521
|
-
if (context.fsLike.exists(context.agentsDir)) {
|
|
522
|
-
for (const file of context.fsLike.list(context.agentsDir).sort()) {
|
|
523
|
-
if (!file.startsWith(LAUNCHD_LABEL_PREFIX) || !file.endsWith(".plist"))
|
|
524
|
-
continue;
|
|
525
|
-
const nativeId = file.slice(LAUNCHD_LABEL_PREFIX.length, -".plist".length);
|
|
526
|
-
plistEntries.push(Object.freeze([nativeId, context.fsLike.readFile(context.plistPath(nativeId))]));
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
const ids = new Set(seedIds);
|
|
530
|
-
for (const [nativeId] of plistEntries)
|
|
531
|
-
ids.add(nativeId);
|
|
532
|
-
for (const serviceLabel of loadedLabels)
|
|
533
|
-
ids.add(serviceLabel.slice(LAUNCHD_LABEL_PREFIX.length));
|
|
534
|
-
for (const serviceLabel of akmDisabledLabels)
|
|
535
|
-
ids.add(serviceLabel.slice(LAUNCHD_LABEL_PREFIX.length));
|
|
536
|
-
const plistByNativeId = new Map(plistEntries);
|
|
537
|
-
const entries = [];
|
|
538
|
-
for (const nativeId of [...ids].sort()) {
|
|
539
|
-
const serviceLabel = context.label(nativeId);
|
|
540
|
-
const plist = plistByNativeId.get(nativeId);
|
|
541
|
-
const enabled = !disabledLabels.has(serviceLabel);
|
|
542
|
-
const loaded = loadedLabels.has(serviceLabel);
|
|
543
|
-
if (plist === undefined && enabled && !loaded)
|
|
544
|
-
continue;
|
|
545
|
-
const artifact = plist === undefined
|
|
546
|
-
? launchdMissingPlistArtifact(nativeId, enabled, loaded)
|
|
547
|
-
: launchdArtifact(nativeId, plist, enabled, loaded);
|
|
548
|
-
entries.push(Object.freeze({ nativeId, ...(plist !== undefined ? { plist } : {}), enabled, loaded, artifact }));
|
|
549
|
-
}
|
|
550
|
-
const artifacts = entries.map((entry) => entry.artifact);
|
|
551
|
-
const installed = [];
|
|
552
|
-
for (const entry of entries) {
|
|
553
|
-
if (entry.plist === undefined)
|
|
554
|
-
continue;
|
|
555
|
-
const parsed = extractPlistInvocation(entry.plist);
|
|
556
|
-
if (!parsed)
|
|
557
|
-
continue;
|
|
558
|
-
installed.push(withInstalledInvocation({
|
|
559
|
-
id: schedulerLogicalBindingId(entry.nativeId, parsed.invocation),
|
|
560
|
-
enabled: entry.enabled && entry.loaded,
|
|
561
|
-
...(entry.loaded ? { signature: entry.artifact.fingerprint } : {}),
|
|
562
|
-
...(parsed.target !== undefined ? { target: parsed.target } : {}),
|
|
563
|
-
binding: parsed.binding,
|
|
564
|
-
contextPath: parsed.contextPath,
|
|
565
|
-
}, parsed.invocation, entry.nativeId));
|
|
566
|
-
}
|
|
567
|
-
return Object.freeze({
|
|
568
|
-
inspection: Object.freeze({ installed: Object.freeze(installed), artifacts: Object.freeze(artifacts) }),
|
|
569
|
-
entries: Object.freeze(entries),
|
|
570
|
-
});
|
|
571
|
-
}
|
|
572
|
-
function restoreLaunchdBindingEntry(entry, context, errors) {
|
|
573
|
-
try {
|
|
574
|
-
runOrThrow(context.exec, ["launchctl", "bootout", context.target(entry.id)], {
|
|
575
|
-
isOk: (result) => result.status === 0 || isServiceNotFoundResult(result),
|
|
576
|
-
message: (result) => `launchctl bootout failed while restoring task "${entry.id}": ${result.stderr || result.stdout || "no output"}.`,
|
|
577
|
-
});
|
|
578
|
-
}
|
|
579
|
-
catch (error) {
|
|
580
|
-
errors.push(error);
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
|
-
const file = context.plistPath(entry.id);
|
|
584
|
-
let priorFileRestored = false;
|
|
585
|
-
try {
|
|
586
|
-
if (entry.plist === undefined) {
|
|
587
|
-
if (context.fsLike.exists(file))
|
|
588
|
-
context.fsLike.removeFile(file);
|
|
589
|
-
}
|
|
590
|
-
else {
|
|
591
|
-
context.fsLike.ensureDir(context.agentsDir);
|
|
592
|
-
context.fsLike.writeFile(file, entry.plist);
|
|
593
|
-
}
|
|
594
|
-
priorFileRestored = true;
|
|
595
|
-
}
|
|
596
|
-
catch (error) {
|
|
597
|
-
errors.push(error);
|
|
598
|
-
}
|
|
599
|
-
if (entry.loaded && entry.plist !== undefined && priorFileRestored) {
|
|
600
|
-
let overrideCleared = false;
|
|
601
|
-
try {
|
|
602
|
-
// A disable override survives bootout. Clear it before bootstrap or
|
|
603
|
-
// launchd can refuse to register the exact prior loaded definition.
|
|
604
|
-
context.setEnableState(entry.id, true);
|
|
605
|
-
overrideCleared = true;
|
|
606
|
-
}
|
|
607
|
-
catch (error) {
|
|
608
|
-
errors.push(error);
|
|
609
|
-
}
|
|
610
|
-
if (overrideCleared) {
|
|
611
|
-
try {
|
|
612
|
-
runOrThrow(context.exec, ["launchctl", "bootstrap", `gui/${context.exec.uid()}`, file], {
|
|
613
|
-
message: (result) => `launchctl bootstrap failed while restoring task "${entry.id}": ${result.stderr || result.stdout || "no output"}.`,
|
|
614
|
-
});
|
|
615
|
-
}
|
|
616
|
-
catch (error) {
|
|
617
|
-
errors.push(error);
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
try {
|
|
622
|
-
// Apply the prior override last even when an earlier step failed; this is
|
|
623
|
-
// both the exact state ordering and the best available partial recovery.
|
|
624
|
-
context.setEnableState(entry.id, entry.enabled);
|
|
625
|
-
}
|
|
626
|
-
catch (error) {
|
|
627
|
-
errors.push(error);
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
function isLaunchdBindingSnapshot(value) {
|
|
631
|
-
return (typeof value === "object" &&
|
|
632
|
-
value !== null &&
|
|
633
|
-
value.kind === LAUNCHD_SNAPSHOT &&
|
|
634
|
-
Array.isArray(value.entries));
|
|
635
|
-
}
|
|
636
|
-
function inspectLaunchdState(context) {
|
|
637
|
-
return inspectStableLaunchdNamespace([], context).inspection;
|
|
638
|
-
}
|
|
639
|
-
function assertLaunchdDirectMutationOwner(artifacts, nativeId, task) {
|
|
640
|
-
const key = schedulerNativeArtifactKey(nativeId);
|
|
641
|
-
const count = artifacts.filter((artifact) => schedulerNativeArtifactKey(artifact.nativeId) === key).length;
|
|
642
|
-
const artifact = assertSchedulerNativeArtifactCardinality(artifacts, nativeId, count === 0 ? 0 : 1);
|
|
643
|
-
if (artifact)
|
|
644
|
-
assertSchedulerNativeArtifactOwner(artifact.nativeId, task, artifact.invocation);
|
|
645
|
-
return artifact;
|
|
646
|
-
}
|
|
647
|
-
function assertLaunchdDirectRemovalOwner(artifacts, nativeId) {
|
|
648
|
-
const key = schedulerNativeArtifactKey(nativeId);
|
|
649
|
-
const count = artifacts.filter((artifact) => schedulerNativeArtifactKey(artifact.nativeId) === key).length;
|
|
650
|
-
const artifact = assertSchedulerNativeArtifactCardinality(artifacts, nativeId, count === 0 ? 0 : 1);
|
|
651
|
-
if (!artifact)
|
|
652
|
-
return undefined;
|
|
653
|
-
if (artifact.nativeId !== nativeId || artifact.invocation === undefined) {
|
|
654
|
-
throw new ConfigError(`launchd artifact ${JSON.stringify(artifact.nativeId)} is not the exact proven requested owner ${JSON.stringify(nativeId)}; refusing direct removal.`, "INVALID_CONFIG_FILE");
|
|
655
|
-
}
|
|
656
|
-
return artifact;
|
|
657
|
-
}
|
|
658
|
-
function launchdRollbackExpectationForCurrent(nativeId, context, priorArtifact) {
|
|
659
|
-
const namespace = inspectStableLaunchdNamespace([nativeId], context);
|
|
660
|
-
const key = schedulerNativeArtifactKey(nativeId);
|
|
661
|
-
const count = namespace.inspection.artifacts.filter((artifact) => schedulerNativeArtifactKey(artifact.nativeId) === key).length;
|
|
662
|
-
const artifact = assertSchedulerNativeArtifactCardinality(namespace.inspection.artifacts, nativeId, count === 0 ? 0 : 1);
|
|
663
|
-
if (!artifact) {
|
|
664
|
-
return Object.freeze({ nativeId, allowed: Object.freeze([{ state: "absent" }]) });
|
|
665
|
-
}
|
|
666
|
-
if (artifact.fingerprint === undefined ||
|
|
667
|
-
artifact.invocation === undefined ||
|
|
668
|
-
priorArtifact?.invocation === undefined ||
|
|
669
|
-
!sameStringArray(artifact.invocation, priorArtifact.invocation)) {
|
|
670
|
-
throw new ConfigError(`launchd artifact ${JSON.stringify(nativeId)} has no exact transaction-owned rollback fingerprint.`, "INVALID_CONFIG_FILE");
|
|
671
|
-
}
|
|
672
|
-
return Object.freeze({
|
|
673
|
-
nativeId,
|
|
674
|
-
allowed: Object.freeze([
|
|
675
|
-
Object.freeze({
|
|
676
|
-
state: "present",
|
|
677
|
-
...(artifact.bindingId !== undefined ? { bindingId: artifact.bindingId } : {}),
|
|
678
|
-
invocation: Object.freeze([...artifact.invocation]),
|
|
679
|
-
fingerprint: artifact.fingerprint,
|
|
680
|
-
}),
|
|
681
|
-
]),
|
|
682
|
-
});
|
|
683
|
-
}
|
|
684
|
-
function sameStringArray(left, right) {
|
|
685
|
-
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
686
|
-
}
|
|
687
|
-
function errorMessage(error) {
|
|
688
|
-
return error instanceof Error ? error.message : String(error);
|
|
689
|
-
}
|
|
690
|
-
function launchdMissingPlistArtifact(nativeId, enabled, loaded) {
|
|
691
|
-
const artifact = { nativeId };
|
|
692
|
-
Object.defineProperty(artifact, "fingerprint", {
|
|
693
|
-
value: `launchd:missing-plist:enabled=${enabled}:loaded=${loaded}`,
|
|
694
|
-
});
|
|
695
|
-
return artifact;
|
|
696
|
-
}
|
|
697
|
-
function launchdArtifact(nativeId, raw, enabled, loaded) {
|
|
698
|
-
const parsed = extractPlistInvocation(raw);
|
|
699
|
-
const owner = parsed ? schedulerLogicalBindingOwner(nativeId, parsed.invocation) : undefined;
|
|
700
|
-
const artifact = parsed
|
|
701
|
-
? {
|
|
702
|
-
nativeId,
|
|
703
|
-
...(owner !== undefined ? { bindingId: owner } : {}),
|
|
704
|
-
invocation: Object.freeze([...parsed.invocation]),
|
|
705
|
-
}
|
|
706
|
-
: { nativeId };
|
|
707
|
-
if (enabled !== undefined && loaded !== undefined) {
|
|
708
|
-
Object.defineProperty(artifact, "fingerprint", { value: launchdFingerprint(raw, enabled, loaded) });
|
|
709
|
-
}
|
|
710
|
-
return artifact;
|
|
711
|
-
}
|
|
712
145
|
function launchdFingerprint(raw, enabled, loaded) {
|
|
713
146
|
const signed = raw.replace(/<!-- akm-enabled:(?:true|false) -->/, `<!-- akm-enabled:${enabled} -->`);
|
|
714
|
-
return `${
|
|
715
|
-
}
|
|
716
|
-
function withInstalledInvocation(ref, invocation, nativeId) {
|
|
717
|
-
Object.defineProperty(ref, "nativeId", { value: nativeId });
|
|
718
|
-
Object.defineProperty(ref, "invocation", { value: Object.freeze([...invocation]) });
|
|
719
|
-
return ref;
|
|
720
|
-
}
|
|
721
|
-
function launchdDefaultContextPath(options, scheduledContext) {
|
|
722
|
-
const pathEnv = options.envPath === false
|
|
723
|
-
? undefined
|
|
724
|
-
: typeof options.envPath === "string"
|
|
725
|
-
? options.envPath
|
|
726
|
-
: (process.env.PATH ?? "");
|
|
727
|
-
return schedulerContextPath(schedulerContextDescriptor(scheduledContext, pathEnv ?? ""));
|
|
728
|
-
}
|
|
729
|
-
function setLaunchdEnableState(exec, nativeTarget, enabled) {
|
|
730
|
-
const verb = enabled ? "enable" : "disable";
|
|
731
|
-
runOrThrow(exec, ["launchctl", verb, nativeTarget], {
|
|
732
|
-
message: (result) => `launchctl ${verb} failed: ${result.stderr || result.stdout || "no output"}.`,
|
|
733
|
-
});
|
|
147
|
+
return `${signed.replace(/\r\n/g, "\n").trim()}:loaded=${loaded}`;
|
|
734
148
|
}
|
|
735
149
|
export function extractPlistInvocation(xml) {
|
|
736
150
|
const block = xml.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
|
|
737
151
|
if (!block)
|
|
738
152
|
return undefined;
|
|
739
153
|
const args = [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => decodeXmlEntities(m[1]));
|
|
740
|
-
|
|
154
|
+
const parsed = parseScheduledInvocationArgv(args);
|
|
155
|
+
if (!parsed)
|
|
156
|
+
return undefined;
|
|
157
|
+
const variables = {};
|
|
158
|
+
const dict = xml.match(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/)?.[1] ?? "";
|
|
159
|
+
for (const entry of dict.matchAll(/<key>([^<]*)<\/key>\s*<string>([\s\S]*?)<\/string>/g)) {
|
|
160
|
+
variables[decodeXmlEntities(entry[1])] = decodeXmlEntities(entry[2]);
|
|
161
|
+
}
|
|
162
|
+
const environment = scheduledRowEnvironmentFrom(variables);
|
|
163
|
+
return environment ? { ...parsed, environment } : parsed;
|
|
741
164
|
}
|
|
742
165
|
function decodeXmlEntities(value) {
|
|
743
166
|
return value
|
|
@@ -748,25 +171,34 @@ function decodeXmlEntities(value) {
|
|
|
748
171
|
.replaceAll("&", "&");
|
|
749
172
|
}
|
|
750
173
|
// ── XML builder (exported for tests) ────────────────────────────────────────
|
|
751
|
-
|
|
752
|
-
const
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
174
|
+
function renderPlistEnvironment(envPath, environment) {
|
|
175
|
+
const variables = [
|
|
176
|
+
...(envPath ? [["PATH", envPath]] : []),
|
|
177
|
+
...scheduledRowEnvironmentEntries(environment),
|
|
178
|
+
];
|
|
179
|
+
if (variables.length === 0)
|
|
180
|
+
return "";
|
|
181
|
+
return [
|
|
182
|
+
" <key>EnvironmentVariables</key>",
|
|
183
|
+
" <dict>",
|
|
184
|
+
...variables.flatMap(([name, value]) => [` <key>${name}</key>`, ` <string>${escapeXml(value)}</string>`]),
|
|
185
|
+
" </dict>",
|
|
186
|
+
"",
|
|
187
|
+
].join("\n");
|
|
188
|
+
}
|
|
189
|
+
export function buildPlistXml(task, akmArgv, logDir, environment, envPath) {
|
|
190
|
+
const trigger = translateToLaunchd(parseSchedule(task.cron, "launchd"));
|
|
191
|
+
const argv = buildScheduledInvocation(akmArgv, task.invocation);
|
|
756
192
|
const programArgs = argv.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
|
|
757
193
|
const nativeId = schedulerBindingNativeId(task);
|
|
758
194
|
const logPath = path.join(logDir, `${nativeId}.log`);
|
|
759
|
-
const triggerXml = renderLaunchdTrigger(trigger);
|
|
760
|
-
const executionEvidence = task.executionEvidenceDigest === undefined
|
|
761
|
-
? ""
|
|
762
|
-
: ` <!-- akm-workflow-evidence:${assertSchedulerExecutionEvidenceDigest(task.executionEvidenceDigest)} -->\n`;
|
|
763
195
|
const xml = launchdTemplate
|
|
764
|
-
.replace("<dict>\n", `<dict>\n <!-- akm-enabled:${task.enabled} -->\n
|
|
196
|
+
.replace("<dict>\n", `<dict>\n <!-- akm-enabled:${task.enabled} -->\n`)
|
|
765
197
|
.replace("{{LABEL}}", LAUNCHD_LABEL_PREFIX + escapeXml(nativeId))
|
|
766
198
|
.replace("{{PROGRAM_ARGS}}", programArgs)
|
|
767
199
|
.replaceAll("{{LOG_PATH}}", escapeXml(logPath))
|
|
768
|
-
.replace("{{ENV_VARS}}",
|
|
769
|
-
.replace("{{TRIGGER_XML}}",
|
|
200
|
+
.replace("{{ENV_VARS}}", () => renderPlistEnvironment(envPath, environment))
|
|
201
|
+
.replace("{{TRIGGER_XML}}", renderLaunchdTrigger(trigger));
|
|
770
202
|
for (const char of xml) {
|
|
771
203
|
const code = char.codePointAt(0) ?? 0;
|
|
772
204
|
if (code <= 0x08 || code === 0x0b || code === 0x0c || (code >= 0x0e && code <= 0x1f)) {
|
|
@@ -778,22 +210,18 @@ export function buildPlistXml(task, akmArgv, logDir, contextPath, _target) {
|
|
|
778
210
|
function renderLaunchdTrigger(trigger) {
|
|
779
211
|
if (trigger.calendars !== undefined) {
|
|
780
212
|
const lines = [" <key>StartCalendarInterval</key>", " <array>"];
|
|
781
|
-
for (const calendar of trigger.calendars)
|
|
213
|
+
for (const calendar of trigger.calendars)
|
|
782
214
|
lines.push(...renderCalendar(calendar, " "));
|
|
783
|
-
}
|
|
784
215
|
lines.push(" </array>");
|
|
785
216
|
return lines.join("\n");
|
|
786
217
|
}
|
|
787
|
-
|
|
788
|
-
const lines = [" <key>StartCalendarInterval</key>", ...renderCalendar(cal, " ")];
|
|
789
|
-
return lines.join("\n");
|
|
218
|
+
return [" <key>StartCalendarInterval</key>", ...renderCalendar(trigger.calendar ?? {}, " ")].join("\n");
|
|
790
219
|
}
|
|
791
220
|
function renderCalendar(calendar, indent) {
|
|
792
221
|
const valueIndent = `${indent} `;
|
|
793
222
|
const lines = [`${indent}<dict>`];
|
|
794
|
-
if (calendar.Minute !== undefined)
|
|
223
|
+
if (calendar.Minute !== undefined)
|
|
795
224
|
lines.push(`${valueIndent}<key>Minute</key><integer>${calendar.Minute}</integer>`);
|
|
796
|
-
}
|
|
797
225
|
if (calendar.Hour !== undefined)
|
|
798
226
|
lines.push(`${valueIndent}<key>Hour</key><integer>${calendar.Hour}</integer>`);
|
|
799
227
|
if (calendar.Day !== undefined)
|
|
@@ -806,49 +234,25 @@ function renderCalendar(calendar, indent) {
|
|
|
806
234
|
lines.push(`${indent}</dict>`);
|
|
807
235
|
return lines;
|
|
808
236
|
}
|
|
809
|
-
function normalizeSignature(xml) {
|
|
810
|
-
return xml.replace(/\r\n/g, "\n").trim();
|
|
811
|
-
}
|
|
812
237
|
/**
|
|
813
238
|
* Collect the akm-owned service labels present in `launchctl` output.
|
|
814
239
|
*
|
|
815
|
-
* This reads the CURRENT USER'S OWN scheduler inventory
|
|
816
|
-
*
|
|
817
|
-
*
|
|
818
|
-
* `com.akm.task.` namespace and ignore everything else.
|
|
819
|
-
*
|
|
820
|
-
* It previously enforced a rigid grammar over the whole document and returned
|
|
821
|
-
* `undefined` — surfacing as a hard INVALID_CONFIG_FILE that refused to inspect
|
|
822
|
-
* scheduler state at all — if ANY line failed to match. Real
|
|
823
|
-
* `launchctl print gui/<uid>` output on macOS is far richer than that grammar
|
|
824
|
-
* allowed, so on a real Mac this rejected the user's own inventory and broke
|
|
825
|
-
* every launchd operation. Caught by the gated native-scheduler suite, which
|
|
826
|
-
* had never been dispatched before.
|
|
827
|
-
*
|
|
828
|
-
* The two bounds that remain are real resource bounds, not structural ones: a
|
|
829
|
-
* cap on how much output we will read, and a cap on how many distinct akm
|
|
830
|
-
* labels we will track. Exceeding either still returns `undefined`.
|
|
240
|
+
* This reads the CURRENT USER'S OWN scheduler inventory, so it is parsed
|
|
241
|
+
* permissively: scan for labels in our own `com.akm.task.` namespace and
|
|
242
|
+
* ignore everything else.
|
|
831
243
|
*/
|
|
832
244
|
export function parseLaunchdLoadedLabels(output) {
|
|
833
245
|
const labels = new Set();
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
const label = match[0];
|
|
841
|
-
if (!LAUNCHD_AKM_LABEL_RE.test(label))
|
|
842
|
-
continue;
|
|
843
|
-
labels.add(label);
|
|
844
|
-
if (labels.size >= MAX_LAUNCHD_AKM_NAMESPACE_ENTRIES)
|
|
845
|
-
break;
|
|
246
|
+
// `[^\s"{}=,()]` stops the token at whatever punctuation the surrounding
|
|
247
|
+
// launchctl syntax uses, so a label works whether it appears as a bare
|
|
248
|
+
// table cell, a quoted string, or a dictionary key.
|
|
249
|
+
for (const match of output.matchAll(/com\.akm\.task\.[^\s"{}=,()]+/gu)) {
|
|
250
|
+
if (LAUNCHD_AKM_LABEL_RE.test(match[0]))
|
|
251
|
+
labels.add(match[0]);
|
|
846
252
|
}
|
|
847
253
|
return labels;
|
|
848
254
|
}
|
|
849
|
-
|
|
850
|
-
return output.length > MAX_LAUNCHD_DOMAIN_OUTPUT_BYTES ? output.slice(0, MAX_LAUNCHD_DOMAIN_OUTPUT_BYTES) : output;
|
|
851
|
-
}
|
|
255
|
+
/** `print-disabled` failing or looking unfamiliar degrades to "nothing is disabled". */
|
|
852
256
|
function readDisabledLabels(exec) {
|
|
853
257
|
try {
|
|
854
258
|
const result = exec.run(["launchctl", "print-disabled", `gui/${exec.uid()}`]);
|
|
@@ -856,35 +260,25 @@ function readDisabledLabels(exec) {
|
|
|
856
260
|
warn("[akm] launchctl print-disabled exited %d; assuming no akm task is disabled.", result.status);
|
|
857
261
|
return new Set();
|
|
858
262
|
}
|
|
859
|
-
|
|
263
|
+
const disabled = new Set();
|
|
264
|
+
const entryPattern = /"(com\.akm\.task\.[^"\r\n]+)"\s*=>\s*(true|false|enabled|disabled)/gu;
|
|
265
|
+
for (const match of result.stdout.matchAll(entryPattern)) {
|
|
266
|
+
const label = match[1];
|
|
267
|
+
if (LAUNCHD_AKM_LABEL_RE.test(label) && (match[2] === "true" || match[2] === "disabled"))
|
|
268
|
+
disabled.add(label);
|
|
269
|
+
}
|
|
270
|
+
return disabled;
|
|
860
271
|
}
|
|
861
272
|
catch (error) {
|
|
862
273
|
warn("[akm] launchctl print-disabled could not be run; assuming no akm task is disabled: %s", error instanceof Error ? error.message : String(error));
|
|
863
274
|
return new Set();
|
|
864
275
|
}
|
|
865
276
|
}
|
|
866
|
-
function parseDisabledLabels(output) {
|
|
867
|
-
const disabled = new Set();
|
|
868
|
-
const entryPattern = /"(com\.akm\.task\.[^"\r\n]+)"\s*=>\s*(true|false|enabled|disabled)/gu;
|
|
869
|
-
for (const match of boundLaunchdOutput(output).matchAll(entryPattern)) {
|
|
870
|
-
const label = match[1];
|
|
871
|
-
if (!LAUNCHD_AKM_LABEL_RE.test(label))
|
|
872
|
-
continue;
|
|
873
|
-
if (match[2] === "true" || match[2] === "disabled")
|
|
874
|
-
disabled.add(label);
|
|
875
|
-
if (disabled.size >= MAX_LAUNCHD_AKM_NAMESPACE_ENTRIES)
|
|
876
|
-
break;
|
|
877
|
-
}
|
|
878
|
-
return disabled;
|
|
879
|
-
}
|
|
880
277
|
function isServiceNotFoundResult(result) {
|
|
881
278
|
const output = `${result.stderr ?? ""}\n${result.stdout ?? ""}`;
|
|
882
279
|
return /could not find service\b|service\b.*\bnot found\b|\bno such process\b/i.test(output);
|
|
883
280
|
}
|
|
884
281
|
function defaultAgentsDir() {
|
|
885
|
-
// launchd's per-user LaunchAgents live under the user's home directory.
|
|
886
|
-
// If we can't determine HOME, refuse rather than silently producing a
|
|
887
|
-
// relative path that would write somewhere unexpected.
|
|
888
282
|
const home = os.homedir();
|
|
889
283
|
if (!home) {
|
|
890
284
|
throw new ConfigError("Cannot determine user home directory; launchd backend requires HOME to locate ~/Library/LaunchAgents.", "INVALID_CONFIG_FILE", "Set $HOME (POSIX) or the equivalent before running `akm task` on macOS.");
|
|
@@ -917,9 +311,8 @@ function defaultLaunchdFs() {
|
|
|
917
311
|
return fs.readdirSync(dir);
|
|
918
312
|
}
|
|
919
313
|
catch (error) {
|
|
920
|
-
// Genuinely absent is an empty listing; anything else (e.g. EACCES)
|
|
921
|
-
// not "no plists"
|
|
922
|
-
// think there is nothing to reconcile.
|
|
314
|
+
// Genuinely absent is an empty listing; anything else (e.g. EACCES)
|
|
315
|
+
// is not "no plists".
|
|
923
316
|
if (hasErrnoCode(error, "ENOENT"))
|
|
924
317
|
return [];
|
|
925
318
|
throw new ConfigError(`Unable to read LaunchAgents directory at "${dir}".`, "INVALID_CONFIG_FILE");
|