@mnstry/atelier 0.2.0-alpha.13 → 0.2.0-alpha.15
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 +283 -0
- package/README.md +102 -52
- package/SECURITY.md +1 -1
- package/TRADEMARKS.md +2 -2
- package/contracts/atelier-extension-manifest.v1.schema.json +412 -0
- package/contracts/atelier-graph-view.v1.schema.json +1085 -0
- package/contracts/public-api-baseline.json +298 -19
- package/docs/README.md +90 -0
- package/docs/blocks/capability-stewardship.md +1 -1
- package/docs/blocks/claims.md +2 -2
- package/docs/blocks/conformance-admission.md +1 -1
- package/docs/blocks/learning-harnesses.md +1 -1
- package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
- package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +2 -4
- package/docs/guides/co-authoring.md +113 -0
- package/docs/{distributions.md → guides/distributions.md} +13 -8
- package/docs/guides/extensions.md +189 -0
- package/docs/guides/graph-refresh.md +152 -0
- package/docs/guides/graph-workspace.md +64 -0
- package/docs/{ingestion.md → guides/ingestion.md} +16 -15
- package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +2 -3
- package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
- package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +2 -3
- package/docs/{learning.md → guides/learning.md} +12 -5
- package/docs/{obsidian.md → guides/obsidian.md} +370 -23
- package/docs/guides/ontology-guide.md +111 -0
- package/docs/{personal-workspace.md → guides/personal-workspace.md} +17 -5
- package/docs/{research-starter.md → guides/research-starter.md} +1 -1
- package/docs/{tenant-readiness.md → guides/tenant-readiness.md} +39 -0
- package/docs/{upgrade.md → guides/upgrade.md} +197 -11
- package/docs/knowledge-health/assessment/capabilities.md +6 -15
- package/docs/knowledge-health/facilitator.md +15 -4
- package/docs/knowledge-health/{devday/participant-response.md → participant-response.md} +4 -2
- package/docs/knowledge-health/quickstart.md +97 -75
- package/docs/knowledge-health/readme.md +13 -5
- package/docs/maintainers/describing-the-atelier.md +173 -0
- package/docs/{release-engineering.md → maintainers/release-engineering.md} +5 -4
- package/docs/{announcements.md → reference/announcements.md} +1 -1
- package/docs/{architecture.md → reference/architecture.md} +31 -5
- package/docs/{atelier-runtime.md → reference/atelier-runtime.md} +1 -1
- package/docs/{connected-composition.md → reference/connected-composition.md} +7 -10
- package/docs/{coordination.md → reference/coordination.md} +1 -1
- package/docs/{decisions.md → reference/decisions.md} +3 -3
- package/docs/{design.md → reference/design.md} +1 -1
- package/docs/reference/edge-policy.md +151 -0
- package/docs/reference/graph-view-structured.md +119 -0
- package/docs/reference/graph-view.md +139 -0
- package/docs/reference/graph-workspace.md +37 -0
- package/docs/{intake-and-guides.md → reference/intake-and-guides.md} +3 -4
- package/docs/{knowledge-graph.md → reference/knowledge-graph.md} +120 -6
- package/docs/{local-services.md → reference/local-services.md} +43 -4
- package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +29 -4
- package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +218 -68
- package/docs/{practical-judgment.md → reference/phronesis.md} +12 -3
- package/docs/reference/portability.md +92 -0
- package/docs/{portable-presentation.md → reference/portable-presentation.md} +2 -2
- package/docs/{presentation-consumer-boundaries.md → reference/presentation-consumer-boundaries.md} +5 -0
- package/docs/{project-options.md → reference/project-options.md} +17 -0
- package/docs/{repo-boundary-guard.md → reference/repo-boundary-guard.md} +50 -4
- package/docs/{review-portability.md → reference/review-portability.md} +4 -0
- package/docs/{semantic-operation.md → reference/semantic-operation.md} +4 -3
- package/docs/{template-profile.md → reference/template-profile.md} +1 -1
- package/docs/{trackables.md → reference/trackables.md} +2 -2
- package/docs/start/independent-start.md +89 -0
- package/docs/{install.md → start/install.md} +9 -9
- package/docs/{knowledge-setup.md → start/knowledge-setup.md} +5 -5
- package/docs/{research-starter-quickstart.md → start/research-starter-quickstart.md} +6 -6
- package/fixtures/atelier-architecture/catalog/invalid/document.json +2 -2
- package/fixtures/atelier-architecture/catalog/valid/document.json +2 -2
- package/fixtures/graph-view/synthetic-authorized-input.v1.json +437 -0
- package/fixtures/graph-view/synthetic-view.v1.json +1 -0
- package/fixtures/knowledge-health/workshop/README.md +1 -1
- package/fixtures/templates/local-library.v1.json +3 -1
- package/package.json +22 -8
- package/plugins/obsidian/main.js +57 -8
- package/plugins/obsidian/manifest.json +1 -1
- package/skills/claude/atelier-guided-upgrade/SKILL.md +1 -1
- package/skills/claude/atelier-knowledge-setup/SKILL.md +2 -2
- package/skills/claude/atelier-local-service/SKILL.md +1 -1
- package/skills/claude/atelier-practical-judgment/SKILL.md +7 -2
- package/skills/claude/atelier-skill-steward/SKILL.md +1 -1
- package/skills/claude/mnstry-readiness/SKILL.md +2 -2
- package/skills/codex/atelier-guided-upgrade/SKILL.md +1 -1
- package/skills/codex/atelier-knowledge-setup/SKILL.md +2 -2
- package/skills/codex/atelier-local-service/SKILL.md +1 -1
- package/skills/codex/atelier-practical-judgment/SKILL.md +7 -2
- package/skills/codex/atelier-skill-steward/SKILL.md +1 -1
- package/skills/codex/mnstry-readiness/SKILL.md +2 -2
- package/src/architecture/catalog.mjs +2 -2
- package/src/attestation/sign.mjs +1 -1
- package/src/boundary/content-rules.mjs +534 -68
- package/src/boundary/policy.mjs +187 -60
- package/src/cli/run.mjs +26 -9
- package/src/commands/distribution.mjs +1 -1
- package/src/commands/extension.mjs +27 -0
- package/src/commands/ingest.mjs +1 -1
- package/src/commands/learn.mjs +1 -1
- package/src/commands/obsidian.mjs +49 -21
- package/src/commands/review.mjs +3 -0
- package/src/edge-policy/contract.mjs +69 -0
- package/src/edge-policy/policy.mjs +142 -0
- package/src/edge-policy/source-citation-adapter.mjs +43 -0
- package/src/extension-packs/loader.mjs +4 -3
- package/src/extensions/admission.mjs +83 -0
- package/src/extensions/client.d.mts +23 -0
- package/src/extensions/client.mjs +99 -0
- package/src/extensions/contracts.d.mts +29 -0
- package/src/extensions/contracts.mjs +163 -0
- package/src/extensions/convex.d.mts +24 -0
- package/src/extensions/convex.mjs +189 -0
- package/src/extensions/delegation.mjs +54 -0
- package/src/extensions/drizzle.d.mts +11 -0
- package/src/extensions/drizzle.mjs +33 -0
- package/src/extensions/postgres.d.mts +7 -0
- package/src/extensions/postgres.mjs +66 -0
- package/src/extensions/prisma.d.mts +13 -0
- package/src/extensions/prisma.mjs +63 -0
- package/src/extensions/project.mjs +432 -0
- package/src/extensions/scaffold-convex.mjs +130 -0
- package/src/extensions/scaffold-sql.mjs +144 -0
- package/src/extensions/server.d.mts +20 -0
- package/src/extensions/server.mjs +102 -0
- package/src/extensions/sql-scope.mjs +22 -0
- package/src/graph/graph.mjs +13 -5
- package/src/graph/knowledge-graph.mjs +20 -2
- package/src/graph/repo-contribution.mjs +405 -0
- package/src/graph/repo-contribution.types.mjs +32 -0
- package/src/graph-access/edge-decision-adapter.mjs +41 -0
- package/src/graph-access/edge-record-adapter.mjs +73 -0
- package/src/graph-access/index.mjs +253 -0
- package/src/graph-access/multi-contribution-record-adapter.mjs +101 -0
- package/src/graph-access/multi-record-projection-adapter.mjs +74 -0
- package/src/graph-access/source-adapter.mjs +81 -0
- package/src/graph-refresh/compare.mjs +110 -0
- package/src/graph-refresh/controller.mjs +324 -0
- package/src/graph-refresh/index.mjs +3 -0
- package/src/graph-refresh/local.mjs +78 -0
- package/src/graph-view/babylon/babylon-scene-port.mjs +64 -0
- package/src/graph-view/babylon/index.mjs +2 -0
- package/src/graph-view/babylon/navigator.mjs +312 -0
- package/src/graph-view/babylon/provenance.mjs +36 -0
- package/src/graph-view/babylon/scene-controller.mjs +131 -0
- package/src/graph-view/babylon/semantic-picking.mjs +35 -0
- package/src/graph-view/babylon/web-host.mjs +188 -0
- package/src/graph-view/cli.mjs +3 -0
- package/src/graph-view/command.mjs +34 -0
- package/src/graph-view/contracts.mjs +79 -0
- package/src/graph-view/errors.mjs +5 -0
- package/src/graph-view/index.mjs +4 -0
- package/src/graph-view/intents.mjs +30 -0
- package/src/graph-view/query.mjs +213 -0
- package/src/graph-view/refs.mjs +2 -0
- package/src/graph-view/structured/browser.mjs +262 -0
- package/src/graph-view/structured/styles.mjs +8 -0
- package/src/graph-workspace/authoring.mjs +185 -0
- package/src/graph-workspace/browser/constellation.mjs +369 -0
- package/src/graph-workspace/browser/dom.mjs +23 -0
- package/src/graph-workspace/browser/index.mjs +6 -0
- package/src/graph-workspace/browser/review.mjs +55 -0
- package/src/graph-workspace/browser/styles.mjs +11 -0
- package/src/graph-workspace/browser/workbench.mjs +351 -0
- package/src/graph-workspace/browser/workspace.mjs +431 -0
- package/src/graph-workspace/disclosure.mjs +39 -0
- package/src/graph-workspace/geometry.mjs +78 -0
- package/src/graph-workspace/index.mjs +11 -0
- package/src/graph-workspace/labels.mjs +50 -0
- package/src/graph-workspace/model.mjs +109 -0
- package/src/graph-workspace/recipes.mjs +88 -0
- package/src/graph-workspace/scale.mjs +95 -0
- package/src/graph-workspace/theme.mjs +156 -0
- package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
- package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
- package/src/ingestion/optional/graphify/native-pins.json +306 -0
- package/src/knowledge/ontology-guide.mjs +152 -0
- package/src/knowledge-health/proof/installed-check.mjs +2 -2
- package/src/knowledge-health/rule-identity.mjs +14 -9
- package/src/knowledge-health/workshop.mjs +22 -2
- package/src/personal-workspace/index.mjs +1 -1
- package/src/personal-workspace/selection.mjs +34 -3
- package/src/project/config.mjs +1 -1
- package/src/project/path-match.mjs +2 -1
- package/src/project/providers/github.mjs +24 -0
- package/src/project/repo-identity.mjs +3 -22
- package/src/projection/obsidian/contracts.mjs +1 -1
- package/src/projection/obsidian/materialize/path-registry.mjs +1 -1
- package/src/projection/obsidian/materialize/prepare-view.mjs +88 -6
- package/src/projection/obsidian/materialize/redaction.mjs +1 -1
- package/src/projection/obsidian/personal-workspace.mjs +84 -5
- package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
- package/src/projection/obsidian/proposals/adapter.mjs +46 -23
- package/src/projection/obsidian/proposals/observation.mjs +40 -2
- package/src/projection/obsidian/publication/publisher.mjs +96 -8
- package/src/projection/project.mjs +21 -16
- package/src/readiness/readiness.mjs +20 -11
- package/src/readiness-protocols/bundled-pack.mjs +14 -766
- package/src/readiness-protocols/bundled-selection.mjs +32 -0
- package/src/readiness-protocols/evidence.mjs +2 -1
- package/src/readiness-protocols/packs/mnstry.mjs +766 -0
- package/src/readiness-protocols/runtime.mjs +43 -6
- package/src/runtime/obsidian/engine.mjs +387 -76
- package/src/runtime/obsidian/lifecycle.mjs +20 -15
- package/src/runtime/obsidian/login-item.mjs +6 -3
- package/src/runtime/obsidian/machine-settings.mjs +52 -8
- package/src/runtime/obsidian/observation.mjs +36 -4
- package/src/runtime/obsidian/opening.mjs +4 -3
- package/src/runtime/obsidian/personal-selection.mjs +187 -0
- package/src/runtime/obsidian/pipeline.mjs +115 -6
- package/src/runtime/obsidian/private-lock.mjs +50 -6
- package/src/runtime/obsidian/selection-continuity.mjs +124 -0
- package/src/runtime/obsidian/service-main.mjs +68 -16
- package/src/runtime/obsidian/service.mjs +80 -6
- package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
- package/src/runtime/obsidian/view-permission.mjs +335 -0
- package/src/ui/control-system.mjs +201 -0
- package/src/ui/html-primitives.mjs +3 -0
- package/src/upgrade/closure-diagnostics.mjs +135 -0
- package/src/upgrade/closure.mjs +649 -0
- package/src/upgrade/upgrade.mjs +42 -11
- package/templates/distribution-workspace/README.md +2 -2
- package/templates/external-project-workspace/atelier.project.json +3 -0
- package/templates/external-project-workspace/pack-lifecycle.json +3 -1
- package/templates/knowledge-workspace/README.md +2 -2
- package/docs/integration-and-review-plan.md +0 -409
- package/docs/integration-contract-decisions.md +0 -44
- package/docs/knowledge-health/devday/facilitator.md +0 -16
- package/docs/knowledge-health/devday/public-default.md +0 -107
- package/docs/knowledge-health/devday/quickstart.md +0 -18
- package/docs/knowledge-health/devday/support.md +0 -22
- package/docs/knowledge-health/exercise.md +0 -17
- package/docs/knowledge-health/support.md +0 -34
- package/docs/presentation-review-disposition.md +0 -47
- package/docs/progressive-ingestion-acceptance.md +0 -89
- package/docs/progressive-ingestion.md +0 -235
- /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
- /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
- /package/docs/{local-review.md → guides/local-review.md} +0 -0
- /package/docs/{skill-steward.md → guides/skill-steward.md} +0 -0
- /package/docs/{template-upgrades.md → guides/template-upgrades.md} +0 -0
- /package/docs/{assurance-controls.md → maintainers/assurance-controls.md} +0 -0
- /package/docs/{attestation.md → reference/attestation.md} +0 -0
- /package/docs/{coauthor-session.md → reference/coauthor-session.md} +0 -0
- /package/docs/{continuity.md → reference/continuity.md} +0 -0
- /package/docs/{contract-stability.md → reference/contract-stability.md} +0 -0
- /package/docs/{decision-practices.md → reference/decision-practices.md} +0 -0
- /package/docs/{evidence-navigation.md → reference/evidence-navigation.md} +0 -0
- /package/docs/{extension-pack-lifecycle.md → reference/extension-pack-lifecycle.md} +0 -0
- /package/docs/{interaction-and-reflection.md → reference/interaction-and-reflection.md} +0 -0
- /package/docs/{local-state.md → reference/local-state.md} +0 -0
- /package/docs/{ontology.md → reference/ontology.md} +0 -0
- /package/docs/{optional-ingestion.md → reference/optional-ingestion.md} +0 -0
- /package/docs/{project-command-inventory.md → reference/project-command-inventory.md} +0 -0
- /package/docs/{semantic-ingestion.md → reference/semantic-ingestion.md} +0 -0
- /package/docs/{vault-service.md → reference/vault-service.md} +0 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
// Read-only planner for a consumer's own dependency closure (`upgrade closure`).
|
|
2
|
+
//
|
|
3
|
+
// It reads the candidate's package.json and package-lock.json, the installed
|
|
4
|
+
// manifest of every lockfile copy of the selected package, the package.json of
|
|
5
|
+
// each directory above the npm root (to refuse a parent that declares
|
|
6
|
+
// workspaces) and, when no package is named, this package's own package.json
|
|
7
|
+
// for the default name. It classifies what an offline reinstall proof would
|
|
8
|
+
// need. It never runs npm, never uses the network, never reads npm
|
|
9
|
+
// configuration or credentials, and never writes. A completed plan is not a
|
|
10
|
+
// proof: `proof` is always 'not-run', and no path here exits 0.
|
|
11
|
+
//
|
|
12
|
+
// Refusals reuse the existing typed codes. `usage` covers an input this plan
|
|
13
|
+
// does not support, `consumer-closure-incomplete` a lockfile entry that an
|
|
14
|
+
// offline reinstall could not be proven for, and `override-not-inherited` a
|
|
15
|
+
// copy the lockfile holds away from the selected package's pin. Each refusal
|
|
16
|
+
// also carries one `reason` from the bounded list in HINTS below.
|
|
17
|
+
|
|
18
|
+
import crypto from 'node:crypto'
|
|
19
|
+
import fs from 'node:fs'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import { AtelierDiagnosticError } from '../project/config.mjs'
|
|
22
|
+
import {
|
|
23
|
+
CONSUMER_CLOSURE_INCOMPLETE,
|
|
24
|
+
OVERRIDE_NOT_INHERITED,
|
|
25
|
+
capturedClosure,
|
|
26
|
+
npmTreeLines,
|
|
27
|
+
overrideFindings,
|
|
28
|
+
} from './closure-diagnostics.mjs'
|
|
29
|
+
|
|
30
|
+
const USAGE = 'usage'
|
|
31
|
+
|
|
32
|
+
export const CLOSURE_PLAN_EXIT = 3
|
|
33
|
+
export const CLOSURE_REFUSAL_EXIT = 2
|
|
34
|
+
|
|
35
|
+
const HINTS = {
|
|
36
|
+
'npm-root-required': 'Pass --npm-root with the absolute path of the staged candidate\'s npm root; this command has no default directory.',
|
|
37
|
+
'npm-root-not-absolute': 'Pass --npm-root as an absolute path, so the plan cannot follow the current directory.',
|
|
38
|
+
'npm-root-not-directory': 'Check the path: --npm-root must name an existing directory.',
|
|
39
|
+
'npm-root-linked': 'Pass the real path of the npm root, given as realPath in the JSON result. npm works from the physical directory, so a path through a link can name a project whose parent directories this plan did not check. On macOS, /tmp is a link into /private and $TMPDIR passes through /var, another such link, so a candidate staged there is refused until its real path is given.',
|
|
40
|
+
'argument-unsupported': 'This command plans only and accepts --npm-root, --package and --registry-host, each once and each with a value. It does not fetch, install or prove.',
|
|
41
|
+
'manifest-missing': 'Point --npm-root at the directory that holds the candidate\'s own package.json.',
|
|
42
|
+
'lockfile-missing': 'Install the candidate with npm so it has its own package-lock.json, then plan again.',
|
|
43
|
+
'lockfile-invalid': 'Regenerate the candidate\'s package-lock.json with npm; this plan reads it as a JSON object whose packages map holds one object per entry.',
|
|
44
|
+
'lockfile-version': 'Regenerate the lockfile with a current npm (lockfileVersion 2 or 3); version 1 has no packages map to classify.',
|
|
45
|
+
workspaces: 'npm workspaces are not supported by this plan. Check a single npm root project, or record the workspace as unchecked.',
|
|
46
|
+
'prefix-mismatch': 'A parent directory declares npm workspaces. If its workspaces include this directory, npm treats that parent as the root project; this plan does not resolve workspace membership, so it refuses any such parent. Stage the candidate where no parent directory declares workspaces.',
|
|
47
|
+
'linked-node-modules': 'The candidate needs its own node_modules directory; a link shares an installation with another project.',
|
|
48
|
+
'missing-integrity': 'An offline reinstall needs every registry entry locked with both resolved and integrity; inspect how the candidate resolved these entries.',
|
|
49
|
+
'git-source': 'A git dependency cannot be captured from the registry. Record it as unchecked, or replace it with a published version before proving the closure.',
|
|
50
|
+
'other-host': 'This tarball is on a host that was not named as an approved registry. Name the host with --registry-host only if the owner approves it.',
|
|
51
|
+
'outside-project': 'This entry points outside the candidate. Record it as unchecked, or bring the dependency inside the project or onto the registry.',
|
|
52
|
+
'link-target-missing': 'A link entry must name the folder it resolves to. Regenerate the lockfile with npm, or record the linked dependency as unchecked.',
|
|
53
|
+
'zero-copies': 'The lockfile lists no copy of a package it must hold, so nothing was checked. Reinstall the candidate and confirm the lockfile is the candidate\'s own.',
|
|
54
|
+
'selected-package-unreadable': 'Install the selected package in the candidate, so its manifest and overrides can be read from the candidate\'s own node_modules.',
|
|
55
|
+
'selected-package-mismatch': 'The installed manifest is not the package the lockfile records at that path, so its overrides were not used. Reinstall the candidate from its own lockfile, then plan again.',
|
|
56
|
+
'selected-copies-differ': 'The lockfile installs the selected package more than once and the copies do not agree, so no single set of overrides applies. Deduplicate the selected package, or record its overrides as unchecked.',
|
|
57
|
+
'copy-off-pin': 'npm applies overrides only in the root project, so consumers never receive them. Changing a pin or an override in the owner\'s project needs the owner\'s consent.',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const HOSTLESS_REGISTRY_HINT = 'This tarball URL has no readable host, so no --registry-host can approve it. Regenerate the lockfile with npm so the entry records a full registry URL, or record it as unchecked.'
|
|
61
|
+
|
|
62
|
+
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex')
|
|
63
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
64
|
+
|
|
65
|
+
// A parsed JSON object, or the reason it could not be read.
|
|
66
|
+
function readJsonObject(file) {
|
|
67
|
+
let bytes
|
|
68
|
+
try {
|
|
69
|
+
bytes = fs.readFileSync(file)
|
|
70
|
+
} catch {
|
|
71
|
+
return { missing: true }
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const value = JSON.parse(bytes.toString('utf8'))
|
|
75
|
+
return isObject(value) ? { value, bytes } : { invalid: true }
|
|
76
|
+
} catch {
|
|
77
|
+
return { invalid: true }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Lexical containment: `target` resolved against the npm root stays inside it.
|
|
82
|
+
// The target need not exist, so links are not followed here.
|
|
83
|
+
function insideProject(npmRoot, target) {
|
|
84
|
+
const relative = path.relative(npmRoot, path.resolve(npmRoot, target))
|
|
85
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A lockfile path under a node_modules directory names an installed package;
|
|
89
|
+
// any other path is a folder of the project itself, the target of a link.
|
|
90
|
+
const installedPath = (entryPath) => /(?:^|\/)node_modules\//.test(entryPath)
|
|
91
|
+
|
|
92
|
+
const declaresWorkspaces = (manifest) => {
|
|
93
|
+
const workspaces = manifest?.workspaces
|
|
94
|
+
if (Array.isArray(workspaces)) return workspaces.length > 0
|
|
95
|
+
return isObject(workspaces)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The nearest parent directory whose package.json declares workspaces, or null.
|
|
99
|
+
function workspaceAncestor(npmRoot) {
|
|
100
|
+
let current = path.dirname(npmRoot)
|
|
101
|
+
for (;;) {
|
|
102
|
+
const manifest = readJsonObject(path.join(current, 'package.json'))
|
|
103
|
+
if (manifest.value && declaresWorkspaces(manifest.value)) return current
|
|
104
|
+
const parent = path.dirname(current)
|
|
105
|
+
if (parent === current) return null
|
|
106
|
+
current = parent
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// node_modules must be the candidate's own directory: absent is allowed here,
|
|
111
|
+
// a link or a directory that resolves elsewhere is not.
|
|
112
|
+
function nodeModulesIsLinked(npmRoot) {
|
|
113
|
+
const own = path.join(npmRoot, 'node_modules')
|
|
114
|
+
let stat
|
|
115
|
+
try {
|
|
116
|
+
stat = fs.lstatSync(own)
|
|
117
|
+
} catch {
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
if (stat.isSymbolicLink()) return true
|
|
121
|
+
try {
|
|
122
|
+
return fs.realpathSync(own) !== path.join(fs.realpathSync(npmRoot), 'node_modules')
|
|
123
|
+
} catch {
|
|
124
|
+
return true
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// npm's rule for one platform field (as in npm-install-checks): the list must
|
|
129
|
+
// match none of its negated values and, when it has plain values, at least one
|
|
130
|
+
// of them. `any` alone allows everything.
|
|
131
|
+
function listAllows(value, list) {
|
|
132
|
+
const values = (Array.isArray(list) ? list : [list]).filter((item) => typeof item === 'string')
|
|
133
|
+
if (values.length === 0) return true
|
|
134
|
+
if (values.length === 1 && values[0] === 'any') return true
|
|
135
|
+
let negated = 0
|
|
136
|
+
let matched = false
|
|
137
|
+
for (const item of values) {
|
|
138
|
+
if (item.startsWith('!')) {
|
|
139
|
+
negated += 1
|
|
140
|
+
if (item.slice(1) === value) return false
|
|
141
|
+
} else if (item === value) {
|
|
142
|
+
matched = true
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return matched || negated === values.length
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The lock field that provably excludes this host, or null. Only evidence
|
|
149
|
+
// counts: an unknown host value never excludes, and `libc` is compared only on
|
|
150
|
+
// a Linux host whose libc is known.
|
|
151
|
+
function platformExclusion(entry, host) {
|
|
152
|
+
if (entry?.os !== undefined && typeof host?.os === 'string' && !listAllows(host.os, entry.os)) return 'os'
|
|
153
|
+
if (entry?.cpu !== undefined && typeof host?.cpu === 'string' && !listAllows(host.cpu, entry.cpu)) return 'cpu'
|
|
154
|
+
if (entry?.libc !== undefined && host?.os === 'linux' && typeof host?.libc === 'string' && !listAllows(host.libc, entry.libc)) return 'libc'
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Where each lockfile entry comes from, with the three source kinds
|
|
159
|
+
// `inspectPackageProvenance` already uses (`npm`, `git`, `local_path`), plus
|
|
160
|
+
// links and bundled entries. Unlike `capturedClosure`, which skips what it
|
|
161
|
+
// cannot cache, this names every entry an offline proof could not capture from
|
|
162
|
+
// an approved registry:
|
|
163
|
+
// - `git-source`: a git dependency;
|
|
164
|
+
// - `other-host`: a tarball on a host outside `approvedRegistryHosts`, when
|
|
165
|
+
// the caller names approved hosts;
|
|
166
|
+
// - `outside-project`: a link, folder or local file outside the npm root, or a
|
|
167
|
+
// source with a scheme this plan does not know;
|
|
168
|
+
// - `link-target-missing`: a link with no folder named in `resolved`.
|
|
169
|
+
// An installed entry with no `resolved` is counted as `unclassified`, so the
|
|
170
|
+
// counts always add up to the number of entries; refusing it is left to
|
|
171
|
+
// `capturedClosure`. `registrySchemes` counts http and https separately:
|
|
172
|
+
// approving a host says nothing about the transport. A registry URL that does
|
|
173
|
+
// not parse is counted under `unparseable` and, when approved hosts are named,
|
|
174
|
+
// is always a finding: no approved host can match it.
|
|
175
|
+
export function lockSourceFindings(lock, { npmRoot, approvedRegistryHosts = null } = {}) {
|
|
176
|
+
const counts = { npm: 0, git: 0, local_path: 0, link: 0, bundled: 0, unclassified: 0 }
|
|
177
|
+
const registryHosts = {}
|
|
178
|
+
const registrySchemes = {}
|
|
179
|
+
const findings = []
|
|
180
|
+
const approved = Array.isArray(approvedRegistryHosts) && approvedRegistryHosts.length > 0 ? new Set(approvedRegistryHosts) : null
|
|
181
|
+
const entries = Object.entries(lock?.packages ?? {}).sort(([left], [right]) => left.localeCompare(right))
|
|
182
|
+
for (const [entryPath, entry] of entries) {
|
|
183
|
+
if (entryPath === '') continue
|
|
184
|
+
if (entry?.inBundle) {
|
|
185
|
+
counts.bundled += 1
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
const resolved = typeof entry?.resolved === 'string' ? entry.resolved : null
|
|
189
|
+
if (entry?.link) {
|
|
190
|
+
counts.link += 1
|
|
191
|
+
if (resolved === null || resolved.trim() === '') findings.push({ reason: 'link-target-missing', path: entryPath })
|
|
192
|
+
else if (!insideProject(npmRoot, resolved)) findings.push({ reason: 'outside-project', path: entryPath })
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
if (!installedPath(entryPath)) {
|
|
196
|
+
counts.local_path += 1
|
|
197
|
+
if (!insideProject(npmRoot, entryPath)) findings.push({ reason: 'outside-project', path: entryPath })
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
if (resolved === null) {
|
|
201
|
+
counts.unclassified += 1
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
if (/^(?:git\+|git:)/.test(resolved)) {
|
|
205
|
+
counts.git += 1
|
|
206
|
+
findings.push({ reason: 'git-source', path: entryPath })
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
if (/^https?:\/\//.test(resolved)) {
|
|
210
|
+
counts.npm += 1
|
|
211
|
+
let host = null
|
|
212
|
+
try {
|
|
213
|
+
host = new URL(resolved).host
|
|
214
|
+
} catch {
|
|
215
|
+
host = null
|
|
216
|
+
}
|
|
217
|
+
const scheme = resolved.slice(0, resolved.indexOf(':'))
|
|
218
|
+
registrySchemes[scheme] = (registrySchemes[scheme] ?? 0) + 1
|
|
219
|
+
// An empty host is as unusable as a URL that does not parse.
|
|
220
|
+
const key = host || 'unparseable'
|
|
221
|
+
registryHosts[key] = (registryHosts[key] ?? 0) + 1
|
|
222
|
+
if (approved && (!host || !approved.has(host))) findings.push({ reason: 'other-host', path: entryPath, host: host || null })
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
counts.local_path += 1
|
|
226
|
+
const local = resolved.startsWith('file:') ? resolved.slice('file:'.length) : null
|
|
227
|
+
if (local === null || /^[a-z][a-z0-9+.-]*:\/\//i.test(resolved) || !insideProject(npmRoot, local)) {
|
|
228
|
+
findings.push({ reason: 'outside-project', path: entryPath })
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { counts, registryHosts, registrySchemes, findings }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// The names that link entries install: a link lives at a node_modules path and
|
|
235
|
+
// carries no version of its own, so an override for such a name cannot be
|
|
236
|
+
// compared by this plan.
|
|
237
|
+
function linkedNames(lock) {
|
|
238
|
+
const names = new Set()
|
|
239
|
+
for (const [entryPath, entry] of Object.entries(lock?.packages ?? {})) {
|
|
240
|
+
if (entryPath === '' || !entry?.link || !installedPath(entryPath)) continue
|
|
241
|
+
names.add(entryPath.slice(entryPath.lastIndexOf('node_modules/') + 'node_modules/'.length))
|
|
242
|
+
}
|
|
243
|
+
return names
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Every copy of `name` the lockfile installs, by the same rule the override
|
|
247
|
+
// classifier uses: one per node_modules path, named by the entry's own `name`
|
|
248
|
+
// (an npm: alias) or else by its path; links are not copies.
|
|
249
|
+
function lockCopies(lock, name) {
|
|
250
|
+
return Object.entries(lock?.packages ?? {})
|
|
251
|
+
.filter(([entryPath, entry]) => entryPath !== '' && !entry?.link && installedPath(entryPath)
|
|
252
|
+
&& (entry?.name ?? entryPath.slice(entryPath.lastIndexOf('node_modules/') + 'node_modules/'.length)) === name)
|
|
253
|
+
.map(([entryPath, entry]) => ({ path: entryPath, version: entry?.version ?? null, dependencies: Object.keys(entry?.dependencies ?? {}) }))
|
|
254
|
+
.sort((left, right) => left.path.localeCompare(right.path))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// `overrideFindings` compares unconditional exact pins only and throws on
|
|
258
|
+
// anything else. Each override is offered to it alone first, so what is later
|
|
259
|
+
// compared is exactly what that classifier accepts, and everything else is
|
|
260
|
+
// listed as unchecked instead of being silently skipped.
|
|
261
|
+
function splitOverrides(overrides) {
|
|
262
|
+
const exact = {}
|
|
263
|
+
const unchecked = []
|
|
264
|
+
for (const [name, value] of Object.entries(isObject(overrides) ? overrides : {})) {
|
|
265
|
+
try {
|
|
266
|
+
overrideFindings({ publisherOverrides: { [name]: value }, consumerLock: { packages: {} } })
|
|
267
|
+
exact[name] = value
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (!(error instanceof TypeError)) throw error
|
|
270
|
+
const reason = isObject(value) ? 'nested' : /selects versions/.test(error.message) ? 'selector-key' : 'non-exact'
|
|
271
|
+
unchecked.push({ name, reason })
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return { exact, unchecked }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The lockfile path npm's resolution gives a dependency named by its ancestry
|
|
278
|
+
// (`a > b` is b as required by a): the nearest node_modules holding it, from
|
|
279
|
+
// the requiring package outwards. Null when the lockfile has no such entry.
|
|
280
|
+
function lockPathFor(lock, names) {
|
|
281
|
+
const packages = lock?.packages ?? {}
|
|
282
|
+
let current = ''
|
|
283
|
+
for (const name of names) {
|
|
284
|
+
let base = current
|
|
285
|
+
let found = null
|
|
286
|
+
for (;;) {
|
|
287
|
+
const candidate = `${base ? `${base}/` : ''}node_modules/${name}`
|
|
288
|
+
if (Object.hasOwn(packages, candidate)) {
|
|
289
|
+
found = candidate
|
|
290
|
+
break
|
|
291
|
+
}
|
|
292
|
+
if (base === '') break
|
|
293
|
+
const cut = base.lastIndexOf('/node_modules/')
|
|
294
|
+
base = cut === -1 ? '' : base.slice(0, cut)
|
|
295
|
+
}
|
|
296
|
+
if (found === null) return null
|
|
297
|
+
const entry = packages[found]
|
|
298
|
+
current = entry?.link && typeof entry.resolved === 'string' ? entry.resolved : found
|
|
299
|
+
}
|
|
300
|
+
return current
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// One entry per dependency path in an `npm ls --all --json` tree, keyed by the
|
|
304
|
+
// names along the path. It is built from `npmTreeLines`, so a package with no
|
|
305
|
+
// installed version is read the same way the captured-closure check reads it.
|
|
306
|
+
function treeVersions(npmLsJson) {
|
|
307
|
+
const versions = new Map()
|
|
308
|
+
for (const line of npmTreeLines(npmLsJson)) {
|
|
309
|
+
const names = []
|
|
310
|
+
let version = null
|
|
311
|
+
for (const segment of line.split(' > ')) {
|
|
312
|
+
const at = segment.lastIndexOf('@')
|
|
313
|
+
names.push(segment.slice(0, at))
|
|
314
|
+
version = segment.slice(at + 1)
|
|
315
|
+
}
|
|
316
|
+
versions.set(names.join(' > '), { names, version: version === 'missing' ? null : version })
|
|
317
|
+
}
|
|
318
|
+
return versions
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// The differences between the tree the lockfile describes (`npm ls --all
|
|
322
|
+
// --json --package-lock-only`) and the installed tree (`npm ls --all --json`),
|
|
323
|
+
// by dependency path and version, each classified on evidence:
|
|
324
|
+
// - `stale`: two different versions at one dependency path;
|
|
325
|
+
// - `otherPlatform`: no installed version, and the lockfile entry's `os`,
|
|
326
|
+
// `cpu` or `libc` excludes this host;
|
|
327
|
+
// - `missing`: no installed version and no such evidence;
|
|
328
|
+
// - `extraneous`: installed, and not in the lockfile's tree.
|
|
329
|
+
// `agrees` is true only when both trees report no problems and nothing is
|
|
330
|
+
// stale, missing or extraneous. Both trees are passed in; nothing runs npm.
|
|
331
|
+
export function lockTreeDifferences({ lock, lockTree, installedTree, host } = {}) {
|
|
332
|
+
const problems = [...(lockTree?.problems ?? []), ...(installedTree?.problems ?? [])].map(String)
|
|
333
|
+
const locked = treeVersions(lockTree)
|
|
334
|
+
const installed = treeVersions(installedTree)
|
|
335
|
+
const stale = []
|
|
336
|
+
const otherPlatform = []
|
|
337
|
+
const missing = []
|
|
338
|
+
const extraneous = []
|
|
339
|
+
for (const [dependencyPath, want] of locked) {
|
|
340
|
+
const have = installed.get(dependencyPath)
|
|
341
|
+
if (have && have.version !== null) {
|
|
342
|
+
if (have.version !== want.version) stale.push({ dependencyPath, locked: want.version, installed: have.version })
|
|
343
|
+
continue
|
|
344
|
+
}
|
|
345
|
+
const lockPath = lockPathFor(lock, want.names)
|
|
346
|
+
const excludedBy = lockPath === null ? null : platformExclusion(lock.packages[lockPath], host)
|
|
347
|
+
if (excludedBy) otherPlatform.push({ dependencyPath, lockPath, excludedBy })
|
|
348
|
+
else missing.push({ dependencyPath, lockPath })
|
|
349
|
+
}
|
|
350
|
+
for (const [dependencyPath, have] of installed) {
|
|
351
|
+
if (!locked.has(dependencyPath)) extraneous.push({ dependencyPath, installed: have.version })
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
agrees: problems.length === 0 && stale.length === 0 && missing.length === 0 && extraneous.length === 0,
|
|
355
|
+
problems,
|
|
356
|
+
stale,
|
|
357
|
+
otherPlatform,
|
|
358
|
+
missing,
|
|
359
|
+
extraneous,
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// This package's own name: the default selected package of a guided upgrade.
|
|
364
|
+
function ownPackageName() {
|
|
365
|
+
return JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).name
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// The plan for one explicit npm root. It never throws for a classified case:
|
|
369
|
+
// every refusal is an entry in `refusals`, with the typed code, one bounded
|
|
370
|
+
// reason, a message and a next step. `status` is 'planned' or 'refused';
|
|
371
|
+
// `proof` is always 'not-run'.
|
|
372
|
+
//
|
|
373
|
+
// - `npmRoot`: absolute path of the staged candidate's npm root. Required.
|
|
374
|
+
// - `selectedPackage`: the package whose exact overrides are checked against
|
|
375
|
+
// the candidate's lockfile. Defaults to this package.
|
|
376
|
+
// - `approvedRegistryHosts`: when given, a registry tarball on any other host
|
|
377
|
+
// is refused. When absent, hosts are reported and left unverified, because
|
|
378
|
+
// this plan does not read npm configuration.
|
|
379
|
+
// - `host`: `{ os, cpu, libc }` for the platform evidence. Defaults to this
|
|
380
|
+
// process, with libc unknown.
|
|
381
|
+
export function planConsumerClosure({ npmRoot, selectedPackage = null, approvedRegistryHosts = null, host = null } = {}) {
|
|
382
|
+
const plan = {
|
|
383
|
+
command: 'upgrade closure',
|
|
384
|
+
proof: 'not-run',
|
|
385
|
+
status: 'refused',
|
|
386
|
+
npmRoot: typeof npmRoot === 'string' ? npmRoot : null,
|
|
387
|
+
prefixVerified: false,
|
|
388
|
+
approvedRegistryVerified: false,
|
|
389
|
+
refusals: [],
|
|
390
|
+
}
|
|
391
|
+
const refuse = (code, reason, message, detail = {}) => {
|
|
392
|
+
plan.refusals.push({ code, reason, message, hint: HINTS[reason], ...detail })
|
|
393
|
+
return plan
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (typeof npmRoot !== 'string' || npmRoot.trim() === '') {
|
|
397
|
+
return refuse(USAGE, 'npm-root-required', '--npm-root is required: name the staged candidate\'s npm root explicitly')
|
|
398
|
+
}
|
|
399
|
+
if (!path.isAbsolute(npmRoot)) return refuse(USAGE, 'npm-root-not-absolute', '--npm-root must be an absolute path')
|
|
400
|
+
const root = path.resolve(npmRoot)
|
|
401
|
+
plan.npmRoot = root
|
|
402
|
+
let rootIsDirectory = false
|
|
403
|
+
try {
|
|
404
|
+
rootIsDirectory = fs.statSync(root).isDirectory()
|
|
405
|
+
} catch {
|
|
406
|
+
rootIsDirectory = false
|
|
407
|
+
}
|
|
408
|
+
if (!rootIsDirectory) return refuse(USAGE, 'npm-root-not-directory', '--npm-root does not name an existing directory')
|
|
409
|
+
// npm works from the physical directory. A path through a link would have
|
|
410
|
+
// its own parents checked for workspaces here, not the parents npm sees.
|
|
411
|
+
let realRoot = null
|
|
412
|
+
try {
|
|
413
|
+
realRoot = fs.realpathSync(root)
|
|
414
|
+
} catch {
|
|
415
|
+
realRoot = null
|
|
416
|
+
}
|
|
417
|
+
if (realRoot !== root) {
|
|
418
|
+
return refuse(USAGE, 'npm-root-linked', '--npm-root is not the real path of the directory: it is a link or passes through one',
|
|
419
|
+
realRoot === null ? {} : { realPath: realRoot })
|
|
420
|
+
}
|
|
421
|
+
if (selectedPackage !== null && selectedPackage !== undefined && (typeof selectedPackage !== 'string' || selectedPackage.trim() === '')) {
|
|
422
|
+
return refuse(USAGE, 'argument-unsupported', '--package needs a package name', { argument: '--package' })
|
|
423
|
+
}
|
|
424
|
+
if (approvedRegistryHosts !== null && approvedRegistryHosts !== undefined
|
|
425
|
+
&& (!Array.isArray(approvedRegistryHosts) || approvedRegistryHosts.length === 0
|
|
426
|
+
|| approvedRegistryHosts.some((item) => typeof item !== 'string' || item.trim() === ''))) {
|
|
427
|
+
return refuse(USAGE, 'argument-unsupported', '--registry-host needs at least one host name', { argument: '--registry-host' })
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const manifest = readJsonObject(path.join(root, 'package.json'))
|
|
431
|
+
if (!manifest.value) return refuse(USAGE, 'manifest-missing', 'the npm root has no readable package.json')
|
|
432
|
+
if (declaresWorkspaces(manifest.value)) return refuse(USAGE, 'workspaces', 'the npm root declares npm workspaces, which this plan does not support')
|
|
433
|
+
if (workspaceAncestor(root) !== null) {
|
|
434
|
+
return refuse(USAGE, 'prefix-mismatch', 'a parent directory declares npm workspaces, so npm may not treat this directory as its own root project')
|
|
435
|
+
}
|
|
436
|
+
if (nodeModulesIsLinked(root)) {
|
|
437
|
+
return refuse(USAGE, 'linked-node-modules', 'node_modules in the npm root is a link or resolves inside another installation')
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const lockFile = readJsonObject(path.join(root, 'package-lock.json'))
|
|
441
|
+
if (lockFile.missing) return refuse(USAGE, 'lockfile-missing', 'the npm root has no package-lock.json')
|
|
442
|
+
if (lockFile.invalid) return refuse(USAGE, 'lockfile-invalid', 'package-lock.json in the npm root is not a JSON object')
|
|
443
|
+
const lock = lockFile.value
|
|
444
|
+
if (![2, 3].includes(lock.lockfileVersion) || !isObject(lock.packages)) {
|
|
445
|
+
return refuse(USAGE, 'lockfile-version', 'package-lock.json must be lockfileVersion 2 or 3 with a packages map')
|
|
446
|
+
}
|
|
447
|
+
// Every classifier below iterates the packages map, so its shape is checked
|
|
448
|
+
// once here and a malformed entry is a typed refusal, never a TypeError.
|
|
449
|
+
const malformed = Object.keys(lock.packages).filter((entryPath) => !isObject(lock.packages[entryPath])).sort((left, right) => left.localeCompare(right))
|
|
450
|
+
if (malformed.length > 0) {
|
|
451
|
+
return refuse(USAGE, 'lockfile-invalid', `package-lock.json records ${JSON.stringify(malformed[0])} as something other than a package entry`,
|
|
452
|
+
{ path: malformed[0], malformedEntries: malformed.length })
|
|
453
|
+
}
|
|
454
|
+
if (declaresWorkspaces(lock.packages[''])) {
|
|
455
|
+
return refuse(USAGE, 'workspaces', 'the lockfile records npm workspaces, which this plan does not support')
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
plan.lock = {
|
|
459
|
+
file: 'package-lock.json',
|
|
460
|
+
sha256: sha256(lockFile.bytes),
|
|
461
|
+
lockfileVersion: lock.lockfileVersion,
|
|
462
|
+
entries: Object.keys(lock.packages).filter((entryPath) => entryPath !== '').length,
|
|
463
|
+
}
|
|
464
|
+
plan.host = { os: host?.os ?? process.platform, cpu: host?.cpu ?? process.arch, libc: host?.libc ?? null }
|
|
465
|
+
|
|
466
|
+
const sources = lockSourceFindings(lock, { npmRoot: root, approvedRegistryHosts })
|
|
467
|
+
plan.sources = { counts: sources.counts, registryHosts: sources.registryHosts, registrySchemes: sources.registrySchemes }
|
|
468
|
+
plan.approvedRegistryVerified = Array.isArray(approvedRegistryHosts) && approvedRegistryHosts.length > 0
|
|
469
|
+
const sourceMessages = {
|
|
470
|
+
'git-source': (finding) => `the lockfile records ${finding.path} from a git source, which an offline reinstall from the registry cannot capture`,
|
|
471
|
+
'other-host': (finding) => (finding.host === null
|
|
472
|
+
? `the lockfile records ${finding.path} from a registry URL with no readable host, which no approved registry host can match`
|
|
473
|
+
: `the lockfile records ${finding.path} from ${finding.host}, which is not an approved registry host`),
|
|
474
|
+
'link-target-missing': (finding) => `the lockfile records ${finding.path} as a link without the folder it resolves to`,
|
|
475
|
+
'outside-project': (finding) => `the lockfile records ${finding.path} from a source outside the npm root`,
|
|
476
|
+
}
|
|
477
|
+
for (const finding of sources.findings) {
|
|
478
|
+
const { reason, ...detail } = finding
|
|
479
|
+
// Naming a host cannot approve a URL that has none, so that case gets its own next step.
|
|
480
|
+
const hint = reason === 'other-host' && finding.host === null ? { hint: HOSTLESS_REGISTRY_HINT } : {}
|
|
481
|
+
refuse(CONSUMER_CLOSURE_INCOMPLETE, reason, sourceMessages[reason](finding), { ...detail, ...hint })
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const { tarballs, missing } = capturedClosure(lock)
|
|
485
|
+
plan.capture = { registryTarballs: tarballs.length }
|
|
486
|
+
for (const entryPath of missing.filter(installedPath)) {
|
|
487
|
+
refuse(CONSUMER_CLOSURE_INCOMPLETE, 'missing-integrity',
|
|
488
|
+
`the lockfile records ${entryPath} without a registry resolved URL or integrity`, { path: entryPath })
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
plan.otherPlatform = Object.entries(lock.packages)
|
|
492
|
+
.filter(([entryPath, entry]) => entryPath !== '' && installedPath(entryPath) && !entry?.link)
|
|
493
|
+
.map(([entryPath, entry]) => ({ path: entryPath, optional: entry?.optional === true, excludedBy: platformExclusion(entry, plan.host) }))
|
|
494
|
+
.filter((item) => item.excludedBy !== null)
|
|
495
|
+
.sort((left, right) => left.path.localeCompare(right.path))
|
|
496
|
+
|
|
497
|
+
const selected = typeof selectedPackage === 'string' ? selectedPackage.trim() : ownPackageName()
|
|
498
|
+
const copies = lockCopies(lock, selected)
|
|
499
|
+
// `manifests` names every installed manifest this plan read for the selected
|
|
500
|
+
// package, with the identity it declares, so a reader can see what the
|
|
501
|
+
// override check was based on.
|
|
502
|
+
plan.selectedPackage = { name: selected, copies: copies.map(({ path: copyPath, version }) => ({ path: copyPath, version })), manifests: [] }
|
|
503
|
+
plan.overrides = { checked: [], notInstalled: [], unchecked: [], multipleCopiesAtPin: [] }
|
|
504
|
+
if (copies.length === 0) {
|
|
505
|
+
refuse(CONSUMER_CLOSURE_INCOMPLETE, 'zero-copies',
|
|
506
|
+
`the lockfile lists no copy of the selected package ${selected}, so its overrides were not checked`, { package: selected })
|
|
507
|
+
} else {
|
|
508
|
+
// Each copy's manifest is bound to its own lockfile entry by name and
|
|
509
|
+
// version. Overrides are used only when every copy is bound and all copies
|
|
510
|
+
// agree; a refused or unreadable copy contributes nothing.
|
|
511
|
+
const bound = []
|
|
512
|
+
for (const copy of copies) {
|
|
513
|
+
const manifestPath = `${copy.path}/package.json`
|
|
514
|
+
const installedManifest = insideProject(root, copy.path)
|
|
515
|
+
? readJsonObject(path.join(root, copy.path, 'package.json'))
|
|
516
|
+
: { missing: true }
|
|
517
|
+
if (!installedManifest.value) {
|
|
518
|
+
refuse(CONSUMER_CLOSURE_INCOMPLETE, 'selected-package-unreadable',
|
|
519
|
+
`the manifest of the selected package ${selected} cannot be read at ${copy.path}, so its overrides were not checked`,
|
|
520
|
+
{ package: selected, path: copy.path })
|
|
521
|
+
continue
|
|
522
|
+
}
|
|
523
|
+
const declared = {
|
|
524
|
+
name: typeof installedManifest.value.name === 'string' ? installedManifest.value.name : null,
|
|
525
|
+
version: typeof installedManifest.value.version === 'string' ? installedManifest.value.version : null,
|
|
526
|
+
}
|
|
527
|
+
plan.selectedPackage.manifests.push({ path: manifestPath, ...declared, sha256: sha256(installedManifest.bytes) })
|
|
528
|
+
if (declared.name !== selected || copy.version === null || declared.version !== copy.version) {
|
|
529
|
+
refuse(CONSUMER_CLOSURE_INCOMPLETE, 'selected-package-mismatch',
|
|
530
|
+
`the manifest at ${manifestPath} declares ${declared.name ?? 'no name'}@${declared.version ?? 'no version'}, but the lockfile records ${selected}@${copy.version ?? 'no version'} there, so its overrides were not used`,
|
|
531
|
+
{ package: selected, path: copy.path, locked: { name: selected, version: copy.version }, manifest: declared })
|
|
532
|
+
continue
|
|
533
|
+
}
|
|
534
|
+
bound.push({ copy, overrides: installedManifest.value.overrides })
|
|
535
|
+
}
|
|
536
|
+
const lockedVersions = [...new Set(copies.map((copy) => String(copy.version)))]
|
|
537
|
+
const overrideSets = [...new Set(bound.map((item) => JSON.stringify(item.overrides ?? null)))]
|
|
538
|
+
if (lockedVersions.length > 1 || (bound.length === copies.length && overrideSets.length > 1)) {
|
|
539
|
+
refuse(CONSUMER_CLOSURE_INCOMPLETE, 'selected-copies-differ',
|
|
540
|
+
`the lockfile installs the selected package ${selected} at ${copies.map((copy) => `${copy.path}@${copy.version}`).join(', ')}, and the copies do not agree, so its overrides were not checked`,
|
|
541
|
+
{ package: selected, copies: plan.selectedPackage.copies })
|
|
542
|
+
} else if (bound.length === copies.length) {
|
|
543
|
+
const { exact, unchecked } = splitOverrides(bound[0].overrides)
|
|
544
|
+
plan.overrides.unchecked = unchecked
|
|
545
|
+
const { findings, compared } = overrideFindings({ publisherOverrides: exact, consumerLock: lock })
|
|
546
|
+
const required = new Set(copies.flatMap((copy) => copy.dependencies))
|
|
547
|
+
const linked = linkedNames(lock)
|
|
548
|
+
const offPin = new Set()
|
|
549
|
+
for (const finding of findings) {
|
|
550
|
+
const away = finding.found.filter((copy) => copy.version !== finding.pinned)
|
|
551
|
+
if (away.length === 0) {
|
|
552
|
+
// More than one copy, all at the pin: valid in an owner's tree.
|
|
553
|
+
plan.overrides.multipleCopiesAtPin.push({ package: finding.package, pinned: finding.pinned, found: finding.found })
|
|
554
|
+
continue
|
|
555
|
+
}
|
|
556
|
+
offPin.add(finding.package)
|
|
557
|
+
refuse(OVERRIDE_NOT_INHERITED, 'copy-off-pin',
|
|
558
|
+
`${finding.package} is pinned to ${finding.pinned} by the selected package's override, but the lockfile installs ${finding.found.map((copy) => `${copy.path}@${copy.version}`).join(', ')}`,
|
|
559
|
+
{ package: finding.package, pinned: finding.pinned, found: finding.found })
|
|
560
|
+
}
|
|
561
|
+
for (const name of Object.keys(exact)) {
|
|
562
|
+
if (linked.has(name)) {
|
|
563
|
+
// A link provides this package and the plan does not read the linked
|
|
564
|
+
// folder's version: never checked and never "not installed". An
|
|
565
|
+
// off-pin registry copy beside the link is still refused above.
|
|
566
|
+
plan.overrides.unchecked.push({ name, reason: compared.includes(name) ? 'linked-copy' : 'link-only' })
|
|
567
|
+
} else if (compared.includes(name)) {
|
|
568
|
+
if (!offPin.has(name)) plan.overrides.checked.push(name)
|
|
569
|
+
} else if (required.has(name)) {
|
|
570
|
+
// The selected package depends on it, yet the lockfile holds no copy:
|
|
571
|
+
// a failed listing, never a pass.
|
|
572
|
+
refuse(CONSUMER_CLOSURE_INCOMPLETE, 'zero-copies',
|
|
573
|
+
`the selected package ${selected} depends on ${name}, but the lockfile lists no copy of it`, { package: name })
|
|
574
|
+
} else {
|
|
575
|
+
plan.overrides.notInstalled.push(name)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
plan.status = plan.refusals.length === 0 ? 'planned' : 'refused'
|
|
582
|
+
return plan
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// The plan's first refusal as the typed error the CLI prints, or null.
|
|
586
|
+
export function closureRefusalError(plan) {
|
|
587
|
+
const [first, ...rest] = plan?.refusals ?? []
|
|
588
|
+
if (!first) return null
|
|
589
|
+
const more = rest.length > 0 ? ` (and ${rest.length} more; see the JSON result)` : ''
|
|
590
|
+
return new AtelierDiagnosticError(first.code, `${first.message}${more}`, { hint: first.hint, exitCode: CLOSURE_REFUSAL_EXIT })
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const CLOSURE_FLAGS = ['npm-root', 'package', 'registry-host']
|
|
594
|
+
|
|
595
|
+
// How often each closure flag appears in the raw arguments, in either
|
|
596
|
+
// spelling (`--flag value` or `--flag=value`). The shared parser keeps only
|
|
597
|
+
// the last value of a repeated flag, so repeats are counted here.
|
|
598
|
+
function repeatedFlags(argv) {
|
|
599
|
+
return CLOSURE_FLAGS.filter((flag) => argv.filter((item) => item === `--${flag}` || (typeof item === 'string' && item.startsWith(`--${flag}=`))).length > 1)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// `atelier upgrade closure --npm-root <absolute-dir>`. Prints exactly one JSON
|
|
603
|
+
// plan, then exits 3 for a completed plan or throws the typed refusal (exit
|
|
604
|
+
// 2). An argument this command does not accept is a refused plan too, so
|
|
605
|
+
// every run prints one document. It resolves no Atelier project and writes
|
|
606
|
+
// nothing. `argv` is the raw argument list, used only to refuse a flag given
|
|
607
|
+
// more than once.
|
|
608
|
+
export function runClosurePlanCommand(args, argv = []) {
|
|
609
|
+
// The raw arguments are checked too: the shared parser drops some names
|
|
610
|
+
// (for example --__proto__), which would otherwise pass unnoticed.
|
|
611
|
+
const rawFlags = (Array.isArray(argv) ? argv : [])
|
|
612
|
+
.filter((item) => typeof item === 'string' && item.startsWith('--'))
|
|
613
|
+
.map((item) => item.slice(2).split('=')[0])
|
|
614
|
+
const unsupported = [...new Set([
|
|
615
|
+
...Object.keys(args).filter((key) => key !== '_' && !CLOSURE_FLAGS.includes(key)).map((key) => `--${key}`),
|
|
616
|
+
...rawFlags.filter((name) => !CLOSURE_FLAGS.includes(name)).map((name) => `--${name}`),
|
|
617
|
+
]), ...(args._ ?? []).slice(1).map((item) => (item === '' ? '(empty argument)' : item))]
|
|
618
|
+
const valueless = CLOSURE_FLAGS.filter((flag) => args[flag] === true)
|
|
619
|
+
const repeated = repeatedFlags(Array.isArray(argv) ? argv : [])
|
|
620
|
+
const hosts = typeof args['registry-host'] === 'string'
|
|
621
|
+
? args['registry-host'].split(',').map((item) => item.trim()).filter(Boolean)
|
|
622
|
+
: null
|
|
623
|
+
let argumentProblem = null
|
|
624
|
+
if (unsupported.length > 0) argumentProblem = [`upgrade closure does not accept ${unsupported.join(', ')}`, unsupported]
|
|
625
|
+
else if (valueless.length > 0) argumentProblem = [`${valueless.map((flag) => `--${flag}`).join(', ')} needs a value`, valueless.map((flag) => `--${flag}`)]
|
|
626
|
+
else if (repeated.length > 0) argumentProblem = [`${repeated.map((flag) => `--${flag}`).join(', ')} was given more than once`, repeated.map((flag) => `--${flag}`)]
|
|
627
|
+
else if (typeof args.package === 'string' && args.package.trim() === '') argumentProblem = ['--package needs a package name', ['--package']]
|
|
628
|
+
else if (hosts !== null && hosts.length === 0) argumentProblem = ['--registry-host needs at least one host name', ['--registry-host']]
|
|
629
|
+
|
|
630
|
+
const plan = argumentProblem
|
|
631
|
+
? {
|
|
632
|
+
command: 'upgrade closure',
|
|
633
|
+
proof: 'not-run',
|
|
634
|
+
status: 'refused',
|
|
635
|
+
npmRoot: typeof args['npm-root'] === 'string' ? args['npm-root'] : null,
|
|
636
|
+
prefixVerified: false,
|
|
637
|
+
approvedRegistryVerified: false,
|
|
638
|
+
refusals: [{ code: USAGE, reason: 'argument-unsupported', message: argumentProblem[0], hint: HINTS['argument-unsupported'], arguments: argumentProblem[1] }],
|
|
639
|
+
}
|
|
640
|
+
: planConsumerClosure({
|
|
641
|
+
npmRoot: typeof args['npm-root'] === 'string' ? args['npm-root'] : null,
|
|
642
|
+
selectedPackage: typeof args.package === 'string' ? args.package : null,
|
|
643
|
+
approvedRegistryHosts: hosts,
|
|
644
|
+
})
|
|
645
|
+
console.log(JSON.stringify(plan, null, 2))
|
|
646
|
+
const refusal = closureRefusalError(plan)
|
|
647
|
+
if (refusal) throw refusal
|
|
648
|
+
process.exitCode = CLOSURE_PLAN_EXIT
|
|
649
|
+
}
|