rcf-lite 0.13.0 → 0.14.0
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 +29 -1
- package/bin/rcf.js +3 -1
- package/blueprints/application-api-rest/docs/topics.md +1 -1
- package/blueprints/application-spa/README.md +3 -3
- package/blueprints/application-spa/contributions/adrs/adr-202-application-spa-theming.json +1 -1
- package/blueprints/application-spa/contributions/adrs/adr-206-application-spa-iconography.json +1 -1
- package/blueprints/application-spa/contributions/tacs/tac-207-application-spa-token-adherence-probe.json +7 -7
- package/blueprints/application-spa/contributions/tacs/tac-208-application-spa-icon-adherence-probe.json +7 -7
- package/blueprints/application-spa/contributions/tacs/tac-209-application-spa-csp-styled-adherence-probe.json +8 -8
- package/blueprints/application-spa/contributions/tacs/tac-210-application-spa-external-dependency-provisioning-probe.json +8 -8
- package/blueprints/application-spa/contributions/tacs/tac-211-application-spa-core-flow-e2e-probe.json +8 -8
- package/blueprints/application-spa/contributions/user-stories/application-spa-us-1129.json +2 -2
- package/blueprints/application-spa/contributions/user-stories/application-spa-us-1130.json +2 -2
- package/blueprints/application-spa/contributions/user-stories/application-spa-us-1131.json +2 -2
- package/blueprints/application-spa/contributions/user-stories/application-spa-us-1132.json +2 -2
- package/blueprints/application-spa/contributions/user-stories/application-spa-us-1133.json +3 -3
- package/blueprints/application-spa/docs/topics.md +1 -1
- package/blueprints/delivery-ci-workflows/CHANGELOG.md +39 -0
- package/blueprints/delivery-ci-workflows/README.md +57 -0
- package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/default-branch-checks.yml +55 -0
- package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/pull-request-checks.yml +65 -0
- package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/release.yml +71 -0
- package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/scheduled-audit.yml +61 -0
- package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/notes.md +61 -0
- package/blueprints/{ci-pipeline → delivery-ci-workflows}/assets/report-samples/per-gate.json +2 -1
- package/blueprints/delivery-ci-workflows/blueprint.json +87 -0
- package/blueprints/delivery-ci-workflows/contributions/adrs/adr-701-delivery-ci-workflows-ci-gates.json +30 -0
- package/blueprints/{ci-pipeline/contributions/adrs/adr-702-ci-pipeline-strict-coverage-gate.json → delivery-ci-workflows/contributions/adrs/adr-702-delivery-ci-workflows-strict-coverage-gate.json} +2 -2
- package/blueprints/{ci-pipeline/contributions/adrs/adr-703-ci-pipeline-node-only-runner.json → delivery-ci-workflows/contributions/adrs/adr-703-delivery-ci-workflows-node-only-runner.json} +1 -1
- package/blueprints/{ci-pipeline/contributions/adrs/adr-704-ci-pipeline-report-shape.json → delivery-ci-workflows/contributions/adrs/adr-704-delivery-ci-workflows-report-shape.json} +3 -3
- package/blueprints/delivery-ci-workflows/contributions/adrs/adr-705-delivery-ci-workflows-elicitation-surface.json +25 -0
- package/blueprints/delivery-ci-workflows/contributions/adrs/adr-706-delivery-ci-workflows-branch-model-defaults.json +25 -0
- package/blueprints/delivery-ci-workflows/contributions/adrs/adr-707-delivery-ci-workflows-release-workflow-shape.json +25 -0
- package/blueprints/delivery-ci-workflows/contributions/adrs/adr-708-delivery-ci-workflows-provider-hint-shape.json +25 -0
- package/blueprints/delivery-ci-workflows/contributions/adrs/adr-709-delivery-ci-workflows-release-artefacts.json +25 -0
- package/blueprints/delivery-ci-workflows/contributions/adrs/adr-710-delivery-ci-workflows-scheduled-audit.json +25 -0
- package/blueprints/delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-001.json +18 -0
- package/blueprints/{ci-pipeline/contributions/requirements/ci-pipeline-req-002.json → delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-002.json} +2 -2
- package/blueprints/{ci-pipeline/contributions/requirements/ci-pipeline-req-003.json → delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-003.json} +2 -2
- package/blueprints/{ci-pipeline/contributions/requirements/ci-pipeline-req-004.json → delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-004.json} +2 -2
- package/blueprints/{ci-pipeline/contributions/requirements/ci-pipeline-req-005.json → delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-005.json} +4 -4
- package/blueprints/{ci-pipeline/contributions/requirements/ci-pipeline-req-006.json → delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-006.json} +2 -2
- package/blueprints/{ci-pipeline/contributions/requirements/ci-pipeline-req-007.json → delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-007.json} +2 -2
- package/blueprints/{ci-pipeline/contributions/requirements/ci-pipeline-req-008.json → delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-008.json} +2 -2
- package/blueprints/delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-009.json +18 -0
- package/blueprints/{ci-pipeline/contributions/requirements/ci-pipeline-req-010.json → delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-010.json} +2 -2
- package/blueprints/delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-011.json +18 -0
- package/blueprints/delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-012.json +18 -0
- package/blueprints/delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-013.json +18 -0
- package/blueprints/delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-014.json +18 -0
- package/blueprints/delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-015.json +18 -0
- package/blueprints/delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-016.json +18 -0
- package/blueprints/delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-017.json +18 -0
- package/blueprints/delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-018.json +18 -0
- package/blueprints/delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-019.json +18 -0
- package/blueprints/delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-020.json +18 -0
- package/blueprints/delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-021.json +18 -0
- package/blueprints/delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-022.json +18 -0
- package/blueprints/delivery-ci-workflows/contributions/requirements/delivery-ci-workflows-req-023.json +18 -0
- package/blueprints/{ci-pipeline/contributions/tacs/tac-701-ci-pipeline-gate-runner.json → delivery-ci-workflows/contributions/tacs/tac-701-delivery-ci-workflows-gate-runner.json} +3 -3
- package/blueprints/{ci-pipeline/contributions/tacs/tac-702-ci-pipeline-gate-report.json → delivery-ci-workflows/contributions/tacs/tac-702-delivery-ci-workflows-gate-report.json} +1 -1
- package/blueprints/{ci-pipeline/contributions/tacs/tac-703-ci-pipeline-aggregate-report.json → delivery-ci-workflows/contributions/tacs/tac-703-delivery-ci-workflows-aggregate-report.json} +2 -2
- package/blueprints/delivery-ci-workflows/contributions/tacs/tac-704-delivery-ci-workflows-workflow-materialiser.json +64 -0
- package/blueprints/delivery-ci-workflows/contributions/tacs/tac-705-delivery-ci-workflows-release-workflow.json +51 -0
- package/blueprints/delivery-ci-workflows/contributions/tacs/tac-706-delivery-ci-workflows-scheduled-audit.json +38 -0
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6101.json +37 -0
- package/blueprints/{ci-pipeline/contributions/user-stories/ci-pipeline-us-6102.json → delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6102.json} +3 -3
- package/blueprints/{ci-pipeline/contributions/user-stories/ci-pipeline-us-6103.json → delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6103.json} +4 -4
- package/blueprints/{ci-pipeline/contributions/user-stories/ci-pipeline-us-6104.json → delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6104.json} +4 -4
- package/blueprints/{ci-pipeline/contributions/user-stories/ci-pipeline-us-6105.json → delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6105.json} +6 -6
- package/blueprints/{ci-pipeline/contributions/user-stories/ci-pipeline-us-6106.json → delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6106.json} +3 -3
- package/blueprints/{ci-pipeline/contributions/user-stories/ci-pipeline-us-6107.json → delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6107.json} +5 -5
- package/blueprints/{ci-pipeline/contributions/user-stories/ci-pipeline-us-6108.json → delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6108.json} +4 -4
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6109.json +36 -0
- package/blueprints/{ci-pipeline/contributions/user-stories/ci-pipeline-us-6110.json → delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6110.json} +3 -3
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6111.json +36 -0
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6112.json +36 -0
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6113.json +36 -0
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6114.json +46 -0
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6115.json +37 -0
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6116.json +28 -0
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6117.json +28 -0
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6118.json +37 -0
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6119.json +28 -0
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6120.json +28 -0
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6121.json +46 -0
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6122.json +46 -0
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6123.json +37 -0
- package/blueprints/delivery-ci-workflows/docs/topics.md +61 -0
- package/blueprints/delivery-ci-workflows/guide/delivery-ci-workflows.md +136 -0
- package/blueprints/deploy-cloudflare-workers/docs/topics.md +3 -3
- package/blueprints/email-smtp-resend/docs/topics.md +1 -1
- package/blueprints/observability-essentials/README.md +2 -2
- package/blueprints/observability-essentials/docs/topics.md +5 -5
- package/blueprints/observability-probe-endpoints/docs/topics.md +2 -2
- package/blueprints/persistence-data-d1/README.md +2 -2
- package/blueprints/persistence-data-d1/assets/facade-shape/facade-module-shape.md +1 -1
- package/blueprints/persistence-data-d1/contributions/tacs/tac-1403-persistence-data-d1-deploy-gate.json +1 -1
- package/blueprints/persistence-data-d1/docs/topics.md +2 -2
- package/blueprints/persistence-data-d1/guide/persistence-data-d1.md +1 -1
- package/blueprints/persistence-data-sqlite/README.md +1 -1
- package/blueprints/persistence-data-sqlite/docs/topics.md +1 -1
- package/blueprints/security-auth-clerk/README.md +5 -3
- package/blueprints/security-auth-clerk/assets/middleware/workers-fetch-shape.md +123 -0
- package/blueprints/security-auth-clerk/assets/wiring/workers-wrangler-toml-shape.md +51 -0
- package/blueprints/security-auth-clerk/blueprint.json +1 -1
- package/blueprints/security-auth-clerk/docs/topics.md +2 -2
- package/blueprints/security-auth-clerk/guide/security-auth-clerk.md +9 -0
- package/blueprints/security-auth-keycloak/docs/topics.md +2 -2
- package/blueprints/security-auth-magic-link/README.md +1 -1
- package/blueprints/security-auth-magic-link/docs/topics.md +1 -1
- package/blueprints/security-auth-oauth2/README.md +1 -1
- package/blueprints/security-auth-oauth2/docs/topics.md +2 -2
- package/blueprints/security-secrets-management/README.md +1 -1
- package/blueprints/security-secrets-management/docs/topics.md +3 -3
- package/guidance/build-cycle-playbook.md +2 -2
- package/guidance/document-model.md +1 -1
- package/guidance/harness-template.md +13 -0
- package/guidance/managed/agent-instructions-block.hash +1 -1
- package/guidance/managed/agent-instructions-block.md +13 -0
- package/package.json +5 -2
- package/rcf/adrs/adr-001.json +1 -1
- package/rcf/adrs/adr-009.json +1 -1
- package/rcf/build-sequence.json +1 -1
- package/rcf/manifest.json +2 -2
- package/rcf/prd.json +2 -2
- package/releases/releases.yaml +116 -0
- package/src/blueprint/apply.js +51 -13
- package/src/blueprint/index.js +12 -0
- package/src/blueprint/library-loader.js +271 -0
- package/src/blueprint/library-registry.js +341 -0
- package/src/blueprint/list.js +38 -4
- package/src/blueprint/shelf-resolver.js +144 -31
- package/src/cli/blueprint-library.js +419 -0
- package/src/cli/blueprint.js +46 -9
- package/src/cli/guidance.js +1 -1
- package/src/cli/help.js +27 -1
- package/src/cli/version.js +673 -0
- package/src/cli/view.js +282 -1
- package/src/server/index.js +3 -0
- package/src/server/routes.js +15 -1
- package/src/server/scope-endpoint.js +105 -0
- package/src/view/live-client.js +253 -6
- package/src/view/scope.js +231 -0
- package/src/view/style.css +42 -0
- package/blueprints/ci-pipeline/README.md +0 -49
- package/blueprints/ci-pipeline/assets/ci-provider-examples/github-actions.yml +0 -61
- package/blueprints/ci-pipeline/assets/ci-provider-examples/notes.md +0 -50
- package/blueprints/ci-pipeline/blueprint.json +0 -46
- package/blueprints/ci-pipeline/contributions/adrs/adr-701-ci-pipeline-ci-gates.json +0 -25
- package/blueprints/ci-pipeline/contributions/requirements/ci-pipeline-req-001.json +0 -18
- package/blueprints/ci-pipeline/contributions/requirements/ci-pipeline-req-009.json +0 -18
- package/blueprints/ci-pipeline/contributions/user-stories/ci-pipeline-us-6101.json +0 -37
- package/blueprints/ci-pipeline/contributions/user-stories/ci-pipeline-us-6109.json +0 -36
- package/blueprints/ci-pipeline/docs/topics.md +0 -49
- package/blueprints/ci-pipeline/guide/ci-pipeline.md +0 -79
- package/rcf/.identity/profile.md +0 -37
- package/rcf/knowledge/INDEX.md +0 -12
- package/rcf/knowledge/README.md +0 -41
- package/rcf/knowledge/docs/.gitkeep +0 -0
- package/rcf/knowledge/notes/.gitkeep +0 -0
- /package/blueprints/{ci-pipeline → delivery-ci-workflows}/assets/report-samples/pipeline.json +0 -0
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
// `rcf version` core verb (update-awareness spec §2).
|
|
2
|
+
//
|
|
3
|
+
// Two shapes:
|
|
4
|
+
// - `rcf version` -- print `rcf-lite <semver>` to stdout, no network.
|
|
5
|
+
// - `rcf version --check` -- fetch the release feed, semver-compare
|
|
6
|
+
// against the installed version, print a compact headline diff.
|
|
7
|
+
//
|
|
8
|
+
// Design invariants (spec §2):
|
|
9
|
+
// - Zero telemetry. The fetch is a bare HTTPS GET; no identifiers, no
|
|
10
|
+
// custom headers beyond a plain UA, no query params.
|
|
11
|
+
// - Fail silent. Network failure, non-2xx, malformed JSON, unknown
|
|
12
|
+
// feedVersion -- every failure mode degrades to "installed version
|
|
13
|
+
// + one stderr line" and never blocks. Exit 3 only when --check has
|
|
14
|
+
// no usable cache to fall back on.
|
|
15
|
+
// - Cache-first. A 6-hour cache stamp keeps session-start checks off
|
|
16
|
+
// the network on a healthy install; a 24-hour hard ceiling caps the
|
|
17
|
+
// stale-cache window when the network drops.
|
|
18
|
+
// - Kill-switch honoured before any network call: `RCF_UPDATE_CHECK=off`
|
|
19
|
+
// env var OR `updateCheck: "off"` in the platform config file.
|
|
20
|
+
// - Placeholder feed (latest: null, empty releases) reads as "no
|
|
21
|
+
// update information available", NOT as an update, NOT as an error.
|
|
22
|
+
//
|
|
23
|
+
// Semver comparison is inline (three numeric segments + optional
|
|
24
|
+
// prerelease suffix per SemVer 2.0.0 §11); rcf-lite ships no runtime
|
|
25
|
+
// deps beyond ajv today and this verb should not add one.
|
|
26
|
+
|
|
27
|
+
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
|
28
|
+
import { dirname, join, resolve } from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { homedir, platform } from 'node:os';
|
|
31
|
+
import { parseArgs } from 'node:util';
|
|
32
|
+
|
|
33
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
const PACKAGE_ROOT = resolve(here, '..', '..');
|
|
35
|
+
|
|
36
|
+
// Feed URL is a compile-time constant so the tool has one truth. Tests
|
|
37
|
+
// override via the `feedUrl` dep.
|
|
38
|
+
export const FEED_URL = 'https://stravica.ai/docs/rcf/releases.json';
|
|
39
|
+
|
|
40
|
+
// Two-second budget on the network GET (spec §2.5).
|
|
41
|
+
export const FETCH_TIMEOUT_MS = 2_000;
|
|
42
|
+
|
|
43
|
+
// Six-hour cache window before a fresh fetch is attempted (spec §2.6).
|
|
44
|
+
export const CACHE_FRESH_MS = 6 * 60 * 60 * 1_000;
|
|
45
|
+
|
|
46
|
+
// 24-hour ceiling on stale-cache reuse when the network is down.
|
|
47
|
+
export const CACHE_STALE_CEILING_MS = 24 * 60 * 60 * 1_000;
|
|
48
|
+
|
|
49
|
+
// Highest feedVersion this CLI understands. A higher value is treated
|
|
50
|
+
// as "unknown" so an older CLI reading a newer feed degrades cleanly.
|
|
51
|
+
export const KNOWN_FEED_VERSION = 1;
|
|
52
|
+
|
|
53
|
+
// Kill-switch env var.
|
|
54
|
+
export const KILL_SWITCH_ENV = 'RCF_UPDATE_CHECK';
|
|
55
|
+
|
|
56
|
+
// minAgentAction hints the CLI knows how to describe. Unknown values
|
|
57
|
+
// are treated as null per spec §1.5 (forward-compat).
|
|
58
|
+
const KNOWN_MIN_AGENT_ACTIONS = {
|
|
59
|
+
'rewrite-shell-invocations': 'rewrite old CLI invocations across the repo after upgrade.',
|
|
60
|
+
'rerun-init': 'rerun \'rcf init\' after upgrade to refresh managed blocks.',
|
|
61
|
+
'regenerate-chain': 'revalidate and refit existing chain documents after upgrade.',
|
|
62
|
+
'schema-migration': 'the rcf-schemas dependency bumped; see release notes for migration guidance.',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const OPTION_SPEC = {
|
|
66
|
+
check: { type: 'boolean' },
|
|
67
|
+
json: { type: 'boolean' },
|
|
68
|
+
'no-cache': { type: 'boolean' },
|
|
69
|
+
help: { type: 'boolean' },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const HELP = `Usage: rcf version [--check] [--json] [--no-cache] [--help]
|
|
73
|
+
|
|
74
|
+
Print the installed rcf-lite version. With --check, also fetch the
|
|
75
|
+
release feed at ${FEED_URL} and compare against the installed version.
|
|
76
|
+
|
|
77
|
+
The check is advisory: exit code is 0 whether you are up to date or
|
|
78
|
+
behind. Network trouble degrades to a stderr note and the installed
|
|
79
|
+
version, without blocking.
|
|
80
|
+
|
|
81
|
+
Options:
|
|
82
|
+
--check Fetch the release feed and compare against
|
|
83
|
+
the installed version. Prints a compact
|
|
84
|
+
headline diff for each newer release.
|
|
85
|
+
--json Machine-readable envelope. Fields:
|
|
86
|
+
installed, latest, status
|
|
87
|
+
(current | behind | ahead | unknown),
|
|
88
|
+
releasesAhead[], feedFetchedAt, feedSource
|
|
89
|
+
(network | cache | none).
|
|
90
|
+
--no-cache Bypass the local 6-hour cache stamp and force
|
|
91
|
+
a fresh fetch. Diagnostic path.
|
|
92
|
+
--help Print this help.
|
|
93
|
+
|
|
94
|
+
Kill-switch:
|
|
95
|
+
Set ${KILL_SWITCH_ENV}=off in the environment, or add
|
|
96
|
+
{ "updateCheck": "off" } to the platform config file
|
|
97
|
+
(~/.config/rcf-lite/config.json on Linux, or the OS-appropriate
|
|
98
|
+
config path). The check is skipped; the installed version is
|
|
99
|
+
still printed.
|
|
100
|
+
|
|
101
|
+
Exit codes:
|
|
102
|
+
0 version printed; freshness state (if requested) in output.
|
|
103
|
+
2 usage error (unknown flag or positional arg).
|
|
104
|
+
3 --check requested but the feed was unreachable AND no usable
|
|
105
|
+
cache entry existed to fall back on.
|
|
106
|
+
`;
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Semver primitives (numeric x.y.z + optional prerelease; SemVer 2.0.0).
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Parse a semver string into `{ major, minor, patch, prerelease }` or
|
|
116
|
+
* return `null` if it is not a well-formed semver.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} v
|
|
119
|
+
* @returns {null | { major: number, minor: number, patch: number, prerelease: string[] }}
|
|
120
|
+
*/
|
|
121
|
+
export function parseSemver(v) {
|
|
122
|
+
if (typeof v !== 'string') return null;
|
|
123
|
+
const m = SEMVER_RE.exec(v);
|
|
124
|
+
if (!m) return null;
|
|
125
|
+
return {
|
|
126
|
+
major: Number(m[1]),
|
|
127
|
+
minor: Number(m[2]),
|
|
128
|
+
patch: Number(m[3]),
|
|
129
|
+
prerelease: m[4] ? m[4].split('.') : [],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Compare two semvers. Returns -1 if a < b, 0 if equal, 1 if a > b.
|
|
135
|
+
* Follows SemVer 2.0.0 §11: prerelease identifiers rank below the
|
|
136
|
+
* matching normal version; numeric identifiers sort numerically,
|
|
137
|
+
* alphanumeric lexically, numeric below alphanumeric.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} a
|
|
140
|
+
* @param {string} b
|
|
141
|
+
* @returns {number}
|
|
142
|
+
*/
|
|
143
|
+
export function compareSemver(a, b) {
|
|
144
|
+
const pa = parseSemver(a);
|
|
145
|
+
const pb = parseSemver(b);
|
|
146
|
+
if (!pa || !pb) {
|
|
147
|
+
// Non-semver values sort by plain string, as a last resort.
|
|
148
|
+
if (a === b) return 0;
|
|
149
|
+
return a < b ? -1 : 1;
|
|
150
|
+
}
|
|
151
|
+
if (pa.major !== pb.major) return pa.major < pb.major ? -1 : 1;
|
|
152
|
+
if (pa.minor !== pb.minor) return pa.minor < pb.minor ? -1 : 1;
|
|
153
|
+
if (pa.patch !== pb.patch) return pa.patch < pb.patch ? -1 : 1;
|
|
154
|
+
// Prerelease compare: no prerelease > any prerelease.
|
|
155
|
+
if (pa.prerelease.length === 0 && pb.prerelease.length === 0) return 0;
|
|
156
|
+
if (pa.prerelease.length === 0) return 1;
|
|
157
|
+
if (pb.prerelease.length === 0) return -1;
|
|
158
|
+
const len = Math.max(pa.prerelease.length, pb.prerelease.length);
|
|
159
|
+
for (let i = 0; i < len; i++) {
|
|
160
|
+
const ai = pa.prerelease[i];
|
|
161
|
+
const bi = pb.prerelease[i];
|
|
162
|
+
if (ai === undefined) return -1;
|
|
163
|
+
if (bi === undefined) return 1;
|
|
164
|
+
const an = /^\d+$/.test(ai);
|
|
165
|
+
const bn = /^\d+$/.test(bi);
|
|
166
|
+
if (an && bn) {
|
|
167
|
+
const av = Number(ai);
|
|
168
|
+
const bv = Number(bi);
|
|
169
|
+
if (av !== bv) return av < bv ? -1 : 1;
|
|
170
|
+
} else if (an) {
|
|
171
|
+
return -1; // numeric < alphanumeric
|
|
172
|
+
} else if (bn) {
|
|
173
|
+
return 1;
|
|
174
|
+
} else if (ai !== bi) {
|
|
175
|
+
return ai < bi ? -1 : 1;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Feed shape validation.
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Return true if `feed` matches the shape from spec §1.1 well enough
|
|
187
|
+
* that the CLI can trust its keys. Missing optional fields are fine;
|
|
188
|
+
* type mismatches on required fields are not.
|
|
189
|
+
*
|
|
190
|
+
* @param {unknown} feed
|
|
191
|
+
* @returns {feed is FeedShape}
|
|
192
|
+
*/
|
|
193
|
+
export function isValidFeed(feed) {
|
|
194
|
+
if (!feed || typeof feed !== 'object') return false;
|
|
195
|
+
const f = /** @type {Record<string, unknown>} */ (feed);
|
|
196
|
+
if (typeof f.feedVersion !== 'number') return false;
|
|
197
|
+
if (typeof f.generated !== 'string') return false;
|
|
198
|
+
// `latest` is a semver string OR null (placeholder feed).
|
|
199
|
+
if (!(f.latest === null || typeof f.latest === 'string')) return false;
|
|
200
|
+
if (!Array.isArray(f.releases)) return false;
|
|
201
|
+
for (const r of f.releases) {
|
|
202
|
+
if (!r || typeof r !== 'object') return false;
|
|
203
|
+
const rr = /** @type {Record<string, unknown>} */ (r);
|
|
204
|
+
if (typeof rr.version !== 'string') return false;
|
|
205
|
+
if (typeof rr.date !== 'string') return false;
|
|
206
|
+
if (typeof rr.breaking !== 'boolean') return false;
|
|
207
|
+
if (!Array.isArray(rr.headlines)) return false;
|
|
208
|
+
// headlines items must be strings.
|
|
209
|
+
for (const h of rr.headlines) if (typeof h !== 'string') return false;
|
|
210
|
+
// minAgentAction is string-or-null (required, per spec §1.1 table).
|
|
211
|
+
if (!(rr.minAgentAction === null || typeof rr.minAgentAction === 'string')) return false;
|
|
212
|
+
}
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Predicate for "usable" feed. A feedVersion higher than the CLI knows
|
|
218
|
+
* is discarded per spec §6 (forward-compat guard); every other shape
|
|
219
|
+
* failure is discarded too.
|
|
220
|
+
*
|
|
221
|
+
* @param {unknown} feed
|
|
222
|
+
* @returns {boolean}
|
|
223
|
+
*/
|
|
224
|
+
export function isUsableFeed(feed) {
|
|
225
|
+
if (!isValidFeed(feed)) return false;
|
|
226
|
+
if (feed.feedVersion > KNOWN_FEED_VERSION) return false;
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// Cache and config paths.
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Resolve the platform-appropriate cache directory for rcf-lite state.
|
|
236
|
+
* XDG on Linux with fallback to `~/.cache`; `~/Library/Caches` on macOS;
|
|
237
|
+
* `%LOCALAPPDATA%` on Windows.
|
|
238
|
+
*
|
|
239
|
+
* @param {NodeJS.ProcessEnv} env
|
|
240
|
+
* @returns {string}
|
|
241
|
+
*/
|
|
242
|
+
export function resolveCacheDir(env = process.env) {
|
|
243
|
+
const home = env.HOME ?? homedir();
|
|
244
|
+
if (platform() === 'darwin') {
|
|
245
|
+
return join(home, 'Library', 'Caches', 'rcf-lite');
|
|
246
|
+
}
|
|
247
|
+
if (platform() === 'win32') {
|
|
248
|
+
const appData = env.LOCALAPPDATA;
|
|
249
|
+
if (appData) return join(appData, 'rcf-lite', 'Cache');
|
|
250
|
+
return join(home, 'AppData', 'Local', 'rcf-lite', 'Cache');
|
|
251
|
+
}
|
|
252
|
+
// Linux / other POSIX.
|
|
253
|
+
const xdg = env.XDG_CACHE_HOME;
|
|
254
|
+
if (xdg && xdg.length > 0) return join(xdg, 'rcf-lite');
|
|
255
|
+
return join(home, '.cache', 'rcf-lite');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Resolve the platform-appropriate config directory for rcf-lite state.
|
|
260
|
+
*
|
|
261
|
+
* @param {NodeJS.ProcessEnv} env
|
|
262
|
+
* @returns {string}
|
|
263
|
+
*/
|
|
264
|
+
export function resolveConfigDir(env = process.env) {
|
|
265
|
+
const home = env.HOME ?? homedir();
|
|
266
|
+
if (platform() === 'darwin') {
|
|
267
|
+
return join(home, 'Library', 'Application Support', 'rcf-lite');
|
|
268
|
+
}
|
|
269
|
+
if (platform() === 'win32') {
|
|
270
|
+
const appData = env.APPDATA;
|
|
271
|
+
if (appData) return join(appData, 'rcf-lite');
|
|
272
|
+
return join(home, 'AppData', 'Roaming', 'rcf-lite');
|
|
273
|
+
}
|
|
274
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
275
|
+
if (xdg && xdg.length > 0) return join(xdg, 'rcf-lite');
|
|
276
|
+
return join(home, '.config', 'rcf-lite');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Read the platform config file (spec §2.6). Missing file is fine;
|
|
281
|
+
* a corrupted file is treated as absent.
|
|
282
|
+
*
|
|
283
|
+
* @param {string} configDir
|
|
284
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
285
|
+
*/
|
|
286
|
+
export async function readConfig(configDir) {
|
|
287
|
+
try {
|
|
288
|
+
const raw = await readFile(join(configDir, 'config.json'), 'utf8');
|
|
289
|
+
const parsed = JSON.parse(raw);
|
|
290
|
+
if (parsed && typeof parsed === 'object') return parsed;
|
|
291
|
+
return {};
|
|
292
|
+
} catch {
|
|
293
|
+
return {};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* True if the kill-switch is engaged. Env var wins over config file
|
|
299
|
+
* (either enough).
|
|
300
|
+
*
|
|
301
|
+
* @param {NodeJS.ProcessEnv} env
|
|
302
|
+
* @param {Record<string, unknown>} config
|
|
303
|
+
* @returns {boolean}
|
|
304
|
+
*/
|
|
305
|
+
export function isKillSwitchOn(env, config) {
|
|
306
|
+
if (env[KILL_SWITCH_ENV] === 'off') return true;
|
|
307
|
+
if (config.updateCheck === 'off') return true;
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// Cache I/O.
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* @typedef {object} CacheEntry
|
|
317
|
+
* @property {string} fetchedAt
|
|
318
|
+
* @property {string} feedUrl
|
|
319
|
+
* @property {object} feed
|
|
320
|
+
*/
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Read the cache file if it exists and validates cleanly. Corrupted or
|
|
324
|
+
* shape-invalid entries are discarded silently (spec §2.8).
|
|
325
|
+
*
|
|
326
|
+
* @param {string} cachePath
|
|
327
|
+
* @returns {Promise<CacheEntry | null>}
|
|
328
|
+
*/
|
|
329
|
+
export async function readCache(cachePath) {
|
|
330
|
+
try {
|
|
331
|
+
const raw = await readFile(cachePath, 'utf8');
|
|
332
|
+
const parsed = JSON.parse(raw);
|
|
333
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
334
|
+
if (typeof parsed.fetchedAt !== 'string') return null;
|
|
335
|
+
if (typeof parsed.feedUrl !== 'string') return null;
|
|
336
|
+
if (!isUsableFeed(parsed.feed)) return null;
|
|
337
|
+
return parsed;
|
|
338
|
+
} catch {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Write the cache. Failures are swallowed silently (spec §6:
|
|
345
|
+
* malformed cache dir permissions are non-fatal).
|
|
346
|
+
*
|
|
347
|
+
* @param {string} cachePath
|
|
348
|
+
* @param {CacheEntry} entry
|
|
349
|
+
* @returns {Promise<void>}
|
|
350
|
+
*/
|
|
351
|
+
export async function writeCache(cachePath, entry) {
|
|
352
|
+
try {
|
|
353
|
+
await mkdir(dirname(cachePath), { recursive: true });
|
|
354
|
+
await writeFile(cachePath, JSON.stringify(entry, null, 2), 'utf8');
|
|
355
|
+
} catch {
|
|
356
|
+
// Non-fatal.
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
// Fetch primitive.
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Fetch the feed with a hard timeout. Returns the parsed feed or null
|
|
366
|
+
* on any failure (network, non-2xx, malformed JSON, unknown
|
|
367
|
+
* feedVersion). Never throws.
|
|
368
|
+
*
|
|
369
|
+
* @param {string} url
|
|
370
|
+
* @param {object} deps
|
|
371
|
+
* @param {typeof fetch} [deps.fetch]
|
|
372
|
+
* @param {number} [deps.timeoutMs]
|
|
373
|
+
* @returns {Promise<null | object>}
|
|
374
|
+
*/
|
|
375
|
+
export async function fetchFeed(url, deps = {}) {
|
|
376
|
+
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
377
|
+
const timeoutMs = deps.timeoutMs ?? FETCH_TIMEOUT_MS;
|
|
378
|
+
if (typeof doFetch !== 'function') return null;
|
|
379
|
+
|
|
380
|
+
const ctrl = new AbortController();
|
|
381
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
382
|
+
try {
|
|
383
|
+
const res = await doFetch(url, { signal: ctrl.signal });
|
|
384
|
+
if (!res || !res.ok) return null;
|
|
385
|
+
const text = await res.text();
|
|
386
|
+
let parsed;
|
|
387
|
+
try { parsed = JSON.parse(text); } catch { return null; }
|
|
388
|
+
if (!isUsableFeed(parsed)) return null;
|
|
389
|
+
return parsed;
|
|
390
|
+
} catch {
|
|
391
|
+
return null;
|
|
392
|
+
} finally {
|
|
393
|
+
clearTimeout(timer);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
// Compare + result envelope.
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* @typedef {object} CheckResult
|
|
403
|
+
* @property {string} installed
|
|
404
|
+
* @property {string | null} latest
|
|
405
|
+
* @property {'current' | 'behind' | 'ahead' | 'unknown'} status
|
|
406
|
+
* @property {Array<object>} releasesAhead
|
|
407
|
+
* @property {string | null} feedFetchedAt
|
|
408
|
+
* @property {'network' | 'cache' | 'none'} feedSource
|
|
409
|
+
*/
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Compute the check envelope from an installed version + a feed (or
|
|
413
|
+
* null feed = unknown state).
|
|
414
|
+
*
|
|
415
|
+
* @param {string} installed
|
|
416
|
+
* @param {object | null} feed
|
|
417
|
+
* @param {'network' | 'cache' | 'none'} feedSource
|
|
418
|
+
* @param {string | null} feedFetchedAt
|
|
419
|
+
* @returns {CheckResult}
|
|
420
|
+
*/
|
|
421
|
+
export function compareInstalledToFeed(installed, feed, feedSource, feedFetchedAt) {
|
|
422
|
+
if (!feed || feed.latest === null || !Array.isArray(feed.releases) || feed.releases.length === 0) {
|
|
423
|
+
// No update information available (placeholder feed or empty).
|
|
424
|
+
return {
|
|
425
|
+
installed,
|
|
426
|
+
latest: null,
|
|
427
|
+
status: 'unknown',
|
|
428
|
+
releasesAhead: [],
|
|
429
|
+
feedFetchedAt,
|
|
430
|
+
feedSource,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
// Sort releases by semver descending; the feed asserts newest-first
|
|
434
|
+
// but do not trust it (spec §2.7 downgrade protection: semver wins
|
|
435
|
+
// over date ordering).
|
|
436
|
+
const sortedNewestFirst = [...feed.releases].sort((a, b) => compareSemver(b.version, a.version));
|
|
437
|
+
const latest = sortedNewestFirst[0].version;
|
|
438
|
+
const cmp = compareSemver(installed, latest);
|
|
439
|
+
if (cmp === 0) {
|
|
440
|
+
return { installed, latest, status: 'current', releasesAhead: [], feedFetchedAt, feedSource };
|
|
441
|
+
}
|
|
442
|
+
if (cmp > 0) {
|
|
443
|
+
return { installed, latest, status: 'ahead', releasesAhead: [], feedFetchedAt, feedSource };
|
|
444
|
+
}
|
|
445
|
+
// Behind: collect every release with version > installed, newest first.
|
|
446
|
+
const releasesAhead = sortedNewestFirst.filter((r) => compareSemver(r.version, installed) > 0);
|
|
447
|
+
return { installed, latest, status: 'behind', releasesAhead, feedFetchedAt, feedSource };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// Output formatting.
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Render the human-readable check output. Deliberately terse; multi-
|
|
456
|
+
* release ahead cases group headlines under version headers.
|
|
457
|
+
*
|
|
458
|
+
* @param {CheckResult} result
|
|
459
|
+
* @returns {string}
|
|
460
|
+
*/
|
|
461
|
+
export function formatHuman(result) {
|
|
462
|
+
const head = `rcf-lite ${result.installed}\n`;
|
|
463
|
+
if (result.status === 'current') {
|
|
464
|
+
return `${head}Up to date.\n`;
|
|
465
|
+
}
|
|
466
|
+
if (result.status === 'ahead') {
|
|
467
|
+
return `${head}Installed version is ahead of the release feed. No upgrade proposed.\n`;
|
|
468
|
+
}
|
|
469
|
+
if (result.status === 'unknown') {
|
|
470
|
+
return `${head}No update information available.\n`;
|
|
471
|
+
}
|
|
472
|
+
// behind.
|
|
473
|
+
const [top, ...rest] = result.releasesAhead;
|
|
474
|
+
const breaking = top.breaking ? ' BREAKING.' : '';
|
|
475
|
+
const countNote = result.releasesAhead.length > 1
|
|
476
|
+
? ` ${result.releasesAhead.length} releases newer than yours.`
|
|
477
|
+
: '';
|
|
478
|
+
const lines = [head.trimEnd(), ''];
|
|
479
|
+
lines.push(`An update is available: ${top.version} (released ${top.date}).${breaking}${countNote}`);
|
|
480
|
+
if (result.releasesAhead.length === 1) {
|
|
481
|
+
for (const h of top.headlines) lines.push(` - ${h}`);
|
|
482
|
+
} else {
|
|
483
|
+
for (const r of result.releasesAhead) {
|
|
484
|
+
lines.push(` ${r.version}`);
|
|
485
|
+
for (const h of r.headlines) lines.push(` - ${h}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const migrationHints = collectMigrationHints(result.releasesAhead);
|
|
489
|
+
if (migrationHints.length > 0) {
|
|
490
|
+
lines.push('');
|
|
491
|
+
for (const hint of migrationHints) lines.push(`Migration hint: ${hint}`);
|
|
492
|
+
}
|
|
493
|
+
const notesUrl = top.notesUrl;
|
|
494
|
+
if (notesUrl) {
|
|
495
|
+
lines.push('');
|
|
496
|
+
lines.push(`Release notes: ${notesUrl}`);
|
|
497
|
+
}
|
|
498
|
+
return `${lines.join('\n')}\n`;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Collect distinct migration-hint sentences for every release ahead
|
|
503
|
+
* whose minAgentAction is in the known vocabulary. Preserves
|
|
504
|
+
* newest-first order.
|
|
505
|
+
*
|
|
506
|
+
* @param {Array<object>} releasesAhead
|
|
507
|
+
* @returns {string[]}
|
|
508
|
+
*/
|
|
509
|
+
function collectMigrationHints(releasesAhead) {
|
|
510
|
+
const seen = new Set();
|
|
511
|
+
const out = [];
|
|
512
|
+
for (const r of releasesAhead) {
|
|
513
|
+
const action = r.minAgentAction;
|
|
514
|
+
if (!action || typeof action !== 'string') continue;
|
|
515
|
+
const hint = KNOWN_MIN_AGENT_ACTIONS[action];
|
|
516
|
+
if (!hint) continue; // unknown values treated as null (spec §1.5)
|
|
517
|
+
if (seen.has(hint)) continue;
|
|
518
|
+
seen.add(hint);
|
|
519
|
+
out.push(hint);
|
|
520
|
+
}
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Render the JSON envelope. Shape matches spec §2.3.
|
|
526
|
+
*
|
|
527
|
+
* @param {CheckResult} result
|
|
528
|
+
* @returns {string}
|
|
529
|
+
*/
|
|
530
|
+
export function formatJson(result) {
|
|
531
|
+
return `${JSON.stringify(result, null, 2)}\n`;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
// Package-version reader.
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
async function readInstalledVersion() {
|
|
539
|
+
try {
|
|
540
|
+
const pkg = JSON.parse(await readFile(join(PACKAGE_ROOT, 'package.json'), 'utf8'));
|
|
541
|
+
return pkg.version ?? '0.0.0';
|
|
542
|
+
} catch {
|
|
543
|
+
return '0.0.0';
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ---------------------------------------------------------------------------
|
|
548
|
+
// Dispatch.
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* `rcf version` handler.
|
|
553
|
+
*
|
|
554
|
+
* Deps hooks (all optional; defaults from process):
|
|
555
|
+
* - stdout / stderr: writable streams.
|
|
556
|
+
* - env: process.env-shaped map.
|
|
557
|
+
* - now: () => Date (for cache-freshness reasoning).
|
|
558
|
+
* - fetch: fetch impl (for test doubles).
|
|
559
|
+
* - feedUrl: override the compile-time URL.
|
|
560
|
+
* - cacheDir / configDir: absolute directory overrides.
|
|
561
|
+
* - installedVersion: string override (skip reading package.json).
|
|
562
|
+
* - timeoutMs: override the fetch timeout (default 2s).
|
|
563
|
+
*
|
|
564
|
+
* @param {string[]} argv
|
|
565
|
+
* @param {object} [deps]
|
|
566
|
+
* @returns {Promise<number>}
|
|
567
|
+
*/
|
|
568
|
+
export async function main(argv, deps = {}) {
|
|
569
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
570
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
571
|
+
const env = deps.env ?? process.env;
|
|
572
|
+
const now = deps.now ?? (() => new Date());
|
|
573
|
+
|
|
574
|
+
let parsed;
|
|
575
|
+
try {
|
|
576
|
+
parsed = parseArgs({
|
|
577
|
+
args: argv,
|
|
578
|
+
options: OPTION_SPEC,
|
|
579
|
+
allowPositionals: false,
|
|
580
|
+
strict: true,
|
|
581
|
+
});
|
|
582
|
+
} catch (err) {
|
|
583
|
+
stderr.write(`[error] usage ${err.message}\n`);
|
|
584
|
+
stderr.write(HELP);
|
|
585
|
+
return 2;
|
|
586
|
+
}
|
|
587
|
+
if (parsed.values.help) {
|
|
588
|
+
stdout.write(HELP);
|
|
589
|
+
return 0;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const installed = deps.installedVersion ?? await readInstalledVersion();
|
|
593
|
+
|
|
594
|
+
// Bare `rcf version` -- no network, no cache, no side effects.
|
|
595
|
+
if (!parsed.values.check) {
|
|
596
|
+
stdout.write(`rcf-lite ${installed}\n`);
|
|
597
|
+
return 0;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// --check path.
|
|
601
|
+
const feedUrl = deps.feedUrl ?? FEED_URL;
|
|
602
|
+
const cacheDir = deps.cacheDir ?? resolveCacheDir(env);
|
|
603
|
+
const configDir = deps.configDir ?? resolveConfigDir(env);
|
|
604
|
+
const cachePath = join(cacheDir, 'version-check.json');
|
|
605
|
+
|
|
606
|
+
// Kill-switch is honoured before any cache read or network call.
|
|
607
|
+
const config = await readConfig(configDir);
|
|
608
|
+
if (isKillSwitchOn(env, config)) {
|
|
609
|
+
stdout.write(`rcf-lite ${installed}\n`);
|
|
610
|
+
stderr.write('update check skipped: kill-switch is on.\n');
|
|
611
|
+
return 0;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const t0 = now();
|
|
615
|
+
const existingCache = await readCache(cachePath);
|
|
616
|
+
const cacheIsFresh = existingCache
|
|
617
|
+
? (t0.getTime() - Date.parse(existingCache.fetchedAt)) < CACHE_FRESH_MS
|
|
618
|
+
: false;
|
|
619
|
+
const cacheIsUsable = existingCache
|
|
620
|
+
? (t0.getTime() - Date.parse(existingCache.fetchedAt)) < CACHE_STALE_CEILING_MS
|
|
621
|
+
: false;
|
|
622
|
+
|
|
623
|
+
const forceNetwork = Boolean(parsed.values['no-cache']);
|
|
624
|
+
|
|
625
|
+
/** @type {object | null} */
|
|
626
|
+
let feed = null;
|
|
627
|
+
/** @type {'network' | 'cache' | 'none'} */
|
|
628
|
+
let feedSource = 'none';
|
|
629
|
+
/** @type {string | null} */
|
|
630
|
+
let feedFetchedAt = null;
|
|
631
|
+
|
|
632
|
+
if (cacheIsFresh && !forceNetwork && existingCache) {
|
|
633
|
+
feed = existingCache.feed;
|
|
634
|
+
feedSource = 'cache';
|
|
635
|
+
feedFetchedAt = existingCache.fetchedAt;
|
|
636
|
+
} else {
|
|
637
|
+
// Attempt a network fetch.
|
|
638
|
+
const fresh = await fetchFeed(feedUrl, {
|
|
639
|
+
fetch: deps.fetch,
|
|
640
|
+
timeoutMs: deps.timeoutMs ?? FETCH_TIMEOUT_MS,
|
|
641
|
+
});
|
|
642
|
+
if (fresh) {
|
|
643
|
+
feed = fresh;
|
|
644
|
+
feedSource = 'network';
|
|
645
|
+
feedFetchedAt = now().toISOString();
|
|
646
|
+
await writeCache(cachePath, { fetchedAt: feedFetchedAt, feedUrl, feed });
|
|
647
|
+
} else if (cacheIsUsable && existingCache) {
|
|
648
|
+
feed = existingCache.feed;
|
|
649
|
+
feedSource = 'cache';
|
|
650
|
+
feedFetchedAt = existingCache.fetchedAt;
|
|
651
|
+
stderr.write('update check: network fetch failed; using cached feed.\n');
|
|
652
|
+
} else {
|
|
653
|
+
feed = null;
|
|
654
|
+
feedSource = 'none';
|
|
655
|
+
feedFetchedAt = null;
|
|
656
|
+
stderr.write('update check skipped: network unavailable.\n');
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const result = compareInstalledToFeed(installed, feed, feedSource, feedFetchedAt);
|
|
661
|
+
|
|
662
|
+
if (parsed.values.json) {
|
|
663
|
+
stdout.write(formatJson(result));
|
|
664
|
+
} else {
|
|
665
|
+
stdout.write(formatHuman(result));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Exit 3 only when --check was requested and no signal at all was
|
|
669
|
+
// available (no network AND no usable cache). Being behind is not a
|
|
670
|
+
// failure exit; that would tempt CI to gate on freshness (spec §2.4).
|
|
671
|
+
if (feedSource === 'none') return 3;
|
|
672
|
+
return 0;
|
|
673
|
+
}
|