@astryxdesign/cli 0.7.2-canary.fdd384f → 0.7.2
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 +119 -0
- package/README.md +169 -149
- package/api/build/_adapter.d.mts +8 -0
- package/api/build/_adapter.mjs +12 -1
- package/api/build/build.doc.mjs +4 -0
- package/api/build/kit/kit.mjs +3 -11
- package/api/component/_adapter.d.mts +31 -0
- package/api/component/_adapter.mjs +119 -11
- package/api/component/component.doc.mjs +6 -1
- package/api/component/component.mjs +60 -10
- package/api/component/doc-links.test.mjs +386 -0
- package/api/discover/_adapter.d.mts +132 -12
- package/api/discover/_adapter.mjs +488 -27
- package/api/discover/_adapter.test.mjs +599 -17
- package/api/discover/_catalog-view.d.mts +16 -5
- package/api/discover/_catalog-view.mjs +32 -8
- package/api/discover/detail/doc/doc.mjs +9 -1
- package/api/discover/discover.d.mts +11 -0
- package/api/discover/discover.doc.mjs +9 -7
- package/api/discover/discover.mjs +121 -19
- package/api/discover/discover.type.d.mts +20 -7
- package/api/discover/discover.type.mjs +17 -7
- package/api/discover/list/list.d.mts +4 -3
- package/api/discover/list/list.mjs +17 -10
- package/api/docs/_adapter.d.mts +63 -0
- package/api/docs/_adapter.mjs +312 -18
- package/api/docs/docs.doc.mjs +2 -1
- package/api/docs/docs.mjs +2 -1
- package/api/docs/docs.test.mjs +5 -2
- package/api/docs/integration-tree.test.mjs +22 -0
- package/api/docs/list/list.mjs +12 -2
- package/api/docs/reference-blocks.test.mjs +1 -3
- package/api/doctor/_adapter.d.mts +108 -0
- package/api/doctor/_adapter.mjs +223 -4
- package/api/doctor/doctor.d.mts +199 -0
- package/api/doctor/doctor.doc.mjs +3 -2
- package/api/doctor/doctor.mjs +530 -34
- package/api/doctor/doctor.test.mjs +180 -14
- package/api/doctor/release-checks.test.mjs +156 -0
- package/api/doctor/theme-checks.mjs +16 -3
- package/api/doctor/theme-checks.test.mjs +223 -7
- package/api/error.d.mts +10 -0
- package/api/error.mjs +25 -0
- package/api/gap-report/gap-report.d.mts +3 -0
- package/api/gap-report/gap-report.doc.mjs +11 -3
- package/api/gap-report/gap-report.mjs +152 -48
- package/api/gap-report/gap-report.test.mjs +233 -0
- package/api/gap-report/gap-report.type.d.mts +41 -0
- package/api/gap-report/gap-report.type.mjs +18 -0
- package/api/hook/_adapter.mjs +3 -11
- package/api/hook/hook.doc.mjs +4 -0
- package/api/hook/list/list.d.mts +1 -1
- package/api/init/_adapter.d.mts +22 -0
- package/api/init/_adapter.mjs +59 -0
- package/api/init/init.d.mts +7 -5
- package/api/init/init.doc.mjs +28 -7
- package/api/init/init.mjs +28 -4
- package/api/init/init.type.d.mts +48 -3
- package/api/init/init.type.mjs +37 -3
- package/api/init/run/run.d.mts +1 -1
- package/api/init/run/run.mjs +15 -4
- package/api/init/user/user.d.mts +14 -0
- package/api/init/user/user.mjs +87 -0
- package/api/init/user/user.test.mjs +232 -0
- package/api/integration/_adapter.d.mts +33 -0
- package/api/integration/_adapter.mjs +58 -0
- package/api/integration/authoring-checks.mjs +100 -5
- package/api/integration/authoring-checks.test.mjs +769 -0
- package/api/integration/authoring-checks.type.d.mts +134 -0
- package/api/integration/authoring-checks.type.mjs +60 -1
- package/api/integration/integrationCheck.doc.mjs +1 -1
- package/api/integration/integrationComponentConflicts.doc.mjs +11 -3
- package/api/integration/integrationDocConflicts.doc.mjs +22 -5
- package/api/integration/pack-check.mjs +303 -13
- package/api/integration/pack-check.test.mjs +371 -10
- package/api/integration/validate-integration-fixes.test.mjs +67 -0
- package/api/integration/validate-integration.d.mts +8 -0
- package/api/integration/validate-integration.mjs +55 -9
- package/api/integration/validate-integration.test.mjs +77 -8
- package/api/integration/validate-integration.type.d.mts +8 -0
- package/api/integration/validate-integration.type.mjs +3 -1
- package/api/integration/validate-unread-theme-folders.test.mjs +3 -2
- package/api/integration/validateIntegration.doc.mjs +4 -2
- package/api/search/_adapter.d.mts +11 -0
- package/api/search/_adapter.mjs +25 -0
- package/api/search/search.d.mts +73 -13
- package/api/search/search.doc.mjs +8 -3
- package/api/search/search.domain-priority.test.mjs +108 -18
- package/api/search/search.mjs +541 -52
- package/api/search/search.test.mjs +143 -1
- package/api/search/search.type.d.mts +14 -1
- package/api/search/search.type.mjs +6 -1
- package/api/swizzle/_adapter.mjs +2 -2
- package/api/swizzle/copy/copy.mjs +1 -1
- package/api/template/template-integration.test.mjs +1 -1
- package/api/theme/_adapter.d.mts +16 -0
- package/api/theme/_adapter.mjs +32 -5
- package/api/theme/build/build.d.mts +2 -1
- package/api/theme/build/build.mjs +70 -14
- package/api/theme/build/build.packed-old-core.test.mjs +20 -3
- package/api/theme/build/font-warning.mjs +5 -3
- package/api/theme/build/font-warning.test.mjs +2 -1
- package/api/theme/remove/remove.test.mjs +2 -2
- package/api/theme/targets/targets.mjs +3 -10
- package/api/theme/themeTargets.doc.mjs +4 -0
- package/api/theme/use/use.test.mjs +1 -1
- package/api/upgrade/_adapter.d.mts +40 -2
- package/api/upgrade/_adapter.mjs +118 -5
- package/api/upgrade/run/cli-behind-core.test.mjs +438 -0
- package/api/upgrade/run/run.mjs +35 -2
- package/api/upgrade/status/status.d.mts +21 -5
- package/api/upgrade/status/status.mjs +45 -8
- package/api/upgrade/upgrade.doc.mjs +6 -3
- package/api/upgrade/upgrade.mjs +5 -1
- package/api/upgrade/upgrade.type.d.mts +23 -0
- package/api/upgrade/upgrade.type.mjs +16 -1
- package/assets/codemods/__tests__/registry.test.mjs +1 -0
- package/assets/codemods/registry.mjs +1 -0
- package/assets/codemods/runner.mjs +7 -1
- package/assets/codemods/transforms/next/index.mjs +1 -11
- package/assets/codemods/transforms/v0.7.2/index.mjs +20 -0
- package/assets/discover/official-packages.json +367 -0
- package/assets/docs/author-a-theme.doc.mjs +2 -2
- package/assets/docs/browser-support.doc.mjs +1 -1
- package/assets/docs/getting-started.doc.mjs +23 -3
- package/assets/docs/styling-overview.doc.mjs +1 -1
- package/assets/docs/theme.doc.mjs +1 -1
- package/assets/docs/tree/add-a-component.doc.mjs +12 -2
- package/assets/docs/tree/add-a-theme.doc.mjs +13 -3
- package/assets/docs/tree/add-a-topic.doc.mjs +54 -9
- package/assets/docs/tree/agent-guidance.doc.mjs +19 -5
- package/assets/docs/tree/block-template.doc.mjs +6 -18
- package/assets/docs/tree/build-the-template.doc.mjs +1 -1
- package/assets/docs/tree/building-blocks.doc.mjs +4 -0
- package/assets/docs/tree/check-the-props.doc.mjs +8 -3
- package/assets/docs/tree/check-your-docs.doc.mjs +30 -13
- package/assets/docs/tree/checks.doc.mjs +17 -17
- package/assets/docs/tree/ci-and-containers.doc.mjs +88 -0
- package/assets/docs/tree/cli-command.doc.mjs +142 -0
- package/assets/docs/tree/cli.doc.mjs +6 -0
- package/assets/docs/tree/codemods.doc.mjs +17 -7
- package/assets/docs/tree/commands.doc.mjs +7 -1
- package/assets/docs/tree/component-family.doc.mjs +3 -5
- package/assets/docs/tree/component-imports.doc.mjs +13 -3
- package/assets/docs/tree/components.doc.mjs +7 -1
- package/assets/docs/tree/computer-agent-guide.doc.mjs +160 -0
- package/assets/docs/tree/computer-integrations.doc.mjs +93 -0
- package/assets/docs/tree/configuration.doc.mjs +7 -3
- package/assets/docs/tree/debug-and-gap-reports.doc.mjs +41 -22
- package/assets/docs/tree/define-the-theme.doc.mjs +6 -5
- package/assets/docs/tree/describe-the-component.doc.mjs +2 -2
- package/assets/docs/tree/docs.doc.mjs +9 -3
- package/assets/docs/tree/document-the-template.doc.mjs +1 -1
- package/assets/docs/tree/document-the-theme.doc.mjs +3 -3
- package/assets/docs/tree/export-template-assets.doc.mjs +1 -1
- package/assets/docs/tree/extend-or-replace.doc.mjs +19 -9
- package/assets/docs/tree/fonts-and-assets.doc.mjs +2 -2
- package/assets/docs/tree/generate-a-palette.doc.mjs +7 -5
- package/assets/docs/tree/global-integrations.doc.mjs +124 -0
- package/assets/docs/tree/global.doc.mjs +131 -0
- package/assets/docs/tree/grade-template-with-agent.doc.mjs +5 -5
- package/assets/docs/tree/install-once.doc.mjs +105 -0
- package/assets/docs/tree/integrations.doc.mjs +13 -1
- package/assets/docs/tree/layout-spacing.doc.mjs +2 -3
- package/assets/docs/tree/layout.doc.mjs +6 -6
- package/assets/docs/tree/links.doc.mjs +6 -5
- package/assets/docs/tree/migration-setup.doc.mjs +3 -2
- package/assets/docs/tree/migration-verification.doc.mjs +0 -1
- package/assets/docs/tree/migration.doc.mjs +0 -1
- package/assets/docs/tree/monorepos.doc.mjs +84 -0
- package/assets/docs/tree/package-and-test.doc.mjs +1 -1
- package/assets/docs/tree/page-template.doc.mjs +3 -7
- package/assets/docs/tree/problems.doc.mjs +205 -0
- package/assets/docs/tree/project-global-or-both.doc.mjs +131 -0
- package/assets/docs/tree/publishing.doc.mjs +6 -6
- package/assets/docs/tree/quick-start.doc.mjs +52 -26
- package/assets/docs/tree/reference.doc.mjs +192 -0
- package/assets/docs/tree/replace-a-core-component.doc.mjs +15 -7
- package/assets/docs/tree/replace-a-core-template.doc.mjs +9 -11
- package/assets/docs/tree/scaffold.doc.mjs +1 -1
- package/assets/docs/tree/sections-and-placement.doc.mjs +9 -11
- package/assets/docs/tree/see-it-in-an-app.doc.mjs +17 -5
- package/assets/docs/tree/short-and-findable.doc.mjs +9 -8
- package/assets/docs/tree/single-component.doc.mjs +14 -6
- package/assets/docs/tree/start-a-template.doc.mjs +18 -8
- package/assets/docs/tree/subcomponent.doc.mjs +6 -6
- package/assets/docs/tree/template-assets.doc.mjs +2 -2
- package/assets/docs/tree/template-doc-overview.doc.mjs +6 -17
- package/assets/docs/tree/template-fonts.doc.mjs +1 -1
- package/assets/docs/tree/template-icons.doc.mjs +1 -1
- package/assets/docs/tree/template-images-media.doc.mjs +1 -1
- package/assets/docs/tree/template-styles.doc.mjs +2 -2
- package/assets/docs/tree/templates.doc.mjs +7 -1
- package/assets/docs/tree/test-in-an-app.doc.mjs +7 -7
- package/assets/docs/tree/test-template-in-app.doc.mjs +5 -5
- package/assets/docs/tree/themes.doc.mjs +4 -0
- package/assets/docs/tree/tokens-and-setup.doc.mjs +19 -15
- package/assets/docs/tree/troubleshooting.doc.mjs +22 -14
- package/assets/docs/tree/uninstall.doc.mjs +109 -0
- package/assets/docs/tree/updates.doc.mjs +92 -0
- package/assets/docs/tree/upgrading.doc.mjs +6 -6
- package/assets/docs/tree/use-a-theme-in-an-app.doc.mjs +14 -2
- package/assets/docs/tree/verify-packed-template.doc.mjs +3 -3
- package/assets/docs/tree/versioning.doc.mjs +9 -9
- package/assets/docs/tree/who-answers.doc.mjs +119 -0
- package/assets/docs/tree/write-the-template-file.doc.mjs +5 -5
- package/assets/docs/working-with-ai.doc.mjs +23 -9
- package/authoring/codemod/codemod.doc.mjs +7 -3
- package/authoring/config/config.doc.mjs +9 -6
- package/authoring/config/type.ts +4 -2
- package/authoring/debug/parse.d.mts +5 -5
- package/authoring/discover/discover.doc.mjs +7 -1
- package/authoring/doctypes/_schema.d.mts +8 -3
- package/authoring/doctypes/_schema.mjs +1 -0
- package/authoring/doctypes/base/graph-fields.doc.mjs +6 -4
- package/authoring/doctypes/command/command.doc.mjs +20 -12
- package/authoring/doctypes/command/type.ts +14 -0
- package/authoring/doctypes/component/component.doc.mjs +29 -26
- package/authoring/doctypes/component/type.ts +15 -5
- package/authoring/doctypes/enum/enum.doc.mjs +7 -4
- package/authoring/doctypes/function/function.doc.mjs +9 -8
- package/authoring/doctypes/hook/hook.doc.mjs +5 -4
- package/authoring/doctypes/hook/type.ts +4 -0
- package/authoring/doctypes/namespace/namespace.doc.mjs +7 -6
- package/authoring/doctypes/reference/reference.doc.mjs +12 -11
- package/authoring/doctypes/schema/schema.doc.mjs +9 -9
- package/authoring/doctypes/template/template.doc.mjs +2 -2
- package/authoring/doctypes/theme/theme.doc.mjs +2 -2
- package/authoring/gap-report/gap-report.doc.mjs +2 -2
- package/authoring/identity/identity.doc.mjs +3 -3
- package/authoring/index.d.ts +1 -0
- package/authoring/integration/integration.doc.mjs +22 -5
- package/authoring/integration/parse.test.mjs +11 -0
- package/authoring/integration/schema.d.mts +14 -3
- package/authoring/integration/schema.mjs +33 -2
- package/authoring/integration/type.ts +8 -0
- package/clients/cli/bin/astryx.mjs +20 -3
- package/clients/cli/cli-command.test.mjs +166 -0
- package/clients/cli/commands/blog.doc.mjs +1 -0
- package/clients/cli/commands/build-theme.batch-report.test.mjs +14 -2
- package/clients/cli/commands/build-theme.font-warning.test.mjs +3 -3
- package/clients/cli/commands/build-theme.mjs +25 -12
- package/clients/cli/commands/build.doc.mjs +1 -0
- package/clients/cli/commands/build.exit-codes-doc.test.mjs +4 -0
- package/clients/cli/commands/cli-postinstall.test.mjs +49 -1
- package/clients/cli/commands/component.doc.mjs +1 -0
- package/clients/cli/commands/discover.broken-integration.test.mjs +11 -7
- package/clients/cli/commands/discover.doc.mjs +9 -1
- package/clients/cli/commands/discover.mjs +144 -93
- package/clients/cli/commands/discover.no-source.test.mjs +448 -33
- package/clients/cli/commands/discover.sources.test.mjs +20 -1
- package/clients/cli/commands/docs.doc.mjs +1 -0
- package/clients/cli/commands/docs.mjs +49 -19
- package/clients/cli/commands/docs.test.mjs +2 -2
- package/clients/cli/commands/doctor-integration-components.doc.mjs +7 -3
- package/clients/cli/commands/doctor-integration-docs.doc.mjs +20 -5
- package/clients/cli/commands/doctor-integration-templates.doc.mjs +1 -0
- package/clients/cli/commands/doctor-integration-validate.doc.mjs +13 -2
- package/clients/cli/commands/doctor-integration.test.mjs +52 -4
- package/clients/cli/commands/doctor.doc.mjs +12 -2
- package/clients/cli/commands/doctor.mjs +102 -7
- package/clients/cli/commands/doctor.test.mjs +10 -4
- package/clients/cli/commands/gap-report.doc.mjs +15 -2
- package/clients/cli/commands/gap-report.mjs +24 -2
- package/clients/cli/commands/gap-report.test.mjs +79 -0
- package/clients/cli/commands/hook.doc.mjs +1 -0
- package/clients/cli/commands/init.behavior.test.mjs +5 -0
- package/clients/cli/commands/init.doc.mjs +29 -3
- package/clients/cli/commands/integration-add.doc.mjs +7 -0
- package/clients/cli/commands/integration-authoring.test.mjs +49 -3
- package/clients/cli/commands/integration-check.doc.mjs +10 -2
- package/clients/cli/commands/integration-check.test.mjs +175 -1
- package/clients/cli/commands/integration-pack.doc.mjs +8 -1
- package/clients/cli/commands/integration-verify.doc.mjs +8 -1
- package/clients/cli/commands/integration.mjs +15 -9
- package/clients/cli/commands/json-contract.test.mjs +11 -2
- package/clients/cli/commands/manifest.doc.mjs +1 -0
- package/clients/cli/commands/outside-project.test.mjs +774 -0
- package/clients/cli/commands/search.doc.mjs +1 -0
- package/clients/cli/commands/search.mjs +16 -11
- package/clients/cli/commands/search.test.mjs +429 -296
- package/clients/cli/commands/setup-nudge.test.mjs +102 -2
- package/clients/cli/commands/swizzle.doc.mjs +7 -0
- package/clients/cli/commands/swizzle.mjs +5 -3
- package/clients/cli/commands/swizzle.routing.test.mjs +19 -2
- package/clients/cli/commands/template.doc.mjs +1 -0
- package/clients/cli/commands/theme-add-lifecycle.test.mjs +3 -3
- package/clients/cli/commands/theme-add.doc.mjs +8 -1
- package/clients/cli/commands/theme-build.doc.mjs +1 -0
- package/clients/cli/commands/theme-eject.doc.mjs +1 -0
- package/clients/cli/commands/theme-list.behavior.test.mjs +6 -0
- package/clients/cli/commands/theme-list.doc.mjs +1 -0
- package/clients/cli/commands/theme-palette-generate.doc.mjs +1 -0
- package/clients/cli/commands/theme-remove.doc.mjs +7 -0
- package/clients/cli/commands/theme-targets.doc.mjs +1 -0
- package/clients/cli/commands/theme-template.doc.mjs +1 -0
- package/clients/cli/commands/theme-use.doc.mjs +7 -0
- package/clients/cli/commands/upgrade.config-ordering.test.mjs +2 -1
- package/clients/cli/commands/upgrade.doc.mjs +22 -1
- package/clients/cli/commands/upgrade.file-protection.test.mjs +3 -2
- package/clients/cli/commands/upgrade.hook-output.test.mjs +2 -1
- package/clients/cli/commands/upgrade.older-cli.test.mjs +160 -0
- package/clients/cli/index.mjs +111 -16
- package/clients/cli/lib/cli-error.mjs +4 -1
- package/clients/cli/lib/component-format.test.mjs +1 -1
- package/clients/cli/lib/define-command.mjs +2 -2
- package/clients/cli/lib/json-shim.mjs +3 -1
- package/clients/cli/lib/manifest.d.ts +9 -0
- package/clients/cli/lib/manifest.mjs +17 -1
- package/clients/cli/lib/manifest.test.mjs +25 -0
- package/foundation/agent-docs/agent-doc-state.d.mts +35 -6
- package/foundation/agent-docs/agent-doc-state.mjs +66 -6
- package/foundation/agent-docs/agent-docs.d.mts +4 -3
- package/foundation/agent-docs/agent-docs.mjs +11 -9
- package/foundation/agent-docs/computer-agent-text.d.mts +133 -0
- package/foundation/agent-docs/computer-agent-text.mjs +623 -0
- package/foundation/agent-docs/computer-agent-text.test.mjs +602 -0
- package/foundation/config/project.d.mts +35 -3
- package/foundation/config/project.mjs +145 -8
- package/foundation/config/project.test.mjs +66 -0
- package/foundation/config/theme-state.mjs +5 -4
- package/foundation/discovery/authoring-self-docs.mjs +1 -1
- package/foundation/discovery/cli-self-docs.mjs +20 -3
- package/foundation/discovery/cli-self-docs.test.mjs +3 -2
- package/foundation/discovery/component-replacement.mjs +2 -1
- package/foundation/discovery/copied-passages.d.mts +216 -0
- package/foundation/discovery/copied-passages.mjs +720 -0
- package/foundation/discovery/copied-passages.test.mjs +124 -0
- package/foundation/discovery/doc-consistency.d.mts +34 -0
- package/foundation/discovery/doc-consistency.mjs +513 -0
- package/foundation/discovery/doc-consistency.test.mjs +347 -0
- package/foundation/discovery/doc-fluff.d.mts +112 -0
- package/foundation/discovery/doc-fluff.mjs +328 -0
- package/foundation/discovery/doc-fluff.test.mjs +142 -0
- package/foundation/discovery/doc-types.d.mts +1 -1
- package/foundation/discovery/doc-types.mjs +15 -8
- package/foundation/discovery/doc-types.test.mjs +94 -2
- package/foundation/discovery/docs-discovery.d.mts +7 -0
- package/foundation/discovery/docs-discovery.mjs +26 -2
- package/foundation/discovery/project-typescript.d.mts +11 -2
- package/foundation/discovery/project-typescript.mjs +32 -4
- package/foundation/discovery/prop-drift.d.mts +26 -7
- package/foundation/discovery/prop-drift.mjs +173 -8
- package/foundation/discovery/prop-drift.test.mjs +120 -2
- package/foundation/discovery/runner-allowlist.d.mts +40 -0
- package/foundation/discovery/runner-allowlist.mjs +53 -0
- package/foundation/discovery/stray-pointers.d.mts +52 -0
- package/foundation/discovery/stray-pointers.mjs +293 -0
- package/foundation/discovery/stray-pointers.test.mjs +86 -0
- package/foundation/discovery/template-needs.mjs +5 -2
- package/foundation/discovery/theme-imports.mjs +8 -4
- package/foundation/discovery/theme-imports.test.mjs +1 -1
- package/foundation/discovery/type-text.mjs +1 -1
- package/foundation/discovery/type-text.test.mjs +1 -1
- package/foundation/doc-compiler/doc-loads.test.mjs +42 -2
- package/foundation/doc-compiler/inputs.mjs +4 -1
- package/foundation/doc-compiler/links.d.mts +32 -0
- package/foundation/doc-compiler/links.mjs +163 -26
- package/foundation/doc-compiler/links.test.mjs +97 -4
- package/foundation/doc-compiler/read.d.mts +1 -1
- package/foundation/doc-compiler/tree.mjs +30 -1
- package/foundation/doc-compiler/tree.test.mjs +8 -1
- package/foundation/env/cli-command.d.mts +83 -0
- package/foundation/env/cli-command.mjs +152 -0
- package/foundation/env/cli-command.test.mjs +129 -0
- package/foundation/env/node-version.test.mjs +15 -3
- package/foundation/env/package-manager.d.mts +93 -9
- package/foundation/env/package-manager.global.test.mjs +66 -0
- package/foundation/env/package-manager.mjs +184 -13
- package/foundation/env/package-manager.test.mjs +341 -15
- package/foundation/env/project-scope.d.mts +9 -0
- package/foundation/env/project-scope.mjs +33 -0
- package/foundation/env/search-path.d.mts +42 -0
- package/foundation/env/search-path.mjs +205 -0
- package/foundation/env/search-path.test.mjs +122 -0
- package/foundation/env/user-dirs.d.mts +39 -0
- package/foundation/env/user-dirs.mjs +93 -0
- package/foundation/env/user-dirs.test.mjs +72 -0
- package/foundation/env/user-settings.d.mts +46 -0
- package/foundation/env/user-settings.mjs +106 -0
- package/foundation/env/user-settings.test.mjs +64 -0
- package/foundation/fs/file-protection.mjs +15 -1
- package/foundation/fs/module-loader.d.mts +2 -1
- package/foundation/fs/module-loader.mjs +60 -4
- package/foundation/fs/module-loader.stdout.test.mjs +3 -2
- package/foundation/fs/module-loader.test.mjs +194 -0
- package/foundation/fs/path-compare.d.mts +33 -0
- package/foundation/fs/path-compare.mjs +73 -0
- package/foundation/fs/paths.d.mts +57 -1
- package/foundation/fs/paths.mjs +131 -3
- package/foundation/fs/paths.test.mjs +10 -1
- package/foundation/integrations/computer-integrations.d.mts +78 -0
- package/foundation/integrations/computer-integrations.mjs +316 -0
- package/foundation/integrations/computer-integrations.test.mjs +292 -0
- package/foundation/integrations/contribution-inventory.d.mts +11 -0
- package/foundation/integrations/contribution-inventory.mjs +50 -2
- package/foundation/integrations/integration-warnings.mjs +3 -2
- package/foundation/integrations/integration-warnings.test.mjs +8 -9
- package/foundation/integrations/integrations.d.mts +25 -1
- package/foundation/integrations/integrations.mjs +46 -3
- package/foundation/integrations/issue.ts +6 -0
- package/foundation/integrations/validate-contributions.d.mts +2 -0
- package/foundation/integrations/validate-contributions.mjs +41 -1
- package/foundation/release/agent-text-worker.d.mts +4 -0
- package/foundation/release/agent-text-worker.mjs +52 -0
- package/foundation/release/cache.d.mts +310 -0
- package/foundation/release/cache.mjs +1216 -0
- package/foundation/release/cache.test.mjs +920 -0
- package/foundation/release/choose.d.mts +207 -0
- package/foundation/release/choose.mjs +677 -0
- package/foundation/release/choose.test.mjs +427 -0
- package/foundation/release/core-worker.d.mts +4 -0
- package/foundation/release/core-worker.mjs +38 -0
- package/foundation/release/global-state.d.mts +259 -0
- package/foundation/release/global-state.mjs +472 -0
- package/foundation/release/global-state.test.mjs +381 -0
- package/foundation/release/handoff.d.mts +60 -0
- package/foundation/release/handoff.e2e.test.mjs +278 -0
- package/foundation/release/handoff.mjs +357 -0
- package/foundation/release/lock-lease.d.mts +23 -0
- package/foundation/release/lock-lease.mjs +80 -0
- package/foundation/release/notices.d.mts +253 -0
- package/foundation/release/notices.mjs +596 -0
- package/foundation/release/notices.test.mjs +565 -0
- package/foundation/release/npm-guard.d.mts +4 -0
- package/foundation/release/npm-guard.mjs +126 -0
- package/foundation/release/project.d.mts +141 -0
- package/foundation/release/project.mjs +466 -0
- package/foundation/release/project.test.mjs +288 -0
- package/foundation/release/refresh-worker.d.mts +4 -0
- package/foundation/release/refresh-worker.mjs +27 -0
- package/foundation/release/run-context.d.mts +47 -0
- package/foundation/release/run-context.mjs +149 -0
- package/foundation/release/update-notice-env.d.mts +28 -0
- package/foundation/release/update-notice-env.mjs +39 -0
- package/foundation/response/base.d.ts +26 -3
- package/foundation/response/error-codes.d.mts +3 -1
- package/foundation/response/error-codes.doc.mjs +12 -0
- package/foundation/response/error-codes.mjs +17 -0
- package/foundation/response/error-codes.test.mjs +2 -0
- package/foundation/response/json-contract.test.mjs +7 -2
- package/foundation/response/json.mjs +4 -1
- package/foundation/response/response-types.doc.mjs +17 -11
- package/foundation/response/response-types.doc.test.mjs +46 -0
- package/foundation/response/response.doc.mjs +3 -3
- package/foundation/xle/browser-imports.test.mjs +74 -0
- package/package.json +11 -11
- package/scripts/postinstall.mjs +24 -5
- /package/assets/codemods/transforms/{next → v0.7.2}/__tests__/migrate-list-edge-compensation-to-edge.test.mjs +0 -0
- /package/assets/codemods/transforms/{next → v0.7.2}/migrate-list-edge-compensation-to-edge.mjs +0 -0
|
@@ -0,0 +1,1216 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file The per-user cache of CLI releases.
|
|
5
|
+
*
|
|
6
|
+
* Global mode answers some runs with a release other than the copy that
|
|
7
|
+
* started them (spec AST-081 FR2, FR6): the release that matches a project's
|
|
8
|
+
* Core, the newest release for the global copy, or the Core a copy carries
|
|
9
|
+
* outside a project. Each is installed here, once, through the package
|
|
10
|
+
* manager, and reused by every later run:
|
|
11
|
+
*
|
|
12
|
+
* <user cache>/astryx/releases/<version>/
|
|
13
|
+
* package.json (an empty private package)
|
|
14
|
+
* node_modules/@astryxdesign/cli
|
|
15
|
+
* node_modules/@astryxdesign/core
|
|
16
|
+
* release.json (written last: the release is verified)
|
|
17
|
+
*
|
|
18
|
+
* A release counts only once `release.json` exists. It is written after the
|
|
19
|
+
* install finished, the CLI started and reported its own version, and the
|
|
20
|
+
* release supports this computer's Node. An install builds in a temporary
|
|
21
|
+
* folder beside the others and is renamed into place, so a run never sees a
|
|
22
|
+
* half-installed release, and a lock keeps two runs from installing the same
|
|
23
|
+
* release at once.
|
|
24
|
+
*
|
|
25
|
+
* The package manager is npm, started from the Node that runs the CLI, so the
|
|
26
|
+
* registry, proxy, and offline settings in the user's npm configuration
|
|
27
|
+
* apply. No Astryx setting changes where or how releases install.
|
|
28
|
+
*
|
|
29
|
+
* @input a release version
|
|
30
|
+
* @output the release's folder, or why it is unavailable
|
|
31
|
+
* @position foundation/release — used by the bin's hand-off, by the carried
|
|
32
|
+
* Core lookup, and by the background refresh worker.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import * as fs from 'node:fs';
|
|
36
|
+
import * as os from 'node:os';
|
|
37
|
+
import * as path from 'node:path';
|
|
38
|
+
import {spawnSync} from 'node:child_process';
|
|
39
|
+
import {createHash} from 'node:crypto';
|
|
40
|
+
import {fileURLToPath} from 'node:url';
|
|
41
|
+
import {satisfiesRange} from '../env/semver.mjs';
|
|
42
|
+
import {readJson} from './project.mjs';
|
|
43
|
+
import {userCacheDir as userCacheDirFor} from '../env/user-dirs.mjs';
|
|
44
|
+
import {foreignLockIsSilent} from './lock-lease.mjs';
|
|
45
|
+
import {relativeWithin} from '../fs/path-compare.mjs';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The per-user cache directory every Astryx cache lives in, never inside a
|
|
49
|
+
* project (`foundation/env/user-dirs.mjs`).
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function userCacheDir() {
|
|
53
|
+
return userCacheDirFor();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The folder all cached releases live in.
|
|
58
|
+
* @param {string} [cacheDir]
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
export function releasesDir(cacheDir = userCacheDir()) {
|
|
62
|
+
return path.join(cacheDir, 'releases');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A release version safe to use as a folder name and an npm spec. */
|
|
66
|
+
const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Is `version` a plain release version?
|
|
70
|
+
* @param {unknown} version
|
|
71
|
+
* @returns {version is string}
|
|
72
|
+
*/
|
|
73
|
+
export function isReleaseVersion(version) {
|
|
74
|
+
return typeof version === 'string' && VERSION_RE.test(version);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A cached, verified release.
|
|
79
|
+
* @typedef {object} CachedRelease
|
|
80
|
+
* @property {string} version
|
|
81
|
+
* @property {string} dir the release folder
|
|
82
|
+
* @property {string} cliDir its `@astryxdesign/cli`
|
|
83
|
+
* @property {string} coreDir its `@astryxdesign/core`
|
|
84
|
+
* @property {string} bin the CLI's `astryx` entry file
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The entry file a CLI package's `astryx` bin points at.
|
|
89
|
+
* @param {string} cliDir
|
|
90
|
+
* @returns {string | null}
|
|
91
|
+
*/
|
|
92
|
+
export function cliBinOf(cliDir) {
|
|
93
|
+
const pkg = readJson(path.join(cliDir, 'package.json'));
|
|
94
|
+
const bin =
|
|
95
|
+
typeof pkg?.bin === 'string' ? pkg.bin : pkg?.bin && pkg.bin.astryx;
|
|
96
|
+
if (typeof bin !== 'string') return null;
|
|
97
|
+
const file = path.join(cliDir, bin);
|
|
98
|
+
return fs.existsSync(file) ? file : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The files whose bytes a cached release's marker records, relative to the
|
|
103
|
+
* release folder: enough to notice a damaged or half-written release without
|
|
104
|
+
* reading every file on each run.
|
|
105
|
+
* @param {string} bin the CLI's entry file
|
|
106
|
+
* @param {string} dir the release folder
|
|
107
|
+
* @returns {string[]}
|
|
108
|
+
*/
|
|
109
|
+
function checkedFiles(bin, dir) {
|
|
110
|
+
return [
|
|
111
|
+
path.join('node_modules', '@astryxdesign', 'cli', 'package.json'),
|
|
112
|
+
path.join('node_modules', '@astryxdesign', 'core', 'package.json'),
|
|
113
|
+
path.relative(dir, bin),
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The sha256 of a file, or null when it cannot be read.
|
|
119
|
+
* @param {string} file
|
|
120
|
+
* @returns {string | null}
|
|
121
|
+
*/
|
|
122
|
+
function fileHash(file) {
|
|
123
|
+
try {
|
|
124
|
+
return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The cached release `version`, when it is installed and verified: its
|
|
132
|
+
* marker parses, names the version, and records the bytes of the CLI's and
|
|
133
|
+
* Core's package.json and the CLI's entry file, and those files still have
|
|
134
|
+
* them, and the number and total size of the two packages' files are the
|
|
135
|
+
* ones recorded. Anything else is a damaged release, which counts as absent and is
|
|
136
|
+
* replaced the next time a run needs it.
|
|
137
|
+
* @param {string} version
|
|
138
|
+
* @param {string} [cacheDir]
|
|
139
|
+
* @returns {CachedRelease | null}
|
|
140
|
+
*/
|
|
141
|
+
export function cachedRelease(version, cacheDir = userCacheDir()) {
|
|
142
|
+
if (!isReleaseVersion(version)) return null;
|
|
143
|
+
const key = `${cacheDir}\0${version}`;
|
|
144
|
+
if (verified.has(key)) return verified.get(key) ?? null;
|
|
145
|
+
const result = verifyRelease(version, cacheDir);
|
|
146
|
+
// Only a verified release is remembered; an absent one is looked at again,
|
|
147
|
+
// since another run may be installing it.
|
|
148
|
+
if (result) verified.set(key, result);
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Releases this process already verified, so a run checks each one once.
|
|
154
|
+
* @type {Map<string, CachedRelease>}
|
|
155
|
+
*/
|
|
156
|
+
const verified = new Map();
|
|
157
|
+
|
|
158
|
+
/** Forget which releases this process verified, so the next look checks again. */
|
|
159
|
+
export function forgetVerifiedReleases() {
|
|
160
|
+
verified.clear();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The number of files and their total size under a package folder.
|
|
165
|
+
* @param {string} dir
|
|
166
|
+
* @returns {{count: number, bytes: number}}
|
|
167
|
+
*/
|
|
168
|
+
function treeSize(dir) {
|
|
169
|
+
let count = 0;
|
|
170
|
+
let bytes = 0;
|
|
171
|
+
/** @param {string} d */
|
|
172
|
+
const walk = d => {
|
|
173
|
+
for (const entry of fs.readdirSync(d, {withFileTypes: true})) {
|
|
174
|
+
const p = path.join(d, entry.name);
|
|
175
|
+
if (entry.isDirectory()) walk(p);
|
|
176
|
+
else {
|
|
177
|
+
count += 1;
|
|
178
|
+
bytes += fs.statSync(p).size;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
walk(dir);
|
|
183
|
+
return {count, bytes};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The size record of a release's two packages.
|
|
188
|
+
* @param {string} dir the release folder
|
|
189
|
+
* @returns {{cli: {count: number, bytes: number}, core: {count: number, bytes: number}} | null}
|
|
190
|
+
*/
|
|
191
|
+
export function releaseTree(dir) {
|
|
192
|
+
try {
|
|
193
|
+
return {
|
|
194
|
+
cli: treeSize(path.join(dir, 'node_modules', '@astryxdesign', 'cli')),
|
|
195
|
+
core: treeSize(path.join(dir, 'node_modules', '@astryxdesign', 'core')),
|
|
196
|
+
};
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The check behind {@link cachedRelease}.
|
|
204
|
+
* @param {string} version
|
|
205
|
+
* @param {string} cacheDir
|
|
206
|
+
* @returns {CachedRelease | null}
|
|
207
|
+
*/
|
|
208
|
+
function verifyRelease(version, cacheDir) {
|
|
209
|
+
const dir = path.join(releasesDir(cacheDir), version);
|
|
210
|
+
const marker = readJson(path.join(dir, 'release.json'));
|
|
211
|
+
if (!marker || marker.version !== version || !marker.files) return null;
|
|
212
|
+
const cliDir = path.join(dir, 'node_modules', '@astryxdesign', 'cli');
|
|
213
|
+
const coreDir = path.join(dir, 'node_modules', '@astryxdesign', 'core');
|
|
214
|
+
const bin = cliBinOf(cliDir);
|
|
215
|
+
if (!bin) return null;
|
|
216
|
+
for (const rel of checkedFiles(bin, dir)) {
|
|
217
|
+
const want = marker.files[rel.split(path.sep).join('/')];
|
|
218
|
+
if (typeof want !== 'string' || fileHash(path.join(dir, rel)) !== want) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// Every other file of the two packages: a deleted or truncated module
|
|
223
|
+
// changes the count or the total size.
|
|
224
|
+
if (
|
|
225
|
+
JSON.stringify(releaseTree(dir)) !== JSON.stringify(marker.tree ?? null)
|
|
226
|
+
) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
return {version, dir, cliDir, coreDir, bin};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Is `dir` inside the release cache? A copy that lives there was started by
|
|
234
|
+
* global mode, never installed by a person.
|
|
235
|
+
* @param {string} dir
|
|
236
|
+
* @param {string} [cacheDir]
|
|
237
|
+
* @returns {boolean}
|
|
238
|
+
*/
|
|
239
|
+
export function isInReleaseCache(dir, cacheDir = userCacheDir()) {
|
|
240
|
+
return (
|
|
241
|
+
relativeWithin(realOrSelf(releasesDir(cacheDir)), realOrSelf(dir)) !== null
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {string} p
|
|
247
|
+
* @returns {string}
|
|
248
|
+
*/
|
|
249
|
+
function realOrSelf(p) {
|
|
250
|
+
try {
|
|
251
|
+
return fs.realpathSync(p);
|
|
252
|
+
} catch {
|
|
253
|
+
return path.resolve(p);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* How to start npm, never through a shell, so nothing a project declares is
|
|
259
|
+
* read by one: npm's own entry file run with this Node, from beside the
|
|
260
|
+
* running Node, else from beside an `npm` on the PATH (on Windows, npm's
|
|
261
|
+
* `npm.cmd` shim sits next to its `node_modules`); else `npm` on the PATH,
|
|
262
|
+
* started directly. An `npm` beside the running Node or on the PATH that
|
|
263
|
+
* links to npm's entry file (as distribution packages install it, such as
|
|
264
|
+
* `/usr/bin/npm` to `/usr/lib/node_modules_22/npm/bin/npm-cli.js`) counts as
|
|
265
|
+
* that entry file.
|
|
266
|
+
* @param {{env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, execPath?: string}} [where]
|
|
267
|
+
* `execPath`: the Node that runs npm (default: this one)
|
|
268
|
+
* @returns {{command: string, prefix: string[], shell: false}}
|
|
269
|
+
*/
|
|
270
|
+
export function npmCommand(where = {}) {
|
|
271
|
+
const {
|
|
272
|
+
env = process.env,
|
|
273
|
+
platform = process.platform,
|
|
274
|
+
execPath = process.execPath,
|
|
275
|
+
} = where;
|
|
276
|
+
const paths = platform === 'win32' ? path.win32 : path.posix;
|
|
277
|
+
const nodeDir = paths.dirname(execPath);
|
|
278
|
+
const entry = (/** @type {string} */ dir) =>
|
|
279
|
+
paths.join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
|
280
|
+
const candidates = [
|
|
281
|
+
...(platform === 'win32'
|
|
282
|
+
? [entry(nodeDir)]
|
|
283
|
+
: [
|
|
284
|
+
paths.join(
|
|
285
|
+
nodeDir,
|
|
286
|
+
'..',
|
|
287
|
+
'lib',
|
|
288
|
+
'node_modules',
|
|
289
|
+
'npm',
|
|
290
|
+
'bin',
|
|
291
|
+
'npm-cli.js',
|
|
292
|
+
),
|
|
293
|
+
entry(nodeDir),
|
|
294
|
+
]),
|
|
295
|
+
...String(env.PATH ?? env.Path ?? '')
|
|
296
|
+
.split(platform === 'win32' ? ';' : ':')
|
|
297
|
+
.filter(Boolean)
|
|
298
|
+
.filter(dir =>
|
|
299
|
+
fs.existsSync(
|
|
300
|
+
paths.join(dir, platform === 'win32' ? 'npm.cmd' : 'npm'),
|
|
301
|
+
),
|
|
302
|
+
)
|
|
303
|
+
.flatMap(dir =>
|
|
304
|
+
platform === 'win32'
|
|
305
|
+
? [entry(dir)]
|
|
306
|
+
: [
|
|
307
|
+
paths.join(
|
|
308
|
+
dir,
|
|
309
|
+
'..',
|
|
310
|
+
'lib',
|
|
311
|
+
'node_modules',
|
|
312
|
+
'npm',
|
|
313
|
+
'bin',
|
|
314
|
+
'npm-cli.js',
|
|
315
|
+
),
|
|
316
|
+
],
|
|
317
|
+
),
|
|
318
|
+
];
|
|
319
|
+
if (platform !== 'win32') {
|
|
320
|
+
// An `npm` that links to npm's entry file: beside the running Node first.
|
|
321
|
+
for (const dir of [
|
|
322
|
+
nodeDir,
|
|
323
|
+
...String(env.PATH ?? '')
|
|
324
|
+
.split(':')
|
|
325
|
+
.filter(Boolean),
|
|
326
|
+
]) {
|
|
327
|
+
try {
|
|
328
|
+
const real = fs.realpathSync(paths.join(dir, 'npm'));
|
|
329
|
+
if (paths.basename(real) === 'npm-cli.js') candidates.push(real);
|
|
330
|
+
} catch {
|
|
331
|
+
// No npm there.
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
for (const file of candidates) {
|
|
336
|
+
if (fs.existsSync(file)) {
|
|
337
|
+
return {command: execPath, prefix: [file], shell: false};
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return {command: 'npm', prefix: [], shell: false};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Whether `spec` reads as an npm version range and nothing else: versions,
|
|
345
|
+
* `x`/`*` wildcards, prerelease tags, the range operators, `||`, and spaces.
|
|
346
|
+
* Anything a shell or npm would read as more, such as `&`, `;`, a lone `|`,
|
|
347
|
+
* quotes, or a URL, is refused.
|
|
348
|
+
* @param {string} spec
|
|
349
|
+
* @returns {boolean}
|
|
350
|
+
*/
|
|
351
|
+
export function isVersionRange(spec) {
|
|
352
|
+
if (typeof spec !== 'string' || spec.trim() === '' || spec.length > 256) {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
if (/[^0-9A-Za-z.\-+^~<>=|*\s]/u.test(spec)) return false;
|
|
356
|
+
return !/(?:^|[^|])\|(?:[^|]|$)/u.test(spec);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The result of running npm.
|
|
361
|
+
* @typedef {object} NpmResult
|
|
362
|
+
* @property {number | null} status
|
|
363
|
+
* @property {NodeJS.Signals | null} [signal] the signal that ended npm, such as Ctrl-C's
|
|
364
|
+
* @property {string} stdout
|
|
365
|
+
* @property {string} stderr
|
|
366
|
+
* @property {Error} [error]
|
|
367
|
+
*/
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Run npm synchronously with no output on the caller's streams.
|
|
371
|
+
* @param {string[]} args
|
|
372
|
+
* npm runs under npm-guard.mjs, which gives it a process group of its own
|
|
373
|
+
* and ends that group when the time limit passes, when this run goes away
|
|
374
|
+
* (even killed outright), or when this run receives Ctrl-C. So no install
|
|
375
|
+
* outlives the run that started it.
|
|
376
|
+
*
|
|
377
|
+
* @param {string[]} args
|
|
378
|
+
* @param {{cwd?: string, timeoutMs?: number, pidFile?: string, npm?: {command: string, prefix: string[], shell?: false}}} [options]
|
|
379
|
+
* `pidFile` receives npm's process id while it runs (the release lock's
|
|
380
|
+
* record); `npm` replaces how npm is started (tests).
|
|
381
|
+
* @returns {NpmResult}
|
|
382
|
+
*/
|
|
383
|
+
export function runNpm(args, options = {}) {
|
|
384
|
+
const {command, prefix} = options.npm ?? npmCommand();
|
|
385
|
+
const timeoutMs = options.timeoutMs ?? 300_000;
|
|
386
|
+
const direct = process.platform === 'win32';
|
|
387
|
+
const result = direct
|
|
388
|
+
? spawnSync(command, [...prefix, ...args], {
|
|
389
|
+
cwd: options.cwd ?? os.tmpdir(),
|
|
390
|
+
encoding: 'utf-8',
|
|
391
|
+
// Never a shell: a declared range must not reach one.
|
|
392
|
+
shell: false,
|
|
393
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
394
|
+
timeout: timeoutMs,
|
|
395
|
+
// npm defers SIGTERM until its work finishes, so a time limit has to
|
|
396
|
+
// end it outright or the limit never takes effect.
|
|
397
|
+
killSignal: 'SIGKILL',
|
|
398
|
+
windowsHide: true,
|
|
399
|
+
})
|
|
400
|
+
: spawnSync(
|
|
401
|
+
process.execPath,
|
|
402
|
+
[
|
|
403
|
+
GUARD,
|
|
404
|
+
String(timeoutMs),
|
|
405
|
+
options.pidFile ?? '',
|
|
406
|
+
command,
|
|
407
|
+
...prefix,
|
|
408
|
+
...args,
|
|
409
|
+
],
|
|
410
|
+
{
|
|
411
|
+
cwd: options.cwd ?? os.tmpdir(),
|
|
412
|
+
encoding: 'utf-8',
|
|
413
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
414
|
+
// A backstop only: the guard ends npm at the limit itself.
|
|
415
|
+
timeout: timeoutMs + 30_000,
|
|
416
|
+
killSignal: 'SIGKILL',
|
|
417
|
+
windowsHide: true,
|
|
418
|
+
},
|
|
419
|
+
);
|
|
420
|
+
const timedOut = !direct && result.status === 124;
|
|
421
|
+
return {
|
|
422
|
+
status: timedOut ? null : result.status,
|
|
423
|
+
signal: result.signal ?? null,
|
|
424
|
+
stdout: result.stdout ?? '',
|
|
425
|
+
stderr: result.stderr ?? '',
|
|
426
|
+
...(timedOut
|
|
427
|
+
? {error: Object.assign(new Error('npm timed out'), {code: 'ETIMEDOUT'})}
|
|
428
|
+
: result.error
|
|
429
|
+
? {error: result.error}
|
|
430
|
+
: {}),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The guard npm runs under. */
|
|
435
|
+
const GUARD = fileURLToPath(new URL('./npm-guard.mjs', import.meta.url));
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The newest published version that `spec` (a dist-tag or a range) names,
|
|
439
|
+
* asked of the registry through npm.
|
|
440
|
+
* @param {string} name e.g. `@astryxdesign/core`
|
|
441
|
+
* @param {string} spec e.g. `latest`, `^0.6.8`
|
|
442
|
+
* @param {{run?: typeof runNpm}} [options]
|
|
443
|
+
* @returns {string | null}
|
|
444
|
+
*/
|
|
445
|
+
export function resolvePublishedVersion(name, spec, options = {}) {
|
|
446
|
+
if (!isVersionRange(spec) || !/^@astryxdesign\/[a-z-]+$/u.test(name)) {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
const run = options.run ?? runNpm;
|
|
450
|
+
const result = run(
|
|
451
|
+
['view', `${name}@${spec}`, 'version', '--json', ...BOUNDED_FETCH],
|
|
452
|
+
{timeoutMs: 60_000},
|
|
453
|
+
);
|
|
454
|
+
if (result.status !== 0) return null;
|
|
455
|
+
let parsed;
|
|
456
|
+
try {
|
|
457
|
+
parsed = JSON.parse(result.stdout || 'null');
|
|
458
|
+
} catch {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
const versions = (Array.isArray(parsed) ? parsed : [parsed]).filter(
|
|
462
|
+
isReleaseVersion,
|
|
463
|
+
);
|
|
464
|
+
return versions.length > 0 ? versions[versions.length - 1] : null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* The release the registry tags `latest` for the CLI, and the Node range it
|
|
469
|
+
* declares; null when the registry cannot be asked, with the reason given to
|
|
470
|
+
* `onFailure`: `npm-missing` (npm cannot be started), `npm-failed: <npm's
|
|
471
|
+
* error code>` (npm ran and failed, such as on ECONNREFUSED or E404), or
|
|
472
|
+
* `bad-reply` (npm's answer names no release).
|
|
473
|
+
* @param {{run?: typeof runNpm, onFailure?: (reason: string) => void}} [options]
|
|
474
|
+
* @returns {{version: string, node: string | null} | null}
|
|
475
|
+
*/
|
|
476
|
+
export function resolveLatestRelease(options = {}) {
|
|
477
|
+
const run = options.run ?? runNpm;
|
|
478
|
+
const fail = (/** @type {string} */ reason) => {
|
|
479
|
+
options.onFailure?.(reason);
|
|
480
|
+
return null;
|
|
481
|
+
};
|
|
482
|
+
const result = run(
|
|
483
|
+
[
|
|
484
|
+
'view',
|
|
485
|
+
'@astryxdesign/cli@latest',
|
|
486
|
+
'version',
|
|
487
|
+
'engines',
|
|
488
|
+
'--json',
|
|
489
|
+
...BOUNDED_FETCH,
|
|
490
|
+
],
|
|
491
|
+
{timeoutMs: 60_000},
|
|
492
|
+
);
|
|
493
|
+
if (result.status !== 0) {
|
|
494
|
+
if (/** @type {any} */ (result.error)?.code === 'ENOENT') {
|
|
495
|
+
return fail('npm-missing');
|
|
496
|
+
}
|
|
497
|
+
const code =
|
|
498
|
+
/npm (?:error|ERR!) code (\S+)/u.exec(result.stderr)?.[1] ??
|
|
499
|
+
/** @type {any} */ (result.error)?.code ??
|
|
500
|
+
(result.signal ? result.signal : `exit ${result.status}`);
|
|
501
|
+
return fail(`npm-failed: ${code}`);
|
|
502
|
+
}
|
|
503
|
+
let parsed;
|
|
504
|
+
try {
|
|
505
|
+
parsed = JSON.parse(result.stdout || 'null');
|
|
506
|
+
} catch {
|
|
507
|
+
return fail('bad-reply');
|
|
508
|
+
}
|
|
509
|
+
const info = Array.isArray(parsed) ? parsed[parsed.length - 1] : parsed;
|
|
510
|
+
if (typeof info === 'string') {
|
|
511
|
+
return isReleaseVersion(info)
|
|
512
|
+
? {version: info, node: null}
|
|
513
|
+
: fail('bad-reply');
|
|
514
|
+
}
|
|
515
|
+
if (!info || !isReleaseVersion(info.version)) return fail('bad-reply');
|
|
516
|
+
const node = info.engines?.node;
|
|
517
|
+
return {version: info.version, node: typeof node === 'string' ? node : null};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Registry requests give up after one retry and 30 seconds each, so a run with
|
|
522
|
+
* no network fails in seconds with ERR_VERSIONS_UNAVAILABLE instead of waiting
|
|
523
|
+
* out npm's default retries. Registry, proxy, and offline settings still come
|
|
524
|
+
* from the user's npm configuration.
|
|
525
|
+
*/
|
|
526
|
+
const BOUNDED_FETCH = [
|
|
527
|
+
'--fetch-retries=1',
|
|
528
|
+
'--fetch-timeout=30000',
|
|
529
|
+
// One quick retry, so an offline run learns it is offline in seconds.
|
|
530
|
+
'--fetch-retry-mintimeout=2000',
|
|
531
|
+
'--fetch-retry-maxtimeout=10000',
|
|
532
|
+
];
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* How long a failed install of a release is remembered, so offline runs do
|
|
536
|
+
* not start npm again on every run (spec AST-081 FR2): 2 minutes after a
|
|
537
|
+
* first failure, so a passing registry hiccup is retried soon, and 10 minutes
|
|
538
|
+
* after a second failure in a row. A successful install forgets it.
|
|
539
|
+
* @param {number} failures the failures in a row, 1 or more
|
|
540
|
+
* @returns {number}
|
|
541
|
+
*/
|
|
542
|
+
export function failedInstallWaitMs(failures) {
|
|
543
|
+
return (failures >= 2 ? 10 : 2) * 60_000;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* The record of a failed install of `version`: a file in the release cache,
|
|
548
|
+
* shared by every copy of the CLI that uses this cache.
|
|
549
|
+
* @param {string} version
|
|
550
|
+
* @param {string} cacheDir
|
|
551
|
+
* @returns {string}
|
|
552
|
+
*/
|
|
553
|
+
function failedRecordPath(version, cacheDir) {
|
|
554
|
+
return path.join(releasesDir(cacheDir), `.failed-${version}.json`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* The recorded failed installs of `version` in a row, when the last was
|
|
559
|
+
* within its wait (failedInstallWaitMs), or null.
|
|
560
|
+
* @param {string} version
|
|
561
|
+
* @param {string} cacheDir
|
|
562
|
+
* @param {number} [now]
|
|
563
|
+
* @returns {{at: number, reason: string, failures: number, waitMs: number} | null}
|
|
564
|
+
*/
|
|
565
|
+
export function recentFailedInstall(version, cacheDir, now = Date.now()) {
|
|
566
|
+
const record = readFailedInstall(version, cacheDir);
|
|
567
|
+
if (!record) return null;
|
|
568
|
+
const waitMs = failedInstallWaitMs(record.failures);
|
|
569
|
+
const age = now - record.at;
|
|
570
|
+
return age >= 0 && age < waitMs ? {...record, waitMs} : null;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* The record of failed installs of `version`, whatever its age, or null.
|
|
575
|
+
* @param {string} version
|
|
576
|
+
* @param {string} cacheDir
|
|
577
|
+
* @returns {{at: number, reason: string, failures: number} | null}
|
|
578
|
+
*/
|
|
579
|
+
function readFailedInstall(version, cacheDir) {
|
|
580
|
+
const record = readJson(failedRecordPath(version, cacheDir));
|
|
581
|
+
if (
|
|
582
|
+
!record ||
|
|
583
|
+
typeof record.at !== 'number' ||
|
|
584
|
+
typeof record.reason !== 'string'
|
|
585
|
+
) {
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
const failures =
|
|
589
|
+
Number.isInteger(record.failures) && record.failures > 0
|
|
590
|
+
? record.failures
|
|
591
|
+
: 1;
|
|
592
|
+
return {at: record.at, reason: record.reason, failures};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Remember, or forget, a failed install of `version`. Never fails a run.
|
|
597
|
+
* @param {string} version
|
|
598
|
+
* @param {string} cacheDir
|
|
599
|
+
* @param {string | null} reason null forgets it
|
|
600
|
+
*/
|
|
601
|
+
function recordFailedInstall(version, cacheDir, reason) {
|
|
602
|
+
const file = failedRecordPath(version, cacheDir);
|
|
603
|
+
try {
|
|
604
|
+
if (reason === null) {
|
|
605
|
+
fs.rmSync(file, {force: true});
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
fs.mkdirSync(path.dirname(file), {recursive: true});
|
|
609
|
+
const failures = (readFailedInstall(version, cacheDir)?.failures ?? 0) + 1;
|
|
610
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
611
|
+
fs.writeFileSync(
|
|
612
|
+
tmp,
|
|
613
|
+
`${JSON.stringify({at: Date.now(), reason, failures})}\n`,
|
|
614
|
+
);
|
|
615
|
+
fs.renameSync(tmp, file);
|
|
616
|
+
} catch {
|
|
617
|
+
// An unwritable cache already answers for itself.
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* The longest an install may take: generous, so a slow but moving download
|
|
623
|
+
* finishes, while a stalled one still ends in ERR_VERSIONS_UNAVAILABLE.
|
|
624
|
+
*/
|
|
625
|
+
export const INSTALL_TIMEOUT_MS = 15 * 60_000;
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* The exit statuses a package manager uses when Ctrl-C, a closed terminal, or
|
|
629
|
+
* a termination request stopped it.
|
|
630
|
+
* @type {Record<number, NodeJS.Signals>}
|
|
631
|
+
*/
|
|
632
|
+
const EXIT_SIGNALS = {129: 'SIGHUP', 130: 'SIGINT', 143: 'SIGTERM'};
|
|
633
|
+
|
|
634
|
+
/** Sleep the thread without spinning. @param {number} ms */
|
|
635
|
+
function sleep(ms) {
|
|
636
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** How long a lock may stand before a run treats its holder as gone. */
|
|
640
|
+
const STALE_LOCK_MS = 30 * 60_000;
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* How long a lock held from another computer (a shared cache) may stand,
|
|
644
|
+
* however often its holder renews it; one whose holder stops renewing it is
|
|
645
|
+
* taken over sooner (lock-lease.mjs).
|
|
646
|
+
*/
|
|
647
|
+
const FOREIGN_LOCK_MS = 20 * 60_000;
|
|
648
|
+
|
|
649
|
+
/** How long a lock with no owner record may stand: its holder died taking it. */
|
|
650
|
+
const UNOWNED_LOCK_MS = 10_000;
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Is process `pid` on this computer still running?
|
|
654
|
+
* @param {number} pid
|
|
655
|
+
* @returns {boolean}
|
|
656
|
+
*/
|
|
657
|
+
function isAlive(pid) {
|
|
658
|
+
try {
|
|
659
|
+
process.kill(pid, 0);
|
|
660
|
+
return true;
|
|
661
|
+
} catch (error) {
|
|
662
|
+
// EPERM: it runs, as another user.
|
|
663
|
+
return /** @type {NodeJS.ErrnoException} */ (error).code === 'EPERM';
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Has the run holding `lockDir` gone? Its owner record names a process on
|
|
669
|
+
* this computer that no longer runs; or there is no record and the lock is
|
|
670
|
+
* older than a run needs to write one; or another computer holds it for
|
|
671
|
+
* longer than an install takes, or its holder there stopped renewing it; or
|
|
672
|
+
* it is older than any install.
|
|
673
|
+
* @param {string} lockDir
|
|
674
|
+
* @param {number} now
|
|
675
|
+
* @returns {boolean}
|
|
676
|
+
*/
|
|
677
|
+
function lockIsStale(lockDir, now) {
|
|
678
|
+
let age;
|
|
679
|
+
try {
|
|
680
|
+
age = now - fs.statSync(lockDir).mtimeMs;
|
|
681
|
+
} catch {
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
if (age > STALE_LOCK_MS) return true;
|
|
685
|
+
const owner = readJson(path.join(lockDir, 'owner.json'));
|
|
686
|
+
if (!owner) return age > UNOWNED_LOCK_MS;
|
|
687
|
+
if (owner.host !== os.hostname() || !Number.isInteger(owner.pid)) {
|
|
688
|
+
return age > FOREIGN_LOCK_MS || foreignLockIsSilent(lockDir, now);
|
|
689
|
+
}
|
|
690
|
+
// A live holder on this computer is installing, however slowly.
|
|
691
|
+
return !isAlive(owner.pid);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Take a lock folder, or return false when another live run holds it. A lock
|
|
696
|
+
* whose holder has gone, such as a run stopped with Ctrl-C or killed, is taken
|
|
697
|
+
* over at once, and one from another computer once its holder has stopped
|
|
698
|
+
* renewing it. Throws when the cache folder cannot be written.
|
|
699
|
+
* @param {string} lockDir
|
|
700
|
+
* @param {{now?: number}} [options] the clock, for tests
|
|
701
|
+
* @returns {boolean}
|
|
702
|
+
*/
|
|
703
|
+
export function tryLock(lockDir, {now = Date.now()} = {}) {
|
|
704
|
+
fs.mkdirSync(path.dirname(lockDir), {recursive: true});
|
|
705
|
+
try {
|
|
706
|
+
fs.mkdirSync(lockDir);
|
|
707
|
+
} catch (error) {
|
|
708
|
+
if (/** @type {NodeJS.ErrnoException} */ (error).code !== 'EEXIST') {
|
|
709
|
+
throw error;
|
|
710
|
+
}
|
|
711
|
+
if (!lockIsStale(lockDir, now)) return false;
|
|
712
|
+
// The run that held it is gone; end the install it left running, if any,
|
|
713
|
+
// before starting another.
|
|
714
|
+
endRecordedInstall(lockDir, {now});
|
|
715
|
+
const aside = `${lockDir}.stale-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
716
|
+
try {
|
|
717
|
+
fs.renameSync(lockDir, aside);
|
|
718
|
+
} catch {
|
|
719
|
+
// Another run took it over first.
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
fs.rmSync(aside, {recursive: true, force: true});
|
|
723
|
+
try {
|
|
724
|
+
fs.mkdirSync(lockDir);
|
|
725
|
+
} catch {
|
|
726
|
+
return false;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
try {
|
|
730
|
+
fs.writeFileSync(
|
|
731
|
+
path.join(lockDir, 'owner.json'),
|
|
732
|
+
JSON.stringify({pid: process.pid, host: os.hostname(), at: Date.now()}),
|
|
733
|
+
);
|
|
734
|
+
} catch {
|
|
735
|
+
// Without a record the lock still expires on its own.
|
|
736
|
+
}
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* What the system says about the process `pid`: its arguments (exact from
|
|
742
|
+
* `/proc`; split on spaces from `ps` or PowerShell, so a path with a space in
|
|
743
|
+
* it never matches), and when it started (ms since the epoch). Read from `/proc` where there is one, else from `ps` (or
|
|
744
|
+
* PowerShell on Windows); null for what cannot be read.
|
|
745
|
+
* @param {number} pid
|
|
746
|
+
* @param {string} procRoot
|
|
747
|
+
* @returns {{argv: string[] | null, startedAt: number | null}}
|
|
748
|
+
*/
|
|
749
|
+
function processFactsOf(pid, procRoot) {
|
|
750
|
+
try {
|
|
751
|
+
let argv = fs
|
|
752
|
+
.readFileSync(path.join(procRoot, String(pid), 'cmdline'), 'utf-8')
|
|
753
|
+
.split('\u0000')
|
|
754
|
+
.filter(Boolean);
|
|
755
|
+
// npm sets its process title to its command line, which Linux shows as
|
|
756
|
+
// one argument ("npm install @astryxdesign/cli@0.7.2 …"): split it as the
|
|
757
|
+
// ps path does.
|
|
758
|
+
if (argv.length === 1) argv = argv[0].trim().split(/\s+/u);
|
|
759
|
+
/** @type {number | null} */
|
|
760
|
+
let startedAt = null;
|
|
761
|
+
try {
|
|
762
|
+
// Field 22 of /proc/<pid>/stat: start time in clock ticks after boot
|
|
763
|
+
// (100 a second on Linux); /proc/stat's btime: boot, in seconds.
|
|
764
|
+
const stat = fs.readFileSync(
|
|
765
|
+
path.join(procRoot, String(pid), 'stat'),
|
|
766
|
+
'utf-8',
|
|
767
|
+
);
|
|
768
|
+
const ticks = Number(
|
|
769
|
+
stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19],
|
|
770
|
+
);
|
|
771
|
+
const btime = Number(
|
|
772
|
+
/^btime (\d+)$/mu.exec(
|
|
773
|
+
fs.readFileSync(path.join(procRoot, 'stat'), 'utf-8'),
|
|
774
|
+
)?.[1],
|
|
775
|
+
);
|
|
776
|
+
if (Number.isFinite(ticks) && Number.isFinite(btime)) {
|
|
777
|
+
startedAt = btime * 1000 + ticks * 10;
|
|
778
|
+
}
|
|
779
|
+
} catch {
|
|
780
|
+
// Start time unknown.
|
|
781
|
+
}
|
|
782
|
+
return {argv, startedAt};
|
|
783
|
+
} catch {
|
|
784
|
+
// No process table here.
|
|
785
|
+
}
|
|
786
|
+
if (process.platform === 'win32') {
|
|
787
|
+
const result = spawnSync(
|
|
788
|
+
'powershell.exe',
|
|
789
|
+
[
|
|
790
|
+
'-NoProfile',
|
|
791
|
+
'-NonInteractive',
|
|
792
|
+
'-Command',
|
|
793
|
+
`$p = Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}'; if ($p) { [DateTimeOffset]::new($p.CreationDate).ToUnixTimeMilliseconds(); $p.CommandLine }`,
|
|
794
|
+
],
|
|
795
|
+
{encoding: 'utf-8', windowsHide: true, timeout: 10_000},
|
|
796
|
+
);
|
|
797
|
+
const [started, ...rest] = (result.stdout ?? '').trim().split(/\r?\n/u);
|
|
798
|
+
const line = rest.join(' ').trim();
|
|
799
|
+
return {
|
|
800
|
+
argv: line
|
|
801
|
+
? line.split(/\s+/u).map(arg => arg.replace(/^"(.*)"$/u, '$1'))
|
|
802
|
+
: null,
|
|
803
|
+
startedAt: Number(started) || null,
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
const result = spawnSync(
|
|
807
|
+
'ps',
|
|
808
|
+
['-o', 'lstart=', '-o', 'args=', '-p', String(pid)],
|
|
809
|
+
{
|
|
810
|
+
encoding: 'utf-8',
|
|
811
|
+
timeout: 5000,
|
|
812
|
+
},
|
|
813
|
+
);
|
|
814
|
+
const line = (result.stdout ?? '').split(/\r?\n/u)[0] ?? '';
|
|
815
|
+
// `lstart` is a fixed 24-character date, such as `Tue Oct 20 03:52:54 2026`.
|
|
816
|
+
const startedAt = Date.parse(line.slice(0, 24));
|
|
817
|
+
const args = line.slice(24).trim();
|
|
818
|
+
return {
|
|
819
|
+
argv: args ? args.split(/\s+/u) : null,
|
|
820
|
+
startedAt: Number.isFinite(startedAt) ? startedAt : null,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Whether a process's arguments are this CLI's install of `version`, as
|
|
826
|
+
* runNpm starts it: the program (`argv[0]`) is npm, or node running npm's
|
|
827
|
+
* entry file `npm-cli.js` (`argv[1]`), each exactly by name, and the arguments
|
|
828
|
+
* include `install` and `@astryxdesign/cli@<version>`. A program that only has
|
|
829
|
+
* `npm` somewhere in its name or arguments is not.
|
|
830
|
+
* @param {string[]} argv
|
|
831
|
+
* @param {string} version
|
|
832
|
+
* @returns {boolean}
|
|
833
|
+
*/
|
|
834
|
+
export function isReleaseInstall(argv, version) {
|
|
835
|
+
const name = (/** @type {string | undefined} */ arg) =>
|
|
836
|
+
(arg ?? '')
|
|
837
|
+
.split(/[\\/]/u)
|
|
838
|
+
.pop()
|
|
839
|
+
?.replace(/\.(?:exe|cmd)$/iu, '')
|
|
840
|
+
.toLowerCase() ?? '';
|
|
841
|
+
const program = name(argv[0]);
|
|
842
|
+
const npm =
|
|
843
|
+
program === 'npm' ||
|
|
844
|
+
(/^node(?:[-.\d]*)$/u.test(program) && name(argv[1]) === 'npm-cli.js');
|
|
845
|
+
return (
|
|
846
|
+
npm &&
|
|
847
|
+
argv.includes('install') &&
|
|
848
|
+
argv.includes(`@astryxdesign/cli@${version}`)
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* End the npm process group a stale lock records, only when that process is
|
|
854
|
+
* surely the install the lock was taken for: its command line is this CLI's
|
|
855
|
+
* install of the lock's release (isReleaseInstall), the lock is younger than
|
|
856
|
+
* any install may take, and the process started no earlier than the lock.
|
|
857
|
+
* Anything less certain is left alone, and the lock is simply taken over,
|
|
858
|
+
* since a recorded process id can name an unrelated process once the install
|
|
859
|
+
* ended.
|
|
860
|
+
* @param {string} lockDir a `.lock-<version>` folder
|
|
861
|
+
* @param {{now?: number, procRoot?: string, factsOf?: (pid: number) => {argv: string[] | null, startedAt: number | null}}} [options]
|
|
862
|
+
* the clock, the process table's folder, and the fact reader, for tests
|
|
863
|
+
* @returns {'ended' | 'left'} whether a process group was ended
|
|
864
|
+
*/
|
|
865
|
+
export function endRecordedInstall(
|
|
866
|
+
lockDir,
|
|
867
|
+
{now = Date.now(), procRoot = '/proc', factsOf} = {},
|
|
868
|
+
) {
|
|
869
|
+
let pid;
|
|
870
|
+
try {
|
|
871
|
+
pid = Number(fs.readFileSync(path.join(lockDir, 'npm.pid'), 'utf-8'));
|
|
872
|
+
} catch {
|
|
873
|
+
return 'left';
|
|
874
|
+
}
|
|
875
|
+
if (!Number.isInteger(pid) || pid <= 1 || !isAlive(pid)) return 'left';
|
|
876
|
+
const version = /^\.lock-(.+)$/u.exec(path.basename(lockDir))?.[1];
|
|
877
|
+
if (!version) return 'left';
|
|
878
|
+
const owner = readJson(path.join(lockDir, 'owner.json'));
|
|
879
|
+
const at = typeof owner?.at === 'number' ? owner.at : null;
|
|
880
|
+
if (at === null || !(now - at >= 0 && now - at < INSTALL_TIMEOUT_MS)) {
|
|
881
|
+
return 'left';
|
|
882
|
+
}
|
|
883
|
+
const facts = (factsOf ?? (id => processFactsOf(id, procRoot)))(pid);
|
|
884
|
+
if (!facts.argv || !isReleaseInstall(facts.argv, version)) return 'left';
|
|
885
|
+
// Started before the lock was taken: an older process that reuses the id.
|
|
886
|
+
// A second's slack covers clocks that round.
|
|
887
|
+
if (facts.startedAt === null || facts.startedAt < at - 1000) return 'left';
|
|
888
|
+
try {
|
|
889
|
+
process.kill(process.platform === 'win32' ? pid : -pid, 'SIGKILL');
|
|
890
|
+
return 'ended';
|
|
891
|
+
} catch {
|
|
892
|
+
// Already gone.
|
|
893
|
+
return 'left';
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Remove a folder by renaming it aside first, so a run never sees it half
|
|
899
|
+
* removed.
|
|
900
|
+
* @param {string} dir
|
|
901
|
+
*/
|
|
902
|
+
function removeAside(dir) {
|
|
903
|
+
const aside = `${dir}.removing-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
904
|
+
try {
|
|
905
|
+
fs.renameSync(dir, aside);
|
|
906
|
+
} catch {
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
fs.rmSync(aside, {recursive: true, force: true});
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Why a release could not be made available.
|
|
914
|
+
* @typedef {object} ReleaseUnavailable
|
|
915
|
+
* @property {string} version
|
|
916
|
+
* @property {string} reason one plain sentence
|
|
917
|
+
* @property {NodeJS.Signals} [signal] the install was stopped by this signal
|
|
918
|
+
*/
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Install release `version` into the cache, verify it, and return it. A
|
|
922
|
+
* release already cached returns at once. When another run is installing the
|
|
923
|
+
* same release, this waits for it.
|
|
924
|
+
*
|
|
925
|
+
* @param {string} version
|
|
926
|
+
* @param {{cacheDir?: string, run?: typeof runNpm, nodeVersion?: string, waitMs?: number, onProgress?: (event: 'install' | 'wait', version: string) => void}} [options]
|
|
927
|
+
* `onProgress` hears once when an install starts and once when the run
|
|
928
|
+
* starts waiting for another run's install.
|
|
929
|
+
* @returns {CachedRelease | ReleaseUnavailable}
|
|
930
|
+
*/
|
|
931
|
+
export function ensureRelease(version, options = {}) {
|
|
932
|
+
const {
|
|
933
|
+
cacheDir = userCacheDir(),
|
|
934
|
+
run = runNpm,
|
|
935
|
+
nodeVersion = process.versions.node,
|
|
936
|
+
waitMs = INSTALL_TIMEOUT_MS + 60_000,
|
|
937
|
+
onProgress = () => {},
|
|
938
|
+
} = options;
|
|
939
|
+
if (!isReleaseVersion(version)) {
|
|
940
|
+
return {version: String(version), reason: 'it is not a release version'};
|
|
941
|
+
}
|
|
942
|
+
const existing = cachedRelease(version, cacheDir);
|
|
943
|
+
if (existing) {
|
|
944
|
+
// Installed by now, by any copy: forget any failure.
|
|
945
|
+
if (readFailedInstall(version, cacheDir)) {
|
|
946
|
+
recordFailedInstall(version, cacheDir, null);
|
|
947
|
+
}
|
|
948
|
+
return existing;
|
|
949
|
+
}
|
|
950
|
+
// A recent failed install is not retried: offline, every run would start
|
|
951
|
+
// npm again and wait for it (FR2).
|
|
952
|
+
const failed = recentFailedInstall(version, cacheDir);
|
|
953
|
+
if (failed) {
|
|
954
|
+
const minutes = Math.max(1, Math.round((Date.now() - failed.at) / 60_000));
|
|
955
|
+
return {
|
|
956
|
+
version,
|
|
957
|
+
reason: `${failed.reason}; last tried ${minutes} minute${minutes === 1 ? '' : 's'} ago, tried again after ${failed.waitMs / 60_000} minutes`,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
try {
|
|
961
|
+
const result = ensureLocked(version, {
|
|
962
|
+
cacheDir,
|
|
963
|
+
run,
|
|
964
|
+
nodeVersion,
|
|
965
|
+
waitMs,
|
|
966
|
+
onProgress,
|
|
967
|
+
});
|
|
968
|
+
// Stopped with Ctrl-C (or closed): the lock and scratch folder are already
|
|
969
|
+
// cleared; end the run the way the signal would have.
|
|
970
|
+
if ('signal' in result && result.signal)
|
|
971
|
+
process.kill(process.pid, result.signal);
|
|
972
|
+
if ('bin' in result) recordFailedInstall(version, cacheDir, null);
|
|
973
|
+
else if (
|
|
974
|
+
'reason' in result &&
|
|
975
|
+
!('signal' in result && result.signal) &&
|
|
976
|
+
result.reason !== 'another run is still installing it'
|
|
977
|
+
) {
|
|
978
|
+
recordFailedInstall(version, cacheDir, result.reason);
|
|
979
|
+
}
|
|
980
|
+
return result;
|
|
981
|
+
} catch (error) {
|
|
982
|
+
const code = /** @type {NodeJS.ErrnoException} */ (error).code;
|
|
983
|
+
return {
|
|
984
|
+
version,
|
|
985
|
+
reason: `the release cache ${releasesDir(cacheDir)} cannot be written${code ? ` (${code})` : ''}`,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* `ensureRelease` under the per-release lock.
|
|
992
|
+
* @param {string} version
|
|
993
|
+
* @param {{cacheDir: string, run: typeof runNpm, nodeVersion: string, waitMs: number, onProgress: (event: 'install' | 'wait', version: string) => void}} options
|
|
994
|
+
* @returns {CachedRelease | ReleaseUnavailable}
|
|
995
|
+
*/
|
|
996
|
+
function ensureLocked(
|
|
997
|
+
version,
|
|
998
|
+
{cacheDir, run, nodeVersion, waitMs, onProgress},
|
|
999
|
+
) {
|
|
1000
|
+
const root = releasesDir(cacheDir);
|
|
1001
|
+
const lockDir = path.join(root, `.lock-${version}`);
|
|
1002
|
+
const deadline = Date.now() + waitMs;
|
|
1003
|
+
let waiting = false;
|
|
1004
|
+
while (!tryLock(lockDir)) {
|
|
1005
|
+
const ready = cachedRelease(version, cacheDir);
|
|
1006
|
+
if (ready) return ready;
|
|
1007
|
+
if (Date.now() > deadline) {
|
|
1008
|
+
return {version, reason: 'another run is still installing it'};
|
|
1009
|
+
}
|
|
1010
|
+
if (!waiting) {
|
|
1011
|
+
waiting = true;
|
|
1012
|
+
onProgress('wait', version);
|
|
1013
|
+
}
|
|
1014
|
+
sleep(200);
|
|
1015
|
+
}
|
|
1016
|
+
// A run stopped mid-install (Ctrl-C, a closed terminal) frees the lock and
|
|
1017
|
+
// its scratch folder on the way out; a killed one is taken over by the
|
|
1018
|
+
// owner check in tryLock.
|
|
1019
|
+
const release = () => {
|
|
1020
|
+
for (const entry of safeList(root)) {
|
|
1021
|
+
if (entry.startsWith(`.tmp-${version}-${process.pid}-`)) {
|
|
1022
|
+
fs.rmSync(path.join(root, entry), {recursive: true, force: true});
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
fs.rmSync(lockDir, {recursive: true, force: true});
|
|
1026
|
+
};
|
|
1027
|
+
/** @param {NodeJS.Signals} signal */
|
|
1028
|
+
const onSignal = signal => {
|
|
1029
|
+
release();
|
|
1030
|
+
removeSignalHandlers();
|
|
1031
|
+
process.kill(process.pid, signal);
|
|
1032
|
+
};
|
|
1033
|
+
const signals = /** @type {NodeJS.Signals[]} */ ([
|
|
1034
|
+
'SIGINT',
|
|
1035
|
+
'SIGTERM',
|
|
1036
|
+
'SIGHUP',
|
|
1037
|
+
]);
|
|
1038
|
+
const removeSignalHandlers = () => {
|
|
1039
|
+
for (const signal of signals) process.off(signal, onSignal);
|
|
1040
|
+
};
|
|
1041
|
+
for (const signal of signals) process.on(signal, onSignal);
|
|
1042
|
+
try {
|
|
1043
|
+
const again = cachedRelease(version, cacheDir);
|
|
1044
|
+
if (again) return again;
|
|
1045
|
+
// Under the lock, anything left for this version is a damaged release or
|
|
1046
|
+
// the scratch folder of a run that stopped: clear both.
|
|
1047
|
+
for (const entry of safeList(root)) {
|
|
1048
|
+
if (
|
|
1049
|
+
entry.startsWith(`.tmp-${version}-`) ||
|
|
1050
|
+
entry.startsWith(`${version}.removing-`) ||
|
|
1051
|
+
entry.startsWith(`.lock-${version}.stale-`)
|
|
1052
|
+
) {
|
|
1053
|
+
fs.rmSync(path.join(root, entry), {recursive: true, force: true});
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
removeAside(path.join(root, version));
|
|
1057
|
+
verified.delete(`${cacheDir}\0${version}`);
|
|
1058
|
+
onProgress('install', version);
|
|
1059
|
+
return installLocked(version, {cacheDir, run, nodeVersion});
|
|
1060
|
+
} finally {
|
|
1061
|
+
removeSignalHandlers();
|
|
1062
|
+
release();
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* A folder's entries, or none.
|
|
1068
|
+
* @param {string} dir
|
|
1069
|
+
* @returns {string[]}
|
|
1070
|
+
*/
|
|
1071
|
+
function safeList(dir) {
|
|
1072
|
+
try {
|
|
1073
|
+
return fs.readdirSync(dir);
|
|
1074
|
+
} catch {
|
|
1075
|
+
return [];
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* The install itself, under the lock.
|
|
1081
|
+
* @param {string} version
|
|
1082
|
+
* @param {{cacheDir: string, run: typeof runNpm, nodeVersion: string}} options
|
|
1083
|
+
* @returns {CachedRelease | ReleaseUnavailable}
|
|
1084
|
+
*/
|
|
1085
|
+
function installLocked(version, {cacheDir, run, nodeVersion}) {
|
|
1086
|
+
const root = releasesDir(cacheDir);
|
|
1087
|
+
const tmp = path.join(
|
|
1088
|
+
root,
|
|
1089
|
+
`.tmp-${version}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`,
|
|
1090
|
+
);
|
|
1091
|
+
fs.mkdirSync(tmp, {recursive: true});
|
|
1092
|
+
try {
|
|
1093
|
+
fs.writeFileSync(
|
|
1094
|
+
path.join(tmp, 'package.json'),
|
|
1095
|
+
`${JSON.stringify({name: 'astryx-release', private: true}, null, 2)}\n`,
|
|
1096
|
+
);
|
|
1097
|
+
const install = run(
|
|
1098
|
+
[
|
|
1099
|
+
'install',
|
|
1100
|
+
'--no-save',
|
|
1101
|
+
'--no-package-lock',
|
|
1102
|
+
'--ignore-scripts',
|
|
1103
|
+
'--no-audit',
|
|
1104
|
+
'--no-fund',
|
|
1105
|
+
'--prefer-offline',
|
|
1106
|
+
...BOUNDED_FETCH,
|
|
1107
|
+
'--loglevel=error',
|
|
1108
|
+
`@astryxdesign/cli@${version}`,
|
|
1109
|
+
`@astryxdesign/core@${version}`,
|
|
1110
|
+
],
|
|
1111
|
+
{
|
|
1112
|
+
cwd: tmp,
|
|
1113
|
+
timeoutMs: INSTALL_TIMEOUT_MS,
|
|
1114
|
+
pidFile: path.join(root, `.lock-${version}`, 'npm.pid'),
|
|
1115
|
+
},
|
|
1116
|
+
);
|
|
1117
|
+
// Our own time limit ends npm: that is a timeout, not a stopped run.
|
|
1118
|
+
if (/** @type {any} */ (install.error)?.code === 'ETIMEDOUT') {
|
|
1119
|
+
return {
|
|
1120
|
+
version,
|
|
1121
|
+
reason: `the install did not finish within ${INSTALL_TIMEOUT_MS / 60_000} minutes`,
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
const stoppedBy = install.signal ?? EXIT_SIGNALS[install.status ?? -1];
|
|
1125
|
+
if (stoppedBy) {
|
|
1126
|
+
return {version, reason: 'the install was stopped', signal: stoppedBy};
|
|
1127
|
+
}
|
|
1128
|
+
if (install.status !== 0) {
|
|
1129
|
+
return {version, reason: npmFailure(install)};
|
|
1130
|
+
}
|
|
1131
|
+
const cliDir = path.join(tmp, 'node_modules', '@astryxdesign', 'cli');
|
|
1132
|
+
const cliPkg = readJson(path.join(cliDir, 'package.json'));
|
|
1133
|
+
if (cliPkg?.version !== version) {
|
|
1134
|
+
return {version, reason: 'the package manager installed another version'};
|
|
1135
|
+
}
|
|
1136
|
+
const engine = cliPkg?.engines?.node;
|
|
1137
|
+
if (typeof engine === 'string' && !satisfiesRange(nodeVersion, engine)) {
|
|
1138
|
+
return {
|
|
1139
|
+
version,
|
|
1140
|
+
reason: `it needs Node ${engine}, and this computer runs Node ${nodeVersion}`,
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
const bin = cliBinOf(cliDir);
|
|
1144
|
+
if (!bin) return {version, reason: 'its CLI has no astryx command'};
|
|
1145
|
+
// A check, not a person's run: the shared update-notifier variable keeps
|
|
1146
|
+
// a release that prints update notices from printing them here, and from
|
|
1147
|
+
// using up the day's notices (spec AST-081 FR10).
|
|
1148
|
+
const started = spawnSync(process.execPath, [bin, '--version'], {
|
|
1149
|
+
cwd: tmp,
|
|
1150
|
+
encoding: 'utf-8',
|
|
1151
|
+
env: {...process.env, NO_UPDATE_NOTIFIER: '1'},
|
|
1152
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1153
|
+
timeout: 60_000,
|
|
1154
|
+
windowsHide: true,
|
|
1155
|
+
});
|
|
1156
|
+
const reported = String(started.stdout ?? '')
|
|
1157
|
+
.trim()
|
|
1158
|
+
.split(/\r?\n/)
|
|
1159
|
+
.pop();
|
|
1160
|
+
if (started.status !== 0 || reported !== version) {
|
|
1161
|
+
return {version, reason: 'its CLI did not start and report its version'};
|
|
1162
|
+
}
|
|
1163
|
+
/** @type {Record<string, string | null>} */
|
|
1164
|
+
const files = {};
|
|
1165
|
+
for (const rel of checkedFiles(bin, tmp)) {
|
|
1166
|
+
files[rel.split(path.sep).join('/')] = fileHash(path.join(tmp, rel));
|
|
1167
|
+
}
|
|
1168
|
+
fs.writeFileSync(
|
|
1169
|
+
path.join(tmp, 'release.json'),
|
|
1170
|
+
`${JSON.stringify({version, verifiedAt: new Date().toISOString(), files, tree: releaseTree(tmp)}, null, 2)}\n`,
|
|
1171
|
+
);
|
|
1172
|
+
|
|
1173
|
+
const dest = path.join(root, version);
|
|
1174
|
+
try {
|
|
1175
|
+
fs.renameSync(tmp, dest);
|
|
1176
|
+
} catch {
|
|
1177
|
+
// A finished release already holds the name; keep that one.
|
|
1178
|
+
}
|
|
1179
|
+
return (
|
|
1180
|
+
cachedRelease(version, cacheDir) ?? {
|
|
1181
|
+
version,
|
|
1182
|
+
reason: 'it could not be saved in the cache',
|
|
1183
|
+
}
|
|
1184
|
+
);
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
return {
|
|
1187
|
+
version,
|
|
1188
|
+
reason: `installing it failed (${error instanceof Error ? error.message : String(error)})`,
|
|
1189
|
+
};
|
|
1190
|
+
} finally {
|
|
1191
|
+
fs.rmSync(tmp, {recursive: true, force: true});
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* One sentence on why npm failed.
|
|
1197
|
+
* @param {NpmResult} result
|
|
1198
|
+
* @returns {string}
|
|
1199
|
+
*/
|
|
1200
|
+
function npmFailure(result) {
|
|
1201
|
+
if (result.error && /** @type {any} */ (result.error).code === 'ENOENT') {
|
|
1202
|
+
return 'npm, which installs releases, was not found';
|
|
1203
|
+
}
|
|
1204
|
+
const text = `${result.stderr}\n${result.stdout}`;
|
|
1205
|
+
if (/E404|404 Not Found|No matching version|ETARGET/i.test(text)) {
|
|
1206
|
+
return 'the registry does not have it';
|
|
1207
|
+
}
|
|
1208
|
+
if (
|
|
1209
|
+
/ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|offline|network/i.test(
|
|
1210
|
+
text,
|
|
1211
|
+
)
|
|
1212
|
+
) {
|
|
1213
|
+
return 'the registry could not be reached';
|
|
1214
|
+
}
|
|
1215
|
+
return 'the package manager could not install it';
|
|
1216
|
+
}
|