dsh-ops 0.0.0-stage → 0.2.1
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 +189 -0
- package/LICENSE +30 -0
- package/NOTICE +106 -0
- package/PROVENANCE.md +417 -0
- package/README.en.md +121 -0
- package/README.md +113 -2
- package/README.zh.md +114 -0
- package/bin/dsh-ops.mjs +1216 -0
- package/cordis.patch.yml +160 -0
- package/docs/manual-validation.md +53 -0
- package/docs/schema-baseline.json +64 -0
- package/docs/schema-current.json +84 -0
- package/docs/schema-measurement.md +17 -0
- package/dsh-plugin.json +88 -0
- package/icon.svg +12 -0
- package/lib/binary.js +409 -0
- package/lib/config.js +198 -0
- package/lib/handshake.js +252 -0
- package/lib/index.js +108 -0
- package/lib/jobs.js +42 -0
- package/lib/policy.js +64 -0
- package/lib/presentation.js +63 -0
- package/lib/profile-install.js +61 -0
- package/lib/rust.js +194 -0
- package/lib/session-shells.js +78 -0
- package/lib/shells.js +993 -0
- package/lib/tools.js +657 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +114 -4
- package/vendor/fastctx/Cargo.lock +3210 -0
- package/vendor/fastctx/Cargo.toml +94 -0
- package/vendor/fastctx/FORK.md +119 -0
- package/vendor/fastctx/LICENSE-APACHE +201 -0
- package/vendor/fastctx/NOTICE +40 -0
- package/vendor/fastctx/README.md +439 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
- package/vendor/fastctx/UPSTREAM.md +49 -0
- package/vendor/fastctx/build.rs +413 -0
- package/vendor/fastctx/src/background_status.rs +403 -0
- package/vendor/fastctx/src/binary.rs +75 -0
- package/vendor/fastctx/src/bounded_sort.rs +500 -0
- package/vendor/fastctx/src/budget.rs +781 -0
- package/vendor/fastctx/src/cli/mod.rs +110 -0
- package/vendor/fastctx/src/context_guard.rs +289 -0
- package/vendor/fastctx/src/control/mod.rs +6 -0
- package/vendor/fastctx/src/control/paths.rs +49 -0
- package/vendor/fastctx/src/control/settings.rs +753 -0
- package/vendor/fastctx/src/control/transaction.rs +531 -0
- package/vendor/fastctx/src/edit/document.rs +535 -0
- package/vendor/fastctx/src/edit/locks.rs +371 -0
- package/vendor/fastctx/src/edit/mod.rs +213 -0
- package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
- package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
- package/vendor/fastctx/src/edit/private_storage.rs +234 -0
- package/vendor/fastctx/src/edit/replace.rs +1030 -0
- package/vendor/fastctx/src/edit_server.rs +53 -0
- package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
- package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
- package/vendor/fastctx/src/encoding.rs +1118 -0
- package/vendor/fastctx/src/file_executor.rs +1151 -0
- package/vendor/fastctx/src/file_snapshot.rs +1491 -0
- package/vendor/fastctx/src/glob_filter.rs +98 -0
- package/vendor/fastctx/src/glob_tool.rs +653 -0
- package/vendor/fastctx/src/grep_sink.rs +1162 -0
- package/vendor/fastctx/src/grep_tool.rs +2449 -0
- package/vendor/fastctx/src/lib.rs +45 -0
- package/vendor/fastctx/src/main.rs +15 -0
- package/vendor/fastctx/src/model.rs +51 -0
- package/vendor/fastctx/src/model_guidance.rs +62 -0
- package/vendor/fastctx/src/operation.rs +356 -0
- package/vendor/fastctx/src/ordered_window.rs +1235 -0
- package/vendor/fastctx/src/os_environment.rs +414 -0
- package/vendor/fastctx/src/path_codec.rs +850 -0
- package/vendor/fastctx/src/paths.rs +244 -0
- package/vendor/fastctx/src/process_identity.rs +763 -0
- package/vendor/fastctx/src/process_policy.rs +74 -0
- package/vendor/fastctx/src/read_tool/batch.rs +496 -0
- package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
- package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
- package/vendor/fastctx/src/read_tool/mod.rs +245 -0
- package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
- package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
- package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
- package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
- package/vendor/fastctx/src/render_plan.rs +468 -0
- package/vendor/fastctx/src/runtime/activity.rs +159 -0
- package/vendor/fastctx/src/runtime/hosts.rs +99 -0
- package/vendor/fastctx/src/runtime/journal.rs +556 -0
- package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
- package/vendor/fastctx/src/runtime/mod.rs +746 -0
- package/vendor/fastctx/src/runtime/protocol.rs +296 -0
- package/vendor/fastctx/src/runtime/session.rs +536 -0
- package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
- package/vendor/fastctx/src/search_parallelism.rs +106 -0
- package/vendor/fastctx/src/search_text.rs +227 -0
- package/vendor/fastctx/src/server.rs +359 -0
- package/vendor/fastctx/src/server_manifest.rs +468 -0
- package/vendor/fastctx/src/server_support.rs +826 -0
- package/vendor/fastctx/src/session.rs +629 -0
- package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
- package/vendor/fastctx/src/shell/bash.rs +263 -0
- package/vendor/fastctx/src/shell/buffer.rs +108 -0
- package/vendor/fastctx/src/shell/encoding.rs +403 -0
- package/vendor/fastctx/src/shell/foreground.rs +115 -0
- package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
- package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
- package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
- package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
- package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
- package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
- package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
- package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
- package/vendor/fastctx/src/shell/mod.rs +345 -0
- package/vendor/fastctx/src/shell/normalize.rs +389 -0
- package/vendor/fastctx/src/shell/output.rs +406 -0
- package/vendor/fastctx/src/shell/process.rs +493 -0
- package/vendor/fastctx/src/shell_server.rs +156 -0
- package/vendor/fastctx/src/skip_report.rs +83 -0
- package/vendor/fastctx/src/stdio_transport.rs +177 -0
- package/vendor/fastctx/src/tool_schema.rs +204 -0
- package/vendor/fastctx/src/traversal.rs +846 -0
- package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
package/bin/dsh-ops.mjs
ADDED
|
@@ -0,0 +1,1216 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `dsh-ops` — operator commands for the FastCtx runtime this plugin hosts.
|
|
4
|
+
*
|
|
5
|
+
* The plugin resolves its runtime automatically, so these commands are not
|
|
6
|
+
* required for a normal install. They exist for what automatic resolution
|
|
7
|
+
* cannot do: build the runtime from the vendored source, install that build
|
|
8
|
+
* into a stable per-user location that survives package upgrades, turn the
|
|
9
|
+
* pinned upstream shell releases into local copies, and report what this
|
|
10
|
+
* deployment resolves and which rungs the prompt ladder renders.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-ops/cli
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawnSync } from 'node:child_process'
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
17
|
+
import fs from 'node:fs'
|
|
18
|
+
import os from 'node:os'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import { parseArgs } from 'node:util'
|
|
21
|
+
import {
|
|
22
|
+
BINARY_ENV,
|
|
23
|
+
PACKAGE_ROOT,
|
|
24
|
+
dshHome,
|
|
25
|
+
executableName,
|
|
26
|
+
managedBinaryFile,
|
|
27
|
+
managedRuntimeDir,
|
|
28
|
+
probeBinary,
|
|
29
|
+
resolveBinary,
|
|
30
|
+
} from '../lib/binary.js'
|
|
31
|
+
import { profileCommand } from '../lib/profile-install.js'
|
|
32
|
+
import { resolveConfig } from '../lib/config.js'
|
|
33
|
+
import { McpStdioClient } from '../lib/handshake.js'
|
|
34
|
+
import {
|
|
35
|
+
BASH_TOOL,
|
|
36
|
+
FILE_TOOLS,
|
|
37
|
+
SHELL_TOOLS,
|
|
38
|
+
ladderLevels,
|
|
39
|
+
publicToolName,
|
|
40
|
+
renderToolingPolicy,
|
|
41
|
+
} from '../lib/policy.js'
|
|
42
|
+
import { VENDOR_DIR, buildFreshness, builtBinary, cargoBuild, findCargo } from '../lib/rust.js'
|
|
43
|
+
import {
|
|
44
|
+
PLATFORM_PACKAGES,
|
|
45
|
+
SHELL_UPSTREAM_PINS,
|
|
46
|
+
provisionedShellStore,
|
|
47
|
+
provisionedShellVersionDir,
|
|
48
|
+
resolveShells,
|
|
49
|
+
} from '../lib/shells.js'
|
|
50
|
+
|
|
51
|
+
/** Receipt schema written beside a provisioned runtime. */
|
|
52
|
+
const RECEIPT_VERSION = 1
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The directory `provision` installs into, and the unit `uninstall` removes:
|
|
56
|
+
* `<DSH_HOME>/dsh-ops`, holding `bin/<executable>` and `runtime.json`.
|
|
57
|
+
* @returns {string} the absolute directory.
|
|
58
|
+
*/
|
|
59
|
+
function managedDir() {
|
|
60
|
+
return path.dirname(managedRuntimeDir())
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* FastCtx's own per-user state directory.
|
|
65
|
+
*
|
|
66
|
+
* FastCtx resolves this from the environment the host process received —
|
|
67
|
+
* `~/.fastctx`, holding `config.toml` and the background-job registry under
|
|
68
|
+
* `jobs/` — and nothing this plugin does creates it. It is reported here, and
|
|
69
|
+
* removed only when the operator asks for it, because a previous FastCtx
|
|
70
|
+
* install may still own it.
|
|
71
|
+
* @returns {string} the absolute directory.
|
|
72
|
+
*/
|
|
73
|
+
function fastctxDataDir() {
|
|
74
|
+
return path.join(os.homedir(), '.fastctx')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Print one usage block.
|
|
79
|
+
* @returns {void}
|
|
80
|
+
*/
|
|
81
|
+
function usage() {
|
|
82
|
+
console.log(`dsh-ops — FastCtx runtime management for the dsh-ops plugin
|
|
83
|
+
|
|
84
|
+
Usage:
|
|
85
|
+
dsh-ops install --profile <desktop|web|tui> [--dsh-cli <path>]
|
|
86
|
+
Install this version using the official target-profile manager
|
|
87
|
+
dsh-ops uninstall --profile <desktop|web|tui> [--dsh-cli <path>]
|
|
88
|
+
Remove the profile plugin and its binary dependency references
|
|
89
|
+
dsh-ops status --profile <desktop|web|tui>
|
|
90
|
+
Report target-profile installation (tui maps to dsh-tui)
|
|
91
|
+
dsh-ops resolve Print the FastCtx executable the plugin will host
|
|
92
|
+
dsh-ops status Resolve, probe, and list the MCP tools it publishes
|
|
93
|
+
dsh-ops build [--force] Build the vendored FastCtx source with cargo (release)
|
|
94
|
+
dsh-ops provision [--force] Build if needed, then install into the managed runtime dir
|
|
95
|
+
dsh-ops uninstall [--yes] [--purge-fastctx]
|
|
96
|
+
Report the runtime this plugin owns and, with --yes, remove it
|
|
97
|
+
dsh-ops ladder [--json] [--config <file>]
|
|
98
|
+
Report the FastCtx executable, both shells, and the rungs the
|
|
99
|
+
prompt ladder actually renders; nothing is spawned
|
|
100
|
+
dsh-ops provision-shells [--bash] [--pwsh] [--force] [--dry-run] [--pin <file>]
|
|
101
|
+
Download the pinned upstream bash and/or PowerShell 7, verify
|
|
102
|
+
their digests, and install them under <DSH_HOME>/dsh-ops/shells
|
|
103
|
+
dsh-ops help This text
|
|
104
|
+
|
|
105
|
+
Environment:
|
|
106
|
+
${BINARY_ENV} Explicit FastCtx executable (wins over the managed copy)
|
|
107
|
+
DSH_OPS_CARGO Explicit cargo executable
|
|
108
|
+
CARGO_HOME Rust toolchain home (.cargo)
|
|
109
|
+
DSH_OPS_RUST_HOME Directory containing a relocated .cargo toolchain
|
|
110
|
+
DSH_HOME DSH home; the managed runtime lives in <DSH_HOME>/dsh-ops/bin,
|
|
111
|
+
the provisioned shells in <DSH_HOME>/dsh-ops/shells
|
|
112
|
+
HTTPS_PROXY Proxy handed to curl explicitly by provision-shells
|
|
113
|
+
|
|
114
|
+
With --profile, install/uninstall delegate to the official DSH profile manager.
|
|
115
|
+
Without --profile, uninstall only cleans legacy provisioned runtime/shell files;
|
|
116
|
+
use --yes to confirm that separate cleanup. No system shell or shared npm cache is removed.
|
|
117
|
+
`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve and describe the executable, without starting it.
|
|
122
|
+
* @returns {{file: string, source: string}|undefined} the resolution, or undefined after reporting.
|
|
123
|
+
*/
|
|
124
|
+
function reportResolution() {
|
|
125
|
+
try {
|
|
126
|
+
const resolved = resolveBinary({ packageRoot: PACKAGE_ROOT })
|
|
127
|
+
console.log(`executable: ${resolved.file}`)
|
|
128
|
+
console.log(`source: ${resolved.source}`)
|
|
129
|
+
return resolved
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error(error.message)
|
|
132
|
+
if (typeof error.report === 'function') console.error(error.report())
|
|
133
|
+
return undefined
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* `resolve`.
|
|
139
|
+
* @returns {number} the exit code.
|
|
140
|
+
*/
|
|
141
|
+
function commandResolve() {
|
|
142
|
+
return reportResolution() === undefined ? 1 : 0
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `status`: prove the resolved executable starts and publishes the tool surface
|
|
147
|
+
* the plugin's prompt policy advertises.
|
|
148
|
+
* @returns {Promise<number>} the exit code.
|
|
149
|
+
*/
|
|
150
|
+
async function commandStatus() {
|
|
151
|
+
const resolved = reportResolution()
|
|
152
|
+
if (resolved === undefined) return 1
|
|
153
|
+
|
|
154
|
+
const probe = probeBinary(resolved.file)
|
|
155
|
+
console.log(`version: ${probe.ok ? probe.version : `UNUSABLE (${probe.detail})`}`)
|
|
156
|
+
if (!probe.ok) return 1
|
|
157
|
+
|
|
158
|
+
const client = McpStdioClient.start({
|
|
159
|
+
file: resolved.file,
|
|
160
|
+
args: ['serve', '--enable-shell'],
|
|
161
|
+
timeoutMs: 60_000,
|
|
162
|
+
})
|
|
163
|
+
try {
|
|
164
|
+
const handshake = await client.initialize('dsh-ops-status')
|
|
165
|
+
const tools = (await client.listTools()).filter(tool => FILE_TOOLS.includes(tool.name))
|
|
166
|
+
console.log(`server: ${handshake.serverInfo?.name} ${handshake.serverInfo?.version}`)
|
|
167
|
+
console.log('scope: standalone diagnostic; command tools need an authorized host session')
|
|
168
|
+
console.log(`tools: ${tools.length}`)
|
|
169
|
+
for (const tool of tools) console.log(` - ${publicToolName(tool.name)}`)
|
|
170
|
+
return tools.length === 0 ? 1 : 0
|
|
171
|
+
} catch (error) {
|
|
172
|
+
console.error(`MCP handshake failed: ${error.message}`)
|
|
173
|
+
for (const line of client.stderrLines.slice(-20)) console.error(` server: ${line}`)
|
|
174
|
+
return 1
|
|
175
|
+
} finally {
|
|
176
|
+
await client.close()
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Read the `dsh-ops` row config a report should describe.
|
|
182
|
+
*
|
|
183
|
+
* The commands here are run outside a profile, so there is no loader to ask:
|
|
184
|
+
* `--config` names a JSON file holding the row's own `config` object, and the
|
|
185
|
+
* keys are the ones the row accepts. Passing it through `resolveConfig` is what
|
|
186
|
+
* keeps this report and the plugin's mount answering the same question — an
|
|
187
|
+
* unknown key fails here exactly as it would fail activation.
|
|
188
|
+
* @param {string|undefined} file - the `--config` path, when one was given.
|
|
189
|
+
* @returns {unknown} the raw row config.
|
|
190
|
+
* @throws {Error} when the file cannot be read, does not parse, or is not an object.
|
|
191
|
+
*/
|
|
192
|
+
function readRowConfig(file) {
|
|
193
|
+
if (file === undefined) return {}
|
|
194
|
+
const resolved = path.resolve(file)
|
|
195
|
+
let text
|
|
196
|
+
try {
|
|
197
|
+
text = fs.readFileSync(resolved, 'utf8')
|
|
198
|
+
} catch (error) {
|
|
199
|
+
throw new Error(`--config does not name a readable file: ${resolved} (${error.message})`)
|
|
200
|
+
}
|
|
201
|
+
let value
|
|
202
|
+
try {
|
|
203
|
+
value = JSON.parse(text)
|
|
204
|
+
} catch (error) {
|
|
205
|
+
throw new Error(`--config must hold one JSON object (the \`dsh-ops\` row's config): ${error.message}`)
|
|
206
|
+
}
|
|
207
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
208
|
+
throw new Error(`--config must hold one JSON object (the \`dsh-ops\` row's config), got ${typeof value}`)
|
|
209
|
+
}
|
|
210
|
+
return value
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The FastCtx resolution as a report value.
|
|
215
|
+
*
|
|
216
|
+
* This is resolution and nothing more: the plugin's own mount also probes the
|
|
217
|
+
* executable before hosting it, but a diagnostic must not spawn anything, so an
|
|
218
|
+
* executable that resolves is reported as resolved and its usability is left to
|
|
219
|
+
* `status`. The search log comes along either way, which is what makes a miss
|
|
220
|
+
* readable.
|
|
221
|
+
* @param {import('../lib/config.js').ResolvedConfig} config - the plugin configuration.
|
|
222
|
+
* @returns {{resolved: boolean, executable: string|null, source: string|null, error: string|null,
|
|
223
|
+
* tried: {file: string, source: string, detail: string}[]}} the report value.
|
|
224
|
+
*/
|
|
225
|
+
function runtimeReport(config) {
|
|
226
|
+
try {
|
|
227
|
+
const resolved = resolveBinary({ binaryPath: config.binaryPath, packageRoot: PACKAGE_ROOT })
|
|
228
|
+
return {
|
|
229
|
+
resolved: true,
|
|
230
|
+
executable: resolved.file,
|
|
231
|
+
source: resolved.source,
|
|
232
|
+
error: null,
|
|
233
|
+
tried: resolved.tried,
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return {
|
|
237
|
+
resolved: false,
|
|
238
|
+
executable: null,
|
|
239
|
+
source: null,
|
|
240
|
+
error: String(error?.message ?? error),
|
|
241
|
+
tried: Array.isArray(error?.tried) ? error.tried : [],
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* One shell rung as a report value: `executable` is null exactly when the rung
|
|
248
|
+
* is unavailable, and `detail` is the resolver's own reason either way.
|
|
249
|
+
* @param {import('../lib/shells.js').ShellResolution} resolution - the rung's resolution.
|
|
250
|
+
* @returns {{available: boolean, executable: string|null, source: string, detail: string}} the report value.
|
|
251
|
+
*/
|
|
252
|
+
function shellReport(resolution) {
|
|
253
|
+
return {
|
|
254
|
+
available: resolution.available === true,
|
|
255
|
+
executable: resolution.file ?? null,
|
|
256
|
+
source: resolution.source,
|
|
257
|
+
detail: resolution.detail,
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The rungs this deployment can reach, from the same derivation the plugin's
|
|
263
|
+
* prompt uses.
|
|
264
|
+
*
|
|
265
|
+
* The plugin asks the live registry; this command has no registry and does no
|
|
266
|
+
* MCP handshake, so it answers from resolution instead: a resolved FastCtx
|
|
267
|
+
* executable stands in for a connected server, and a resolved bash stands in
|
|
268
|
+
* for the published `ops_bash` tool (which also needs the host's `subprocess`
|
|
269
|
+
* service). `ladderLevels` still decides, so the rungs and their numbering come
|
|
270
|
+
* from the renderer that ships them rather than from a second copy here.
|
|
271
|
+
* @param {object} options - the ladder input.
|
|
272
|
+
* @param {{resolved: boolean}} options.runtime - the FastCtx report value.
|
|
273
|
+
* @param {{bash: {available: boolean}, pwsh: {available: boolean}}} options.shells - the shell resolutions.
|
|
274
|
+
* @param {import('../lib/config.js').ResolvedConfig} options.config - the plugin configuration.
|
|
275
|
+
* @returns {import('../lib/policy.js').LadderLevels} the rungs.
|
|
276
|
+
*/
|
|
277
|
+
function ladderFor({ runtime, shells, config }) {
|
|
278
|
+
const published = runtime.resolved ? FILE_TOOLS.map(publicToolName) : []
|
|
279
|
+
if (config.publishBashTool && shells.bash.available) published.push(BASH_TOOL)
|
|
280
|
+
if (shells.pwsh.available) published.push('pwsh')
|
|
281
|
+
return ladderLevels({ published, pwsh: shells.pwsh.available })
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The `## Rung …` headings of a rendered ladder, as numbered titles.
|
|
286
|
+
* @param {string} text - the rendered tooling section.
|
|
287
|
+
* @returns {{number: number, title: string}[]} one entry per rendered rung, in render order.
|
|
288
|
+
*/
|
|
289
|
+
function renderedRungs(text) {
|
|
290
|
+
const rungs = []
|
|
291
|
+
for (const line of text.split('\n')) {
|
|
292
|
+
const match = /^## Rung (\d+) — (.*)$/u.exec(line)
|
|
293
|
+
if (match !== null) rungs.push({ number: Number(match[1]), title: match[2] })
|
|
294
|
+
}
|
|
295
|
+
return rungs
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Print one ladder report for a human.
|
|
300
|
+
* @param {object} report - the report value `commandLadder` assembled.
|
|
301
|
+
* @returns {void}
|
|
302
|
+
*/
|
|
303
|
+
function printLadder(report) {
|
|
304
|
+
console.log('FastCtx runtime')
|
|
305
|
+
if (report.fastctx.resolved) {
|
|
306
|
+
console.log(` executable: ${report.fastctx.executable}`)
|
|
307
|
+
console.log(` source: ${report.fastctx.source}`)
|
|
308
|
+
} else {
|
|
309
|
+
console.log(' executable: missing')
|
|
310
|
+
console.log(` reason: ${report.fastctx.error}`)
|
|
311
|
+
for (const candidate of report.fastctx.tried) {
|
|
312
|
+
console.log(` - ${candidate.source}: ${candidate.file} (${candidate.detail})`)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
console.log('\nShell rungs (lib/shells.js resolveShells)')
|
|
317
|
+
for (const kind of ['bash', 'pwsh']) {
|
|
318
|
+
const rung = report.shells[kind]
|
|
319
|
+
console.log(` ${kind}`)
|
|
320
|
+
console.log(` available: ${rung.available ? 'yes' : 'no'}`)
|
|
321
|
+
console.log(` executable: ${rung.executable ?? 'missing'}`)
|
|
322
|
+
console.log(` source: ${rung.source}`)
|
|
323
|
+
console.log(` detail: ${rung.detail}`)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
console.log('\nThree layers: repository tools → bash → PowerShell 7')
|
|
327
|
+
console.log(report.policy || '(no file tools resolved)')
|
|
328
|
+
console.log('\nResolution only: command visibility requires a live host session and sandboxPolicy.')
|
|
329
|
+
console.log('Resolved candidates only; bash also requires full-access session and subprocess. Nothing is spawned.')
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* `ladder`: report what this deployment resolves and which rungs the model is
|
|
334
|
+
* actually told it can reach — the three questions a missing bundled shell
|
|
335
|
+
* would otherwise need a fresh session to answer.
|
|
336
|
+
*
|
|
337
|
+
* Nothing is spawned: no probe, no MCP handshake. Missing rungs are the report,
|
|
338
|
+
* not a failure, so the exit code is 0 whenever a report was produced and 1 only
|
|
339
|
+
* for a config this command cannot honour.
|
|
340
|
+
* @param {object} options - ladder inputs.
|
|
341
|
+
* @param {boolean} options.json - print the machine-readable report instead of the human one.
|
|
342
|
+
* @param {string|undefined} options.configFile - the `--config` path, when one was given.
|
|
343
|
+
* @returns {number} the exit code.
|
|
344
|
+
*/
|
|
345
|
+
function commandLadder({ json, configFile }) {
|
|
346
|
+
let config
|
|
347
|
+
try {
|
|
348
|
+
config = resolveConfig(readRowConfig(configFile))
|
|
349
|
+
} catch (error) {
|
|
350
|
+
console.error(error.message)
|
|
351
|
+
return 1
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const runtime = runtimeReport(config)
|
|
355
|
+
const shells = resolveShells(config)
|
|
356
|
+
const levels = ladderFor({ runtime, shells, config })
|
|
357
|
+
const report = {
|
|
358
|
+
fastctx: runtime,
|
|
359
|
+
shells: { bash: shellReport(shells.bash), pwsh: shellReport(shells.pwsh) },
|
|
360
|
+
levels,
|
|
361
|
+
rungs: [],
|
|
362
|
+
policy: renderToolingPolicy({
|
|
363
|
+
published: [
|
|
364
|
+
...(runtime.resolved ? FILE_TOOLS.map(publicToolName) : []),
|
|
365
|
+
...(levels.bash ? [BASH_TOOL] : []),
|
|
366
|
+
...(levels.pwsh ? ['pwsh'] : []),
|
|
367
|
+
],
|
|
368
|
+
extraGuidance: config.extraGuidance,
|
|
369
|
+
}),
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (json) {
|
|
373
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
374
|
+
return 0
|
|
375
|
+
}
|
|
376
|
+
printLadder(report)
|
|
377
|
+
return 0
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* `build`.
|
|
382
|
+
* @param {boolean} force - rebuild even when the existing binary is newer than the source.
|
|
383
|
+
* @returns {Promise<number>} the exit code.
|
|
384
|
+
*/
|
|
385
|
+
async function commandBuild(force) {
|
|
386
|
+
let cargo
|
|
387
|
+
try {
|
|
388
|
+
cargo = findCargo()
|
|
389
|
+
} catch (error) {
|
|
390
|
+
console.error(error.message)
|
|
391
|
+
if (Array.isArray(error.tried)) for (const line of error.tried) console.error(` - ${line}`)
|
|
392
|
+
return 1
|
|
393
|
+
}
|
|
394
|
+
console.log(`cargo: ${cargo.version} (${cargo.source}: ${cargo.file})`)
|
|
395
|
+
console.log(`source: ${VENDOR_DIR}`)
|
|
396
|
+
|
|
397
|
+
const freshness = buildFreshness({ sourceDir: VENDOR_DIR })
|
|
398
|
+
if (!force && freshness !== undefined && !freshness.stale) {
|
|
399
|
+
console.log('up to date: target/release is newer than the vendored source; use --force to rebuild')
|
|
400
|
+
return 0
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const started = Date.now()
|
|
404
|
+
const result = await cargoBuild({ cargo, sourceDir: VENDOR_DIR })
|
|
405
|
+
if (result.code !== 0) {
|
|
406
|
+
console.error(`cargo build failed with exit code ${result.code}`)
|
|
407
|
+
for (const line of result.output.split('\n').slice(-30)) if (line !== '') console.error(` ${line}`)
|
|
408
|
+
return 1
|
|
409
|
+
}
|
|
410
|
+
const built = builtBinary({ sourceDir: VENDOR_DIR })
|
|
411
|
+
console.log(`built: ${built?.file ?? '(missing)'} in ${Math.round((Date.now() - started) / 1000)}s`)
|
|
412
|
+
return built === undefined ? 1 : 0
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* `provision`: put one known-good executable into the managed runtime directory
|
|
417
|
+
* and record where it came from.
|
|
418
|
+
* @param {object} options - provision inputs.
|
|
419
|
+
* @param {boolean} options.force - rebuild and reinstall even when current.
|
|
420
|
+
* @param {string|undefined} options.from - install this file instead of building.
|
|
421
|
+
* @param {boolean} options.noBuild - refuse to build; fail when no source build exists.
|
|
422
|
+
* @returns {Promise<number>} the exit code.
|
|
423
|
+
*/
|
|
424
|
+
async function commandProvision({ force, from, noBuild }) {
|
|
425
|
+
let source
|
|
426
|
+
if (from !== undefined) {
|
|
427
|
+
const file = path.resolve(from)
|
|
428
|
+
if (!fs.existsSync(file)) {
|
|
429
|
+
console.error(`--from does not name an existing file: ${file}`)
|
|
430
|
+
return 1
|
|
431
|
+
}
|
|
432
|
+
source = { file, origin: 'from --from' }
|
|
433
|
+
} else {
|
|
434
|
+
let existing = builtBinary({ sourceDir: VENDOR_DIR })
|
|
435
|
+
const freshness = buildFreshness({ sourceDir: VENDOR_DIR })
|
|
436
|
+
const needsBuild = force || existing === undefined || freshness?.stale === true
|
|
437
|
+
if (needsBuild && noBuild) {
|
|
438
|
+
console.error(existing === undefined
|
|
439
|
+
? 'no release build exists and --no-build was given; run `dsh-ops build` first'
|
|
440
|
+
: 'the release build is older than the vendored source and --no-build was given')
|
|
441
|
+
return 1
|
|
442
|
+
}
|
|
443
|
+
if (needsBuild) {
|
|
444
|
+
const code = await commandBuild(true)
|
|
445
|
+
if (code !== 0) return code
|
|
446
|
+
existing = builtBinary({ sourceDir: VENDOR_DIR })
|
|
447
|
+
}
|
|
448
|
+
if (existing === undefined) {
|
|
449
|
+
console.error('no release build found after building; nothing to provision')
|
|
450
|
+
return 1
|
|
451
|
+
}
|
|
452
|
+
source = { file: existing.file, origin: 'vendored source build' }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const probe = probeBinary(source.file)
|
|
456
|
+
if (!probe.ok) {
|
|
457
|
+
console.error(`refusing to install an unusable executable: ${source.file} (${probe.detail})`)
|
|
458
|
+
return 1
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const target = managedBinaryFile()
|
|
462
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
463
|
+
fs.copyFileSync(source.file, target)
|
|
464
|
+
if (process.platform !== 'win32') fs.chmodSync(target, 0o755)
|
|
465
|
+
|
|
466
|
+
const installed = probeBinary(target)
|
|
467
|
+
if (!installed.ok) {
|
|
468
|
+
console.error(`the installed copy does not run: ${target} (${installed.detail})`)
|
|
469
|
+
return 1
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const receipt = {
|
|
473
|
+
schemaVersion: RECEIPT_VERSION,
|
|
474
|
+
executable: target,
|
|
475
|
+
origin: source.origin,
|
|
476
|
+
sourceFile: source.file,
|
|
477
|
+
version: installed.version,
|
|
478
|
+
sha256: createHash('sha256').update(fs.readFileSync(target)).digest('hex'),
|
|
479
|
+
provisionedAt: new Date().toISOString(),
|
|
480
|
+
dshHome: dshHome(),
|
|
481
|
+
executableName: executableName(),
|
|
482
|
+
}
|
|
483
|
+
const receiptFile = path.join(path.dirname(path.dirname(target)), 'runtime.json')
|
|
484
|
+
fs.writeFileSync(receiptFile, `${JSON.stringify(receipt, null, 2)}\n`)
|
|
485
|
+
|
|
486
|
+
console.log(`installed: ${target}`)
|
|
487
|
+
console.log(`version: ${installed.version}`)
|
|
488
|
+
console.log(`sha256: ${receipt.sha256}`)
|
|
489
|
+
console.log(`receipt: ${receiptFile}`)
|
|
490
|
+
return 0
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
// provision-shells: the pinned upstream shells become local copies
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
/** Receipt schema written inside one provisioned shell's version directory. */
|
|
498
|
+
const SHELL_RECEIPT_VERSION = 1
|
|
499
|
+
|
|
500
|
+
/** The marker file a provisioned version directory carries. */
|
|
501
|
+
const SHELL_RECEIPT_FILE = '.provisioned.json'
|
|
502
|
+
|
|
503
|
+
/** The archive shapes a pin may name, and how each is opened. */
|
|
504
|
+
const PIN_EXTRACTORS = Object.freeze(['zip', 'sfx-7z'])
|
|
505
|
+
|
|
506
|
+
/** The fields every pin must carry; `executableSha256` and the labels are optional. */
|
|
507
|
+
const PIN_REQUIRED_FIELDS = Object.freeze([
|
|
508
|
+
'name',
|
|
509
|
+
'platform',
|
|
510
|
+
'arch',
|
|
511
|
+
'version',
|
|
512
|
+
'assetFile',
|
|
513
|
+
'url',
|
|
514
|
+
'sha256',
|
|
515
|
+
'bytes',
|
|
516
|
+
'extractor',
|
|
517
|
+
'extractTo',
|
|
518
|
+
'executableRelativePath',
|
|
519
|
+
])
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* The SHA-256 of one file, streamed so a 100 MB archive is never held in memory
|
|
523
|
+
* twice.
|
|
524
|
+
* @param {string} file - the file to hash.
|
|
525
|
+
* @returns {string} the lowercase hex digest.
|
|
526
|
+
*/
|
|
527
|
+
function sha256File(file) {
|
|
528
|
+
const hash = createHash('sha256')
|
|
529
|
+
const chunk = Buffer.allocUnsafe(1024 * 1024)
|
|
530
|
+
const handle = fs.openSync(file, 'r')
|
|
531
|
+
try {
|
|
532
|
+
for (;;) {
|
|
533
|
+
const read = fs.readSync(handle, chunk, 0, chunk.length, null)
|
|
534
|
+
if (read === 0) break
|
|
535
|
+
hash.update(chunk.subarray(0, read))
|
|
536
|
+
}
|
|
537
|
+
} finally {
|
|
538
|
+
fs.closeSync(handle)
|
|
539
|
+
}
|
|
540
|
+
return hash.digest('hex')
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* The last few non-empty lines of one stream, for a failure report.
|
|
545
|
+
* @param {string|undefined} text - the stream.
|
|
546
|
+
* @param {number} [count] - how many lines to keep.
|
|
547
|
+
* @returns {string[]} the lines.
|
|
548
|
+
*/
|
|
549
|
+
function tailLines(text, count = 8) {
|
|
550
|
+
return String(text ?? '')
|
|
551
|
+
.split('\n')
|
|
552
|
+
.map((line) => line.trimEnd())
|
|
553
|
+
.filter((line) => line !== '')
|
|
554
|
+
.slice(-count)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Run one helper program, and report the tail of its stderr when it fails.
|
|
559
|
+
* @param {string} file - the program.
|
|
560
|
+
* @param {string[]} args - its arguments.
|
|
561
|
+
* @param {object} [options] - run inputs.
|
|
562
|
+
* @param {string} [options.cwd] - working directory for the child.
|
|
563
|
+
* @returns {{ok: true}|{error: string, stderr: string[]}} the outcome.
|
|
564
|
+
*/
|
|
565
|
+
function runTool(file, args, { cwd } = {}) {
|
|
566
|
+
const result = spawnSync(file, args, { cwd, encoding: 'utf8', windowsHide: true })
|
|
567
|
+
if (result.error !== undefined && result.error !== null) {
|
|
568
|
+
return { error: `could not run ${file}: ${String(result.error.message ?? result.error)}`, stderr: [] }
|
|
569
|
+
}
|
|
570
|
+
if (result.status !== 0) {
|
|
571
|
+
return { error: `${file} exited with code ${result.status}`, stderr: tailLines(result.stderr) }
|
|
572
|
+
}
|
|
573
|
+
return { ok: true }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* The proxy one environment names, in curl's own precedence.
|
|
578
|
+
* @param {NodeJS.ProcessEnv} env - the environment to read.
|
|
579
|
+
* @returns {string|undefined} the proxy URL, when one is named.
|
|
580
|
+
*/
|
|
581
|
+
function proxyFor(env) {
|
|
582
|
+
const value = env.HTTPS_PROXY ?? env.https_proxy ?? env.HTTP_PROXY ?? env.http_proxy
|
|
583
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Download one pinned asset with the platform's own `curl`.
|
|
588
|
+
*
|
|
589
|
+
* The child inherits this process's environment — curl reads the proxy
|
|
590
|
+
* variables itself — and a named proxy is ALSO passed as `--proxy`, so the
|
|
591
|
+
* answer does not depend on which of curl's own variables wins. `process.env` is
|
|
592
|
+
* read, never written.
|
|
593
|
+
* @param {object} options - download inputs.
|
|
594
|
+
* @param {{url: string, assetFile: string}} options.pin - what to fetch.
|
|
595
|
+
* @param {string} options.into - the directory to write the asset into.
|
|
596
|
+
* @param {NodeJS.ProcessEnv} options.env - the environment to read.
|
|
597
|
+
* @returns {{file: string}|{error: string, detail: string, stderr: string[]}} the asset, or why not.
|
|
598
|
+
*/
|
|
599
|
+
function curlAsset({ pin, into, env }) {
|
|
600
|
+
const file = path.join(into, pin.assetFile)
|
|
601
|
+
const proxy = proxyFor(env)
|
|
602
|
+
const args = ['--location', '--fail', '--silent', '--show-error', '--output', file]
|
|
603
|
+
if (proxy !== undefined) args.push('--proxy', proxy)
|
|
604
|
+
args.push(pin.url)
|
|
605
|
+
const result = spawnSync(process.platform === 'win32' ? 'curl.exe' : 'curl', args, {
|
|
606
|
+
encoding: 'utf8',
|
|
607
|
+
windowsHide: true,
|
|
608
|
+
env,
|
|
609
|
+
})
|
|
610
|
+
if (result.error !== undefined && result.error !== null) {
|
|
611
|
+
return {
|
|
612
|
+
error: `could not run curl: ${String(result.error.message ?? result.error)}`,
|
|
613
|
+
detail: 'Windows 10 and newer ship curl.exe; install curl on this machine, or provision where one exists',
|
|
614
|
+
stderr: [],
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (result.status !== 0) {
|
|
618
|
+
return {
|
|
619
|
+
error: `curl exited with code ${result.status}`,
|
|
620
|
+
detail: `could not download ${pin.url}`
|
|
621
|
+
+ (proxy === undefined ? '' : ` through the proxy ${proxy}`)
|
|
622
|
+
+ ' — check the network, the proxy configuration, and whether the upstream release still carries that asset',
|
|
623
|
+
stderr: tailLines(result.stderr),
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return { file }
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* A `tar` that can open a zip: Windows' own bsdtar, never the GNU tar a Git
|
|
631
|
+
* installation puts on PATH.
|
|
632
|
+
*
|
|
633
|
+
* The distinction is not cosmetic. GNU tar cannot read a zip container, so
|
|
634
|
+
* pointing it at the PowerShell archive fails with a message about a corrupt
|
|
635
|
+
* archive rather than about the wrong program. `%SystemRoot%\System32\tar.exe`
|
|
636
|
+
* is the bsdtar Windows 10 and newer ship, and its `--version` says so.
|
|
637
|
+
* @param {NodeJS.ProcessEnv} env - the environment to locate it with.
|
|
638
|
+
* @returns {string|undefined} the program, or undefined when there is none.
|
|
639
|
+
*/
|
|
640
|
+
function findZipCapableTar(env) {
|
|
641
|
+
const candidates = process.platform === 'win32'
|
|
642
|
+
? [path.join(env.SystemRoot ?? env.SYSTEMROOT ?? 'C:\\Windows', 'System32', 'tar.exe')]
|
|
643
|
+
: []
|
|
644
|
+
candidates.push('tar')
|
|
645
|
+
for (const candidate of candidates) {
|
|
646
|
+
const probe = spawnSync(candidate, ['--version'], { encoding: 'utf8', windowsHide: true })
|
|
647
|
+
if (probe.status === 0 && /bsdtar|libarchive/u.test(`${probe.stdout ?? ''}${probe.stderr ?? ''}`)) {
|
|
648
|
+
return candidate
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return undefined
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Unpack one verified asset into a version directory.
|
|
656
|
+
* @param {object} options - unpack inputs.
|
|
657
|
+
* @param {{extractor: string, extractTo: string}} options.pin - the pin.
|
|
658
|
+
* @param {string} options.asset - the verified archive.
|
|
659
|
+
* @param {string} options.target - the version directory.
|
|
660
|
+
* @returns {{ok: true}|{error: string, stderr: string[]}} the outcome.
|
|
661
|
+
*/
|
|
662
|
+
function unpackAsset({ pin, asset, target }) {
|
|
663
|
+
const into = path.resolve(target, pin.extractTo)
|
|
664
|
+
if (pin.extractor === 'zip') {
|
|
665
|
+
const tar = findZipCapableTar(process.env)
|
|
666
|
+
if (tar === undefined) {
|
|
667
|
+
return {
|
|
668
|
+
error: 'no zip-capable tar found',
|
|
669
|
+
stderr: [
|
|
670
|
+
'a zip archive needs Windows\' own bsdtar (%SystemRoot%\\System32\\tar.exe); the tar a Git '
|
|
671
|
+
+ 'installation puts on PATH is GNU tar, which cannot read a zip',
|
|
672
|
+
],
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return runTool(tar, ['-xf', asset, '-C', into])
|
|
676
|
+
}
|
|
677
|
+
if (pin.extractor === 'sfx-7z') {
|
|
678
|
+
// PortableGit's archive IS a 7-Zip self-extractor: `-o<dir>` names where it
|
|
679
|
+
// unpacks and `-y` answers its own prompts.
|
|
680
|
+
return runTool(asset, [`-o${into}`, '-y'], { cwd: path.dirname(asset) })
|
|
681
|
+
}
|
|
682
|
+
return { error: `unknown extractor ${JSON.stringify(pin.extractor)}`, stderr: [] }
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Validate one pin object from a `--pin` file.
|
|
687
|
+
*
|
|
688
|
+
* The fields become path segments (`name`, `version`), a file name
|
|
689
|
+
* (`assetFile`), and an extraction directory (`extractTo`), so they are checked
|
|
690
|
+
* rather than trusted: this table is the one operator-supplied input that
|
|
691
|
+
* reaches the filesystem and the network.
|
|
692
|
+
* @param {unknown} value - the candidate pin.
|
|
693
|
+
* @param {number} index - its position, for the error message.
|
|
694
|
+
* @returns {object} the validated pin.
|
|
695
|
+
* @throws {Error} when a field is missing or malformed.
|
|
696
|
+
*/
|
|
697
|
+
function validatePin(value, index) {
|
|
698
|
+
const at = `pins[${index}]`
|
|
699
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
700
|
+
throw new Error(`${at} must be an object`)
|
|
701
|
+
}
|
|
702
|
+
const pin = /** @type {Record<string, unknown>} */ (value)
|
|
703
|
+
for (const field of PIN_REQUIRED_FIELDS) {
|
|
704
|
+
if (pin[field] === undefined) throw new Error(`${at}.${field} is required`)
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* One field as a non-empty string.
|
|
709
|
+
* @param {string} key - the field.
|
|
710
|
+
* @returns {string} its value.
|
|
711
|
+
*/
|
|
712
|
+
const text = (key) => {
|
|
713
|
+
const entry = pin[key]
|
|
714
|
+
if (typeof entry !== 'string' || entry.trim() === '') {
|
|
715
|
+
throw new Error(`${at}.${key} must be a non-empty string, got ${JSON.stringify(entry)}`)
|
|
716
|
+
}
|
|
717
|
+
return entry
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* One field as a value safe to use as a single path segment.
|
|
722
|
+
* @param {string} key - the field.
|
|
723
|
+
* @returns {string} its value.
|
|
724
|
+
*/
|
|
725
|
+
const segment = (key) => {
|
|
726
|
+
const entry = text(key)
|
|
727
|
+
if (entry.includes('/') || entry.includes('\\') || entry.includes('..') || path.isAbsolute(entry)) {
|
|
728
|
+
throw new Error(`${at}.${key} must be a plain name, got ${JSON.stringify(entry)}`)
|
|
729
|
+
}
|
|
730
|
+
return entry
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* One field as a relative path that stays inside the version directory.
|
|
735
|
+
* @param {string} key - the field.
|
|
736
|
+
* @returns {string} its value.
|
|
737
|
+
*/
|
|
738
|
+
const relative = (key) => {
|
|
739
|
+
const entry = text(key)
|
|
740
|
+
if (path.isAbsolute(entry) || entry.split(/[\\/]/u).includes('..')) {
|
|
741
|
+
throw new Error(`${at}.${key} must be a relative path inside the version directory, got ${JSON.stringify(entry)}`)
|
|
742
|
+
}
|
|
743
|
+
return entry
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const name = text('name')
|
|
747
|
+
if (!Object.hasOwn(PLATFORM_PACKAGES, name)) {
|
|
748
|
+
throw new Error(`${at}.name must be one of ${Object.keys(PLATFORM_PACKAGES).join(', ')}, got ${JSON.stringify(name)}`)
|
|
749
|
+
}
|
|
750
|
+
if (!PIN_EXTRACTORS.includes(text('extractor'))) {
|
|
751
|
+
throw new Error(`${at}.extractor must be one of ${PIN_EXTRACTORS.join(', ')}, got ${JSON.stringify(pin.extractor)}`)
|
|
752
|
+
}
|
|
753
|
+
if (!/^[0-9a-f]{64}$/u.test(text('sha256'))) {
|
|
754
|
+
throw new Error(`${at}.sha256 must be 64 lowercase hex characters, got ${JSON.stringify(pin.sha256)}`)
|
|
755
|
+
}
|
|
756
|
+
if (!Number.isSafeInteger(pin.bytes) || /** @type {number} */ (pin.bytes) <= 0) {
|
|
757
|
+
throw new Error(`${at}.bytes must be a positive whole number, got ${JSON.stringify(pin.bytes)}`)
|
|
758
|
+
}
|
|
759
|
+
const executableRelativePath = relative('executableRelativePath')
|
|
760
|
+
if (path.basename(executableRelativePath) !== PLATFORM_PACKAGES[name].executable) {
|
|
761
|
+
throw new Error(`${at}.executableRelativePath must end in ${PLATFORM_PACKAGES[name].executable}, `
|
|
762
|
+
+ `got ${JSON.stringify(executableRelativePath)}`)
|
|
763
|
+
}
|
|
764
|
+
if (pin.executableSha256 !== undefined && !/^[0-9a-f]{64}$/u.test(String(pin.executableSha256))) {
|
|
765
|
+
throw new Error(`${at}.executableSha256 must be 64 lowercase hex characters, got ${JSON.stringify(pin.executableSha256)}`)
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
return {
|
|
769
|
+
...pin,
|
|
770
|
+
name,
|
|
771
|
+
version: segment('version'),
|
|
772
|
+
platform: text('platform'),
|
|
773
|
+
arch: text('arch'),
|
|
774
|
+
assetFile: segment('assetFile'),
|
|
775
|
+
url: text('url'),
|
|
776
|
+
extractor: text('extractor'),
|
|
777
|
+
extractTo: relative('extractTo'),
|
|
778
|
+
executableRelativePath,
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* The pins `provision-shells` works from.
|
|
784
|
+
*
|
|
785
|
+
* The shipped table is the default; `--pin` replaces it with one JSON file — a
|
|
786
|
+
* mirror, an offline pin set, or a test fixture. Every pin that arrives that way
|
|
787
|
+
* is validated, because these values become paths and a download.
|
|
788
|
+
* @param {string|undefined} file - the `--pin` path, when one was given.
|
|
789
|
+
* @returns {object[]} the pins.
|
|
790
|
+
* @throws {Error} when the file is unreadable or a pin is malformed.
|
|
791
|
+
*/
|
|
792
|
+
function pinTable(file) {
|
|
793
|
+
if (file === undefined) return SHELL_UPSTREAM_PINS.map((pin, index) => validatePin(pin, index))
|
|
794
|
+
const resolved = path.resolve(file)
|
|
795
|
+
let text
|
|
796
|
+
try {
|
|
797
|
+
text = fs.readFileSync(resolved, 'utf8')
|
|
798
|
+
} catch (error) {
|
|
799
|
+
throw new Error(`--pin does not name a readable file: ${resolved} (${error.message})`)
|
|
800
|
+
}
|
|
801
|
+
let value
|
|
802
|
+
try {
|
|
803
|
+
value = JSON.parse(text)
|
|
804
|
+
} catch (error) {
|
|
805
|
+
throw new Error(`--pin must hold one JSON document of pins: ${error.message}`)
|
|
806
|
+
}
|
|
807
|
+
const list = Array.isArray(value) ? value : value?.pins
|
|
808
|
+
if (!Array.isArray(list)) {
|
|
809
|
+
throw new Error('--pin must hold one JSON array of pins (or an object with a "pins" array)')
|
|
810
|
+
}
|
|
811
|
+
return list.map((pin, index) => validatePin(pin, index))
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Read one provision marker, or undefined when it is absent or unreadable.
|
|
816
|
+
* @param {string} file - the marker file.
|
|
817
|
+
* @returns {object|undefined} the parsed marker.
|
|
818
|
+
*/
|
|
819
|
+
function readShellReceipt(file) {
|
|
820
|
+
try {
|
|
821
|
+
const value = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
822
|
+
return value !== null && typeof value === 'object' ? value : undefined
|
|
823
|
+
} catch {
|
|
824
|
+
return undefined
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Provision one pinned shell: download, verify, unpack, and record.
|
|
830
|
+
*
|
|
831
|
+
* Nothing outside the deployment is written: the asset lands in a fresh
|
|
832
|
+
* temporary directory that is removed once the copy is in place, and the copy
|
|
833
|
+
* lands in `<DSH_HOME>/dsh-ops/shells/<name>/<version>`. A failure keeps the
|
|
834
|
+
* downloaded bytes and says where they are, because "the digest did not match"
|
|
835
|
+
* is only actionable with the file in hand.
|
|
836
|
+
* @param {object} pin - the validated pin.
|
|
837
|
+
* @param {object} options - run inputs.
|
|
838
|
+
* @param {boolean} options.force - reinstall even when a matching copy is present.
|
|
839
|
+
* @param {boolean} options.dryRun - report what would happen, and download nothing.
|
|
840
|
+
* @returns {boolean} whether this shell is provisioned (or already was).
|
|
841
|
+
*/
|
|
842
|
+
function provisionShell(pin, { force, dryRun }) {
|
|
843
|
+
const target = provisionedShellVersionDir({ kind: pin.name, version: pin.version })
|
|
844
|
+
const executable = path.join(target, pin.executableRelativePath)
|
|
845
|
+
const receiptFile = path.join(target, SHELL_RECEIPT_FILE)
|
|
846
|
+
const receipt = readShellReceipt(receiptFile)
|
|
847
|
+
if (!force && receipt?.sha256 === pin.sha256 && fs.existsSync(executable)) {
|
|
848
|
+
console.log(`${pin.name}: already provisioned — ${executable}`)
|
|
849
|
+
console.log(` version: ${pin.version}`)
|
|
850
|
+
console.log(` sha256: ${pin.sha256}`)
|
|
851
|
+
return true
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (dryRun) {
|
|
855
|
+
console.log(`${pin.name}: would provision version ${pin.version} (dry run: no download, no writes)`)
|
|
856
|
+
console.log(` url: ${pin.url}`)
|
|
857
|
+
console.log(` asset: ${pin.assetFile} (${formatBytes(pin.bytes)})`)
|
|
858
|
+
console.log(` sha256: ${pin.sha256}`)
|
|
859
|
+
console.log(` unpack: ${pin.extractor} into ${path.resolve(target, pin.extractTo)}`)
|
|
860
|
+
console.log(` target: ${target}`)
|
|
861
|
+
console.log(` executable: ${executable}`)
|
|
862
|
+
return true
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), `dsh-ops-${pin.name}-`))
|
|
866
|
+
console.log(`${pin.name}: downloading ${pin.url}`)
|
|
867
|
+
const downloaded = curlAsset({ pin, into: scratch, env: process.env })
|
|
868
|
+
if (!('file' in downloaded)) {
|
|
869
|
+
console.error(`${pin.name}: ${downloaded.error} — ${downloaded.detail}`)
|
|
870
|
+
for (const line of downloaded.stderr) console.error(` curl: ${line}`)
|
|
871
|
+
console.error(` kept for diagnosis: ${scratch}`)
|
|
872
|
+
return false
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const actual = sha256File(downloaded.file)
|
|
876
|
+
if (actual !== pin.sha256) {
|
|
877
|
+
console.error(`${pin.name}: ${pin.assetFile} hashed to ${actual}, not the pinned ${pin.sha256}`)
|
|
878
|
+
console.error(' refusing to install bytes this distribution did not pin')
|
|
879
|
+
console.error(` kept for diagnosis: ${downloaded.file}`)
|
|
880
|
+
return false
|
|
881
|
+
}
|
|
882
|
+
console.log(`${pin.name}: verified sha256 ${actual}`)
|
|
883
|
+
|
|
884
|
+
if (force) fs.rmSync(target, { recursive: true, force: true })
|
|
885
|
+
fs.mkdirSync(path.resolve(target, pin.extractTo), { recursive: true })
|
|
886
|
+
const unpacked = unpackAsset({ pin, asset: downloaded.file, target })
|
|
887
|
+
if (!('ok' in unpacked)) {
|
|
888
|
+
console.error(`${pin.name}: ${unpacked.error}`)
|
|
889
|
+
for (const line of unpacked.stderr) console.error(` ${line}`)
|
|
890
|
+
console.error(` kept for diagnosis: ${downloaded.file}`)
|
|
891
|
+
return false
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (!fs.existsSync(executable)) {
|
|
895
|
+
console.error(`${pin.name}: the archive unpacked without ${pin.executableRelativePath}`)
|
|
896
|
+
console.error(` expected it at ${executable}`)
|
|
897
|
+
console.error(` kept for diagnosis: ${downloaded.file}`)
|
|
898
|
+
return false
|
|
899
|
+
}
|
|
900
|
+
if (pin.executableSha256 !== undefined) {
|
|
901
|
+
const executableActual = sha256File(executable)
|
|
902
|
+
if (executableActual !== pin.executableSha256) {
|
|
903
|
+
console.error(`${pin.name}: the provisioned ${pin.executableRelativePath} hashed to ${executableActual}, `
|
|
904
|
+
+ `not the pinned ${pin.executableSha256}`)
|
|
905
|
+
console.error(` kept for diagnosis: ${downloaded.file}`)
|
|
906
|
+
return false
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
fs.writeFileSync(receiptFile, `${JSON.stringify({
|
|
911
|
+
schemaVersion: SHELL_RECEIPT_VERSION,
|
|
912
|
+
name: pin.name,
|
|
913
|
+
version: pin.version,
|
|
914
|
+
releaseTag: pin.releaseTag ?? null,
|
|
915
|
+
upstreamRepo: pin.upstreamRepo ?? null,
|
|
916
|
+
url: pin.url,
|
|
917
|
+
assetFile: pin.assetFile,
|
|
918
|
+
bytes: pin.bytes,
|
|
919
|
+
sha256: pin.sha256,
|
|
920
|
+
extractor: pin.extractor,
|
|
921
|
+
extractTo: pin.extractTo,
|
|
922
|
+
executable: pin.executableRelativePath,
|
|
923
|
+
executableSha256: pin.executableSha256 ?? null,
|
|
924
|
+
provisionedBy: 'dsh-ops provision-shells',
|
|
925
|
+
provisionedAt: new Date().toISOString(),
|
|
926
|
+
dshHome: dshHome(),
|
|
927
|
+
}, null, 2)}\n`)
|
|
928
|
+
fs.rmSync(scratch, { recursive: true, force: true })
|
|
929
|
+
|
|
930
|
+
// The store holds the version the pins name. A previous version is a copy this
|
|
931
|
+
// distribution no longer vouches for — and one the resolution would still
|
|
932
|
+
// consider, because it takes the greatest version name — so it is removed
|
|
933
|
+
// here, after a successful install and never before it.
|
|
934
|
+
const store = path.join(provisionedShellStore({ env: process.env }), pin.name)
|
|
935
|
+
for (const entry of fs.readdirSync(store, { withFileTypes: true })) {
|
|
936
|
+
if (!entry.isDirectory() || entry.name === pin.version) continue
|
|
937
|
+
const stale = path.join(store, entry.name)
|
|
938
|
+
try {
|
|
939
|
+
fs.rmSync(stale, { recursive: true, force: true })
|
|
940
|
+
console.log(` removed: ${stale} (a version this pin no longer names)`)
|
|
941
|
+
} catch (error) {
|
|
942
|
+
console.error(` warning: could not remove the previous version ${stale}: ${error.message}`)
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
console.log(`${pin.name}: provisioned ${executable}`)
|
|
947
|
+
console.log(` receipt: ${receiptFile}`)
|
|
948
|
+
return true
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* `provision-shells`: turn the pinned upstream releases into the local copies
|
|
953
|
+
* the shell rungs prefer.
|
|
954
|
+
*
|
|
955
|
+
* The pointer is the whole distribution story for these two binaries: this
|
|
956
|
+
* command is what reads {@link SHELL_UPSTREAM_PINS}, downloads the exact
|
|
957
|
+
* archive, checks the exact digest, and unpacks it under `DSH_HOME`. A shell
|
|
958
|
+
* that is already there with the pinned digest is left alone (`--force` is the
|
|
959
|
+
* way to reinstall), and `--dry-run` answers "what would this do" without
|
|
960
|
+
* touching the network or the filesystem.
|
|
961
|
+
* @param {object} options - command inputs.
|
|
962
|
+
* @param {('bash'|'pwsh')[]} options.kinds - which shells to provision.
|
|
963
|
+
* @param {boolean} options.force - reinstall even when current.
|
|
964
|
+
* @param {boolean} options.dryRun - report only.
|
|
965
|
+
* @param {string|undefined} options.pinFile - read the pins from this JSON file instead.
|
|
966
|
+
* @returns {number} the exit code: 0 when every selected shell is in place, 1 otherwise.
|
|
967
|
+
*/
|
|
968
|
+
function commandProvisionShells({ kinds, force, dryRun, pinFile }) {
|
|
969
|
+
let pins
|
|
970
|
+
try {
|
|
971
|
+
pins = pinTable(pinFile)
|
|
972
|
+
} catch (error) {
|
|
973
|
+
console.error(error.message)
|
|
974
|
+
return 1
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const selected = []
|
|
978
|
+
for (const kind of kinds) {
|
|
979
|
+
const pin = pins.find((candidate) => (
|
|
980
|
+
candidate.name === kind && candidate.platform === process.platform && candidate.arch === process.arch
|
|
981
|
+
))
|
|
982
|
+
if (pin === undefined) {
|
|
983
|
+
const covered = [...new Set(pins.map((candidate) => `${candidate.platform}-${candidate.arch}`))].join(', ')
|
|
984
|
+
console.error(`no upstream pin for ${kind} on ${process.platform}-${process.arch}`
|
|
985
|
+
+ (covered === '' ? '' : `; the pin table covers ${covered}`))
|
|
986
|
+
return 1
|
|
987
|
+
}
|
|
988
|
+
selected.push(pin)
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
let failed = 0
|
|
992
|
+
for (const pin of selected) {
|
|
993
|
+
if (!provisionShell(pin, { force, dryRun })) failed += 1
|
|
994
|
+
}
|
|
995
|
+
return failed === 0 ? 0 : 1
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Render a byte count for a human report.
|
|
1000
|
+
* @param {number} bytes - the size.
|
|
1001
|
+
* @returns {string} the size in the largest unit that keeps it readable.
|
|
1002
|
+
*/
|
|
1003
|
+
function formatBytes(bytes) {
|
|
1004
|
+
if (bytes < 1024) return `${bytes} B`
|
|
1005
|
+
const units = ['kB', 'MB', 'GB', 'TB']
|
|
1006
|
+
let value = bytes / 1024
|
|
1007
|
+
let unit = 0
|
|
1008
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
1009
|
+
value /= 1024
|
|
1010
|
+
unit += 1
|
|
1011
|
+
}
|
|
1012
|
+
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Measure a file or directory without following symbolic links out of the tree:
|
|
1017
|
+
* an unreadable entry reports zero rather than failing the report.
|
|
1018
|
+
* @param {string} entry - the path to measure.
|
|
1019
|
+
* @returns {number} the byte count.
|
|
1020
|
+
*/
|
|
1021
|
+
function measure(entry) {
|
|
1022
|
+
let stat
|
|
1023
|
+
try {
|
|
1024
|
+
stat = fs.lstatSync(entry)
|
|
1025
|
+
} catch {
|
|
1026
|
+
return 0
|
|
1027
|
+
}
|
|
1028
|
+
if (!stat.isDirectory()) return stat.size
|
|
1029
|
+
let total = 0
|
|
1030
|
+
for (const child of fs.readdirSync(entry, { withFileTypes: true })) {
|
|
1031
|
+
total += measure(path.join(entry, child.name))
|
|
1032
|
+
}
|
|
1033
|
+
return total
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Describe the top-level entries of a directory, for reporting what a removal
|
|
1038
|
+
* would take with it.
|
|
1039
|
+
* @param {string} dir - the directory.
|
|
1040
|
+
* @returns {{name: string, bytes: number}[]} one row per entry, directories first.
|
|
1041
|
+
*/
|
|
1042
|
+
function contentsOf(dir) {
|
|
1043
|
+
const rows = fs.readdirSync(dir, { withFileTypes: true }).map((entry) => ({
|
|
1044
|
+
name: entry.isDirectory() ? `${entry.name}${path.sep}` : entry.name,
|
|
1045
|
+
bytes: measure(path.join(dir, entry.name)),
|
|
1046
|
+
}))
|
|
1047
|
+
return rows.sort((left, right) => {
|
|
1048
|
+
const leftDir = left.name.endsWith(path.sep)
|
|
1049
|
+
const rightDir = right.name.endsWith(path.sep)
|
|
1050
|
+
return leftDir === rightDir ? left.name.localeCompare(right.name) : leftDir ? -1 : 1
|
|
1051
|
+
})
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Report one directory and its contents under a section heading.
|
|
1056
|
+
* @param {string} label - the section heading.
|
|
1057
|
+
* @param {string} dir - the directory to describe.
|
|
1058
|
+
* @returns {{missing: boolean, rows: {name: string, bytes: number}[], bytes: number}} the report.
|
|
1059
|
+
*/
|
|
1060
|
+
function reportDirectory(label, dir) {
|
|
1061
|
+
console.log(`\n${label}`)
|
|
1062
|
+
console.log(` directory: ${dir}`)
|
|
1063
|
+
if (!fs.existsSync(dir)) {
|
|
1064
|
+
console.log(' contents: (does not exist)')
|
|
1065
|
+
return { missing: true, rows: [], bytes: 0 }
|
|
1066
|
+
}
|
|
1067
|
+
const rows = contentsOf(dir)
|
|
1068
|
+
const bytes = rows.reduce((total, row) => total + row.bytes, 0)
|
|
1069
|
+
console.log(rows.length === 0
|
|
1070
|
+
? ' contents: (empty)'
|
|
1071
|
+
: ` contents: ${rows.map((row) => `${row.name} (${formatBytes(row.bytes)})`).join(', ')}`)
|
|
1072
|
+
console.log(` total: ${formatBytes(bytes)}`)
|
|
1073
|
+
return { missing: false, rows, bytes }
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* `uninstall`: remove the runtime this plugin installed outside the profile, and
|
|
1078
|
+
* report the state it does not own.
|
|
1079
|
+
*
|
|
1080
|
+
* Removing the plugin itself is a profile operation — `dsh plugin --profile
|
|
1081
|
+
* <profile> remove dsh-ops`, or the `plugin_manager` `remove_bundle` tool — and
|
|
1082
|
+
* stays outside this command. What is inside: the managed runtime under
|
|
1083
|
+
* `<DSH_HOME>/dsh-ops`, and, only on request, FastCtx's own `~/.fastctx` state.
|
|
1084
|
+
*
|
|
1085
|
+
* Nothing is deleted without `--yes`, so a bare invocation is the report.
|
|
1086
|
+
*
|
|
1087
|
+
* @param {object} options - uninstall inputs.
|
|
1088
|
+
* @param {boolean} options.yes - perform the removals instead of reporting them.
|
|
1089
|
+
* @param {boolean} options.purgeFastctx - also remove FastCtx's own state directory.
|
|
1090
|
+
* @param {boolean} options.dryRun - report only, whatever `yes` says.
|
|
1091
|
+
* @returns {number} the exit code: 0 when the requested state is gone or absent, 1 when a removal failed.
|
|
1092
|
+
*/
|
|
1093
|
+
function commandUninstall({ yes, purgeFastctx, dryRun }) {
|
|
1094
|
+
const removing = yes && !dryRun
|
|
1095
|
+
console.log(`dsh-ops uninstall — ${removing ? 'removing' : 'report only (dry run; pass --yes to delete)'}`)
|
|
1096
|
+
|
|
1097
|
+
const managed = managedDir()
|
|
1098
|
+
const managedReport = reportDirectory(`managed runtime (<DSH_HOME>/dsh-ops)`, managed)
|
|
1099
|
+
if (managedReport.missing) {
|
|
1100
|
+
console.log(' action: nothing to remove')
|
|
1101
|
+
} else if (!removing) {
|
|
1102
|
+
console.log(` action: would remove this directory and its ${formatBytes(managedReport.bytes)}`)
|
|
1103
|
+
} else {
|
|
1104
|
+
try {
|
|
1105
|
+
fs.rmSync(managed, { recursive: true, force: true })
|
|
1106
|
+
console.log(' action: removed')
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
console.error(` action: FAILED to remove ${managed}: ${error.message}`)
|
|
1109
|
+
return 1
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const fastctx = fastctxDataDir()
|
|
1114
|
+
const fastctxReport = reportDirectory("FastCtx user data (not owned by this plugin)", fastctx)
|
|
1115
|
+
if (fastctxReport.missing) {
|
|
1116
|
+
console.log(' action: nothing to remove')
|
|
1117
|
+
} else if (!purgeFastctx) {
|
|
1118
|
+
console.log(' action: kept (pass --yes --purge-fastctx to remove it as well)')
|
|
1119
|
+
} else if (!removing) {
|
|
1120
|
+
console.log(` action: would remove this directory and its ${formatBytes(fastctxReport.bytes)}`)
|
|
1121
|
+
} else {
|
|
1122
|
+
try {
|
|
1123
|
+
fs.rmSync(fastctx, { recursive: true, force: true })
|
|
1124
|
+
console.log(' action: removed')
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
console.error(` action: FAILED to remove ${fastctx}: ${error.message}`)
|
|
1127
|
+
return 1
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
console.log('\nplugin installation (not handled by this command)')
|
|
1132
|
+
console.log(' action: remove the plugin itself with `dsh plugin --profile <profile> remove dsh-ops`,')
|
|
1133
|
+
console.log(' or the plugin_manager `remove_bundle` tool; this command only removes the')
|
|
1134
|
+
console.log(' runtime and state the plugin wrote outside the profile.')
|
|
1135
|
+
|
|
1136
|
+
return 0
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Parse arguments and run one command.
|
|
1141
|
+
* @returns {Promise<number>} the exit code.
|
|
1142
|
+
*/
|
|
1143
|
+
async function main() {
|
|
1144
|
+
const { values, positionals } = parseArgs({
|
|
1145
|
+
args: process.argv.slice(2),
|
|
1146
|
+
allowPositionals: true,
|
|
1147
|
+
options: {
|
|
1148
|
+
profile: { type: 'string' },
|
|
1149
|
+
'dsh-cli': { type: 'string' },
|
|
1150
|
+
force: { type: 'boolean', default: false },
|
|
1151
|
+
from: { type: 'string' },
|
|
1152
|
+
'no-build': { type: 'boolean', default: false },
|
|
1153
|
+
yes: { type: 'boolean', default: false },
|
|
1154
|
+
'purge-fastctx': { type: 'boolean', default: false },
|
|
1155
|
+
'dry-run': { type: 'boolean', default: false },
|
|
1156
|
+
json: { type: 'boolean', default: false },
|
|
1157
|
+
config: { type: 'string' },
|
|
1158
|
+
bash: { type: 'boolean', default: false },
|
|
1159
|
+
pwsh: { type: 'boolean', default: false },
|
|
1160
|
+
pin: { type: 'string' },
|
|
1161
|
+
},
|
|
1162
|
+
})
|
|
1163
|
+
const command = positionals[0] ?? 'help'
|
|
1164
|
+
if (positionals.length > 1) throw new Error('Unexpected positional arguments.')
|
|
1165
|
+
if (command === 'install' || (['uninstall', 'status'].includes(command) && values.profile !== undefined)) {
|
|
1166
|
+
return profileCommand(command, { profile: values.profile, dshCli: values['dsh-cli'], dryRun: values['dry-run'] === true })
|
|
1167
|
+
}
|
|
1168
|
+
switch (command) {
|
|
1169
|
+
case 'resolve':
|
|
1170
|
+
return commandResolve()
|
|
1171
|
+
case 'status':
|
|
1172
|
+
return commandStatus()
|
|
1173
|
+
case 'ladder':
|
|
1174
|
+
return commandLadder({ json: values.json === true, configFile: values.config })
|
|
1175
|
+
case 'build':
|
|
1176
|
+
return commandBuild(values.force === true)
|
|
1177
|
+
case 'provision':
|
|
1178
|
+
return commandProvision({
|
|
1179
|
+
force: values.force === true,
|
|
1180
|
+
from: values.from,
|
|
1181
|
+
noBuild: values['no-build'] === true,
|
|
1182
|
+
})
|
|
1183
|
+
case 'provision-shells': {
|
|
1184
|
+
/** No shell flag means both, so a bare invocation is "make this deployment whole". */
|
|
1185
|
+
const kinds = []
|
|
1186
|
+
if (values.bash === true || values.pwsh === true) {
|
|
1187
|
+
if (values.bash === true) kinds.push('bash')
|
|
1188
|
+
if (values.pwsh === true) kinds.push('pwsh')
|
|
1189
|
+
} else {
|
|
1190
|
+
kinds.push('bash', 'pwsh')
|
|
1191
|
+
}
|
|
1192
|
+
return commandProvisionShells({
|
|
1193
|
+
kinds,
|
|
1194
|
+
force: values.force === true,
|
|
1195
|
+
dryRun: values['dry-run'] === true,
|
|
1196
|
+
pinFile: values.pin,
|
|
1197
|
+
})
|
|
1198
|
+
}
|
|
1199
|
+
case 'uninstall':
|
|
1200
|
+
return commandUninstall({
|
|
1201
|
+
yes: values.yes === true,
|
|
1202
|
+
purgeFastctx: values['purge-fastctx'] === true,
|
|
1203
|
+
dryRun: values['dry-run'] === true,
|
|
1204
|
+
})
|
|
1205
|
+
case 'help':
|
|
1206
|
+
usage()
|
|
1207
|
+
return 0
|
|
1208
|
+
default:
|
|
1209
|
+
console.error(`dsh-ops: unknown command ${JSON.stringify(command)}\n`)
|
|
1210
|
+
usage()
|
|
1211
|
+
return 2
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
try { process.exitCode = await main() }
|
|
1216
|
+
catch (error) { console.error(`dsh-ops: ${error.message}`); process.exitCode = 1 }
|