dsh-artcraft-plugin-vectorcraft 0.0.0-stage → 0.1.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/cordis.patch.yml +5 -0
- package/package.json +45 -4
- package/src/index.ts +38 -0
- package/src/kit/catalog.js +230 -0
- package/src/kit/discover.js +194 -0
- package/src/kit/exec.js +242 -0
- package/src/kit/index.js +16 -0
- package/src/kit/mcp.js +231 -0
- package/src/kit/platform.js +82 -0
- package/src/kit/profiles.mjs +371 -0
- package/src/kit/tools.js +789 -0
- package/src/kit/types.js +197 -0
- package/src/root.ts +13 -0
- package/README.md +0 -3
package/cordis.patch.yml
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-artcraft-plugin-vectorcraft",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "DSH plugin: drive VectorCraft (vector art) headlessly through its own CLI and MCP server.",
|
|
7
|
+
"license": "MIT OR Apache-2.0",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"@deepseek-ai/cordis": "*",
|
|
14
|
+
"@deepseek-ai/dsh-tools": "*"
|
|
15
|
+
},
|
|
16
|
+
"peerDependenciesMeta": {
|
|
17
|
+
"@deepseek-ai/cordis": {
|
|
18
|
+
"optional": true
|
|
19
|
+
},
|
|
20
|
+
"@deepseek-ai/dsh-tools": {
|
|
21
|
+
"optional": true
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"cordis.patch.yml",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"dsh": {
|
|
30
|
+
"bundle": {
|
|
31
|
+
"patch": "./cordis.patch.yml"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"dsh-plugin",
|
|
36
|
+
"dsh",
|
|
37
|
+
"deepseek-harness",
|
|
38
|
+
"cordis",
|
|
39
|
+
"artcraft",
|
|
40
|
+
"vectorcraft",
|
|
41
|
+
"ai-agent",
|
|
42
|
+
"mcp"
|
|
43
|
+
],
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VectorCraft as a DSH plugin.
|
|
3
|
+
*
|
|
4
|
+
* Registers model-callable tools that drive the `vectorcraft-cli` binary this machine
|
|
5
|
+
* already has — both its generic `--cmd` surface and its own MCP server. The
|
|
6
|
+
* VectorCraft source tree is never touched: the CLI is an external process, located
|
|
7
|
+
* at call time so installing it mid-session works.
|
|
8
|
+
*
|
|
9
|
+
* The kernel under `./kit/` is generated from `dsh-plugins/craft-kit/src` by
|
|
10
|
+
* `scripts/build-kit.mjs` and inlined so this package installs standalone.
|
|
11
|
+
* @module dsh-artcraft-plugin-vectorcraft
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
15
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
16
|
+
import { applyCraftPlugin, profileOf } from './kit/index.js'
|
|
17
|
+
import type { CraftPluginConfig } from './kit/index.js'
|
|
18
|
+
import { PLUGIN_ROOT } from './root.ts'
|
|
19
|
+
|
|
20
|
+
export const name = 'dsh-artcraft-vectorcraft'
|
|
21
|
+
export const inject = ['tools', 'systemPrompt']
|
|
22
|
+
|
|
23
|
+
/** The craft this plugin exposes. */
|
|
24
|
+
export const craft = profileOf('vectorcraft')
|
|
25
|
+
|
|
26
|
+
/** Configuration is the shared craft config. */
|
|
27
|
+
export type Config = CraftPluginConfig
|
|
28
|
+
|
|
29
|
+
export function apply(ctx: Context, config: Config = {}): void {
|
|
30
|
+
if (craft === undefined) throw new Error('dsh-artcraft-vectorcraft: profile "vectorcraft" is missing from the inlined kernel')
|
|
31
|
+
// `ctx.effect` ties the registration and the MCP server to this plugin's fiber.
|
|
32
|
+
ctx.effect(() => applyCraftPlugin(ctx, {
|
|
33
|
+
profile: craft,
|
|
34
|
+
packageRoot: PLUGIN_ROOT,
|
|
35
|
+
config,
|
|
36
|
+
defineTool: defineTool as never,
|
|
37
|
+
}))
|
|
38
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load and normalize a craft's command catalog.
|
|
3
|
+
*
|
|
4
|
+
* Every craft ships `commands`; some print JSON, some a fixed-width table.
|
|
5
|
+
* Plugins use the catalog to tell the model which verbs exist, and the
|
|
6
|
+
* snapshot files generated by `scripts/gen-catalogs.mjs` let a plugin answer
|
|
7
|
+
* from disk when the binary is not installed yet.
|
|
8
|
+
* @module @dsh-artcraft/craft-kit/catalog
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from 'node:fs'
|
|
12
|
+
import { dirname, join } from 'node:path'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
import { CATALOG_CAPTURE_BYTES, runCraft } from './exec.js'
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
/** Directory holding this module's compiled/`src` file. */
|
|
18
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
19
|
+
|
|
20
|
+
/** Where `scripts/gen-catalogs.mjs` writes snapshots, relative to a plugin package. */
|
|
21
|
+
export const CATALOG_DIR = 'catalog'
|
|
22
|
+
|
|
23
|
+
/** Infer the command family from its dotted id: `insert.chart` → `insert`. */
|
|
24
|
+
export function groupOf(id ) {
|
|
25
|
+
const dot = id.indexOf('.')
|
|
26
|
+
return dot === -1 ? '(root)' : id.slice(0, dot)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Normalize one raw catalog entry from any craft's JSON.
|
|
31
|
+
*
|
|
32
|
+
* Most crafts use `{ id, label, params, menu, shortcut }`; PdfCraft's `tools`
|
|
33
|
+
* output instead uses `{ command, description, input_schema }`, so both shapes
|
|
34
|
+
* are folded into the same record.
|
|
35
|
+
*/
|
|
36
|
+
function normalize(raw ) {
|
|
37
|
+
// `name` wins over `command`: PdfCraft's tool list carries the runnable tool
|
|
38
|
+
// name in `name` and a UI-level command id in `command`, and only `name` works
|
|
39
|
+
// with `pdfcraft-cli run`.
|
|
40
|
+
const id = typeof raw.id === 'string' && raw.id.length > 0
|
|
41
|
+
? raw.id
|
|
42
|
+
: typeof raw.name === 'string' && raw.name.length > 0
|
|
43
|
+
? raw.name
|
|
44
|
+
: typeof raw.command === 'string' && raw.command.length > 0 ? raw.command : undefined
|
|
45
|
+
if (id === undefined) return undefined
|
|
46
|
+
const menu = Array.isArray(raw.menu) ? raw.menu.filter((m) => typeof m === 'string') : undefined
|
|
47
|
+
// PdfCraft describes parameters as a JSON Schema; render it back to a grammar
|
|
48
|
+
// string so every craft's catalog reads the same way in the tool output.
|
|
49
|
+
const schema = typeof raw.input_schema === 'object' && raw.input_schema !== null
|
|
50
|
+
? raw.input_schema
|
|
51
|
+
: undefined
|
|
52
|
+
const params = typeof raw.params === 'string'
|
|
53
|
+
? raw.params
|
|
54
|
+
: schema?.properties !== undefined
|
|
55
|
+
? Object.entries(schema.properties)
|
|
56
|
+
.map(([key, node]) => `${key}${schema.required?.includes(key) === true ? '' : '?'}: ${node.type ?? 'value'}${node.description !== undefined ? ` (${node.description})` : ''}`)
|
|
57
|
+
.join(', ')
|
|
58
|
+
: undefined
|
|
59
|
+
return {
|
|
60
|
+
id,
|
|
61
|
+
...typeof raw.label === 'string' ? { label: raw.label } : {},
|
|
62
|
+
...params !== undefined && params.length > 0 ? { params } : {},
|
|
63
|
+
...menu !== undefined ? { menu } : {},
|
|
64
|
+
...typeof raw.shortcut === 'string' || raw.shortcut === null ? { shortcut: raw.shortcut } : {},
|
|
65
|
+
...Array.isArray(raw.aliases) ? { aliases: raw.aliases.filter((a) => typeof a === 'string') } : {},
|
|
66
|
+
...typeof raw.interactive === 'boolean' ? { interactive: raw.interactive } : {},
|
|
67
|
+
...typeof raw.enabled === 'boolean' ? { enabled: raw.enabled } : {},
|
|
68
|
+
...typeof raw.description === 'string' ? { description: raw.description } : {},
|
|
69
|
+
group: groupOf(id),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse a craft's `commands` output.
|
|
75
|
+
* @param stdout - raw stdout of the `commands` subcommand.
|
|
76
|
+
* @param json - whether the CLI was asked for JSON.
|
|
77
|
+
* @returns every command the output described.
|
|
78
|
+
*/
|
|
79
|
+
export function parseCatalog(stdout , json ) {
|
|
80
|
+
const text = stdout.trim()
|
|
81
|
+
if (text.length === 0) return []
|
|
82
|
+
if (json || text.startsWith('[')) {
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(text)
|
|
85
|
+
if (Array.isArray(parsed)) {
|
|
86
|
+
return parsed
|
|
87
|
+
.filter((entry) => typeof entry === 'object' && entry !== null)
|
|
88
|
+
.map(normalize)
|
|
89
|
+
.filter((entry) => entry !== undefined)
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// Fall through to the table parser: a CLI may ignore an unknown --json.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return parseTable(text)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Parse a craft's fixed-column `commands` table.
|
|
100
|
+
*
|
|
101
|
+
* Every table puts the id in the first column and keeps later columns
|
|
102
|
+
* aligned, so a split on 2+ spaces is reliable; parameter grammars and long
|
|
103
|
+
* labels wrap onto continuation lines that stay indented, and those are folded
|
|
104
|
+
* back into the entry they belong to.
|
|
105
|
+
* @param text - raw table output.
|
|
106
|
+
* @returns every command the table described.
|
|
107
|
+
*/
|
|
108
|
+
export function parseTable(text ) {
|
|
109
|
+
const out = []
|
|
110
|
+
let current
|
|
111
|
+
const flush = () => {
|
|
112
|
+
if (current === undefined) return
|
|
113
|
+
const { id, columns } = current
|
|
114
|
+
const rest = columns.join(' ').trim()
|
|
115
|
+
const paramsMatch = /\bparams:\s*(\{.*)$/.exec(rest)
|
|
116
|
+
const labelPart = paramsMatch === null ? rest : rest.slice(0, paramsMatch.index).trim()
|
|
117
|
+
// Trailing `label [Shortcut]` and an interleaved menu column.
|
|
118
|
+
const shortcutMatch = /\[([^\]]+)\]\s*$/.exec(labelPart)
|
|
119
|
+
const label = (shortcutMatch === null ? labelPart : labelPart.slice(0, shortcutMatch.index)).trim()
|
|
120
|
+
out.push({
|
|
121
|
+
id,
|
|
122
|
+
...label.length > 0 ? { label } : {},
|
|
123
|
+
...paramsMatch !== null ? { params: paramsMatch[1].replace(/\s+/g, ' ').trim() } : {},
|
|
124
|
+
...shortcutMatch !== null ? { shortcut: shortcutMatch[1] } : {},
|
|
125
|
+
group: groupOf(id),
|
|
126
|
+
})
|
|
127
|
+
current = undefined
|
|
128
|
+
}
|
|
129
|
+
for (const raw of text.split('\n')) {
|
|
130
|
+
if (raw.trim().length === 0) continue
|
|
131
|
+
if (/^\s/.test(raw)) {
|
|
132
|
+
// Continuation: a wrapped label/params line belongs to the open entry,
|
|
133
|
+
// unless it begins a fresh block (params: on its own column).
|
|
134
|
+
if (current !== undefined) current.columns.push(raw.trim())
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
flush()
|
|
138
|
+
const columns = raw.trimEnd().split(/\s{2,}/).map(part => part.trim()).filter(part => part.length > 0)
|
|
139
|
+
const id = columns[0] ?? ''
|
|
140
|
+
if (id.length === 0 || /\s/.test(id) || !/^[\w.-]+$/.test(id)) continue
|
|
141
|
+
current = { id, columns: columns.slice(1) }
|
|
142
|
+
}
|
|
143
|
+
flush()
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Read a bundled catalog snapshot shipped beside a plugin package. */
|
|
148
|
+
export function readSnapshot(packageRoot , craftId ) {
|
|
149
|
+
try {
|
|
150
|
+
const raw = readFileSync(join(packageRoot, CATALOG_DIR, `${craftId}.json`), 'utf8')
|
|
151
|
+
const parsed = JSON.parse(raw)
|
|
152
|
+
if (!Array.isArray(parsed)) return undefined
|
|
153
|
+
return parsed
|
|
154
|
+
.filter((entry) => typeof entry === 'object' && entry !== null)
|
|
155
|
+
.map(normalize)
|
|
156
|
+
.filter((entry) => entry !== undefined)
|
|
157
|
+
} catch {
|
|
158
|
+
return undefined
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Catalog source and its commands. */
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Load a craft's catalog, preferring the installed binary.
|
|
173
|
+
* @param profile - the craft profile.
|
|
174
|
+
* @param binary - resolved binary, or `undefined` to use the snapshot.
|
|
175
|
+
* @param packageRoot - plugin package root holding `catalog/`.
|
|
176
|
+
* @param timeoutMs - budget for the live read.
|
|
177
|
+
* @returns catalog commands plus where they came from.
|
|
178
|
+
*/
|
|
179
|
+
export async function loadCatalog(
|
|
180
|
+
profile ,
|
|
181
|
+
binary ,
|
|
182
|
+
packageRoot ,
|
|
183
|
+
timeoutMs = 30_000,
|
|
184
|
+
) {
|
|
185
|
+
if (binary !== undefined) {
|
|
186
|
+
const argv = [...profile.catalogArgs ?? ['commands', ...profile.catalogJson ? ['--json'] : []]]
|
|
187
|
+
const result = await runCraft({
|
|
188
|
+
profile, binary, argv, cwd: process.cwd(), timeoutMs, maxCaptureBytes: CATALOG_CAPTURE_BYTES,
|
|
189
|
+
})
|
|
190
|
+
const parsed = parseCatalog(result.stdout, profile.catalogJson)
|
|
191
|
+
if (parsed.length > 0) return { commands: parsed, origin: 'live' }
|
|
192
|
+
const detail = (result.stderr || result.stdout).trim().slice(0, 300)
|
|
193
|
+
const snapshot = readSnapshot(packageRoot, profile.id)
|
|
194
|
+
return snapshot !== undefined
|
|
195
|
+
? { commands: snapshot, origin: 'snapshot', note: `live catalog read failed: ${detail}` }
|
|
196
|
+
: { commands: [], origin: 'empty', note: `live catalog read returned no commands: ${detail}` }
|
|
197
|
+
}
|
|
198
|
+
const snapshot = readSnapshot(packageRoot, profile.id)
|
|
199
|
+
return snapshot !== undefined
|
|
200
|
+
? { commands: snapshot, origin: 'snapshot' }
|
|
201
|
+
: { commands: [], origin: 'empty', note: `${profile.bin} is not installed and no catalog snapshot was bundled` }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** All distinct command families in a catalog, in first-seen order. */
|
|
205
|
+
export function groupsOf(commands ) {
|
|
206
|
+
const seen = []
|
|
207
|
+
for (const command of commands) {
|
|
208
|
+
const group = command.group ?? groupOf(command.id)
|
|
209
|
+
if (!seen.includes(group)) seen.push(group)
|
|
210
|
+
}
|
|
211
|
+
return seen
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Score a command against a free-text query, for search and ranking.
|
|
216
|
+
* @param command - catalog entry.
|
|
217
|
+
* @param needle - lowercased query.
|
|
218
|
+
* @returns 0 when unrelated, higher when more relevant.
|
|
219
|
+
*/
|
|
220
|
+
export function score(command , needle ) {
|
|
221
|
+
const id = command.id.toLowerCase()
|
|
222
|
+
const label = (command.label ?? '').toLowerCase()
|
|
223
|
+
const params = (command.params ?? '').toLowerCase()
|
|
224
|
+
if (id === needle) return 100
|
|
225
|
+
if (id.startsWith(needle)) return 60
|
|
226
|
+
if (id.includes(needle)) return 40
|
|
227
|
+
if (label.includes(needle)) return 25
|
|
228
|
+
if (params.includes(needle)) return 5
|
|
229
|
+
return 0
|
|
230
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locate a craft CLI on this machine.
|
|
3
|
+
*
|
|
4
|
+
* Search order is explicit and reported, because "the tool exists but the
|
|
5
|
+
* binary was not found" is the single most likely failure a user hits:
|
|
6
|
+
* 1. an explicit path written into `catalog/binaries.json` at install time,
|
|
7
|
+
* 2. an explicit path in the plugin config,
|
|
8
|
+
* 3. the craft's own environment variable (`GRIDCRAFT_CLI`, …),
|
|
9
|
+
* 4. the documented search directories, resolved against the plugin package,
|
|
10
|
+
* the session cwd and this kernel (so the workspace `crafts-bin/` is found
|
|
11
|
+
* from wherever the plugin is loaded),
|
|
12
|
+
* 5. `PATH`.
|
|
13
|
+
* @module @dsh-artcraft/craft-kit/discover
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { accessSync, constants, readFileSync, statSync } from 'node:fs'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
19
|
+
import { execFileSync } from 'node:child_process'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
/** True when `path` is an executable regular file (or a symlink to one). */
|
|
24
|
+
export function isExecutable(path ) {
|
|
25
|
+
try {
|
|
26
|
+
if (!statSync(path).isFile()) return false
|
|
27
|
+
accessSync(path, constants.X_OK)
|
|
28
|
+
return true
|
|
29
|
+
} catch {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Every directory on `PATH`, in order. */
|
|
35
|
+
function pathDirs() {
|
|
36
|
+
return (process.env.PATH ?? '').split(delimiter).filter(entry => entry.length > 0)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Directory holding this kernel (`<workspace>/dsh-plugins/craft-kit/src`). */
|
|
40
|
+
const kernelDir = dirname(fileURLToPath(import.meta.url))
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Where `scripts/install-craft.mjs` puts downloaded CLIs.
|
|
44
|
+
*
|
|
45
|
+
* This is the location that works for an installed plugin: the package may sit
|
|
46
|
+
* in a profile's `node_modules` with no workspace around it, so the binary is
|
|
47
|
+
* cached under the harness home instead. `DSH_ARTCRAFT_BIN_DIR` overrides it.
|
|
48
|
+
*/
|
|
49
|
+
function cacheDirs() {
|
|
50
|
+
const explicit = process.env.DSH_ARTCRAFT_BIN_DIR
|
|
51
|
+
if (explicit !== undefined && explicit.length > 0) return [explicit]
|
|
52
|
+
const roots = [process.env.DSH_HOME, join(homedir(), '.dsh'), join(homedir(), '.dsh-artcraft')]
|
|
53
|
+
.filter((root) => typeof root === 'string' && root.length > 0)
|
|
54
|
+
return [...new Set(roots.map(root => join(root, 'crafts', 'bin')))]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Bases a relative search directory is resolved against.
|
|
59
|
+
*
|
|
60
|
+
* The plugin package root comes first: `<workspace>/dsh-plugins/plugin-x` and
|
|
61
|
+
* `<workspace>/crafts-bin` are siblings, so `../crafts-bin` finds the
|
|
62
|
+
* workspace's installed binaries no matter which directory the agent runs in.
|
|
63
|
+
* @param packageRoot - the calling plugin's package root, when it has one.
|
|
64
|
+
* @returns absolute bases, most specific first.
|
|
65
|
+
*/
|
|
66
|
+
function searchBases(packageRoot ) {
|
|
67
|
+
const bases = [
|
|
68
|
+
...packageRoot !== undefined ? [packageRoot, dirname(packageRoot)] : [],
|
|
69
|
+
process.cwd(),
|
|
70
|
+
// `<workspace>/dsh-plugins/craft-kit/src` → up three levels is the workspace.
|
|
71
|
+
resolve(kernelDir, '..', '..', '..'),
|
|
72
|
+
dirname(kernelDir),
|
|
73
|
+
]
|
|
74
|
+
return [...new Set(bases)]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Absolute path recorded by `scripts/link-into-profile.mjs`, when present. */
|
|
78
|
+
function recordedBinary(profile ) {
|
|
79
|
+
for (const base of searchBases()) {
|
|
80
|
+
const file = join(base, 'craft-kit', 'catalog', 'binaries.json')
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
83
|
+
if (typeof parsed !== 'object' || parsed === null) continue
|
|
84
|
+
const entry = (parsed )[profile.id]
|
|
85
|
+
if (typeof entry === 'string' && entry.length > 0) return { path: entry, source: 'craft-kit/catalog/binaries.json' }
|
|
86
|
+
} catch {
|
|
87
|
+
// No record at this base; keep looking.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Candidate absolute paths for one craft binary, in priority order, with provenance.
|
|
95
|
+
* @param profile - the craft to locate.
|
|
96
|
+
* @param configured - explicit path from plugin configuration, if any.
|
|
97
|
+
* @param packageRoot - the calling plugin's package root, when it has one.
|
|
98
|
+
* @returns candidates in the order they will be tested.
|
|
99
|
+
*/
|
|
100
|
+
export function candidates(profile , configured , packageRoot ) {
|
|
101
|
+
const out = []
|
|
102
|
+
if (configured !== undefined && configured.length > 0) out.push({ path: configured, source: 'config.path' })
|
|
103
|
+
const fromEnv = process.env[profile.envVar]
|
|
104
|
+
if (fromEnv !== undefined && fromEnv.length > 0) out.push({ path: fromEnv, source: `env:${profile.envVar}` })
|
|
105
|
+
for (const dir of cacheDirs()) out.push({ path: join(dir, platformBinaryName(profile)), source: `cache:${dir}` })
|
|
106
|
+
const recorded = recordedBinary(profile)
|
|
107
|
+
if (recorded !== undefined) out.push(recorded)
|
|
108
|
+
const seen = new Set ()
|
|
109
|
+
for (const base of searchBases(packageRoot)) {
|
|
110
|
+
for (const dir of profile.searchDirs) {
|
|
111
|
+
const resolved = isAbsolute(dir) ? dir : resolve(base, dir)
|
|
112
|
+
if (seen.has(resolved)) continue
|
|
113
|
+
seen.add(resolved)
|
|
114
|
+
out.push({ path: join(resolved, profile.bin), source: `search-dir:${resolved}` })
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const dir of pathDirs()) {
|
|
118
|
+
out.push({ path: join(dir, profile.bin), source: 'PATH' })
|
|
119
|
+
if (process.platform === 'win32') out.push({ path: join(dir, `${profile.bin}.exe`), source: 'PATH' })
|
|
120
|
+
}
|
|
121
|
+
return out
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The CLI's executable name on this platform (`-cli.exe` on Windows). */
|
|
125
|
+
export function platformBinaryName(profile ) {
|
|
126
|
+
return process.platform === 'win32' ? `${profile.bin}.exe` : profile.bin
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve the craft CLI for `profile`.
|
|
131
|
+
* @param profile - the craft to locate.
|
|
132
|
+
* @param configured - explicit path from plugin configuration, if any.
|
|
133
|
+
* @param packageRoot - the calling plugin's package root, when it has one.
|
|
134
|
+
* @returns the first executable candidate and its provenance, or `undefined`.
|
|
135
|
+
*/
|
|
136
|
+
export function discoverCraft(profile , configured , packageRoot ) {
|
|
137
|
+
for (const candidate of candidates(profile, configured, packageRoot)) {
|
|
138
|
+
if (isExecutable(candidate.path)) return { path: candidate.path, source: candidate.source }
|
|
139
|
+
}
|
|
140
|
+
return undefined
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Cached `--version` output per resolved path; only successes are stored. */
|
|
144
|
+
const versionCache = new Map ()
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Read a craft binary's version, tolerating CLIs that spell the flag
|
|
148
|
+
* differently (`--version` and `version` are both in use).
|
|
149
|
+
* @param path - absolute path to the CLI.
|
|
150
|
+
* @param timeoutMs - hard cap on the probe; a slow binary yields `undefined`.
|
|
151
|
+
* @returns the first non-empty line of version output, or `undefined`.
|
|
152
|
+
*/
|
|
153
|
+
export function craftVersion(path , timeoutMs = 5000) {
|
|
154
|
+
if (versionCache.has(path)) return versionCache.get(path)
|
|
155
|
+
let version
|
|
156
|
+
for (const flag of ['--version', 'version']) {
|
|
157
|
+
try {
|
|
158
|
+
const out = execFileSync(path, [flag], { encoding: 'utf8', timeout: timeoutMs, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
159
|
+
const line = out.split('\n').map(part => part.trim()).find(part => part.length > 0)
|
|
160
|
+
if (line !== undefined) { version = line; break }
|
|
161
|
+
} catch {
|
|
162
|
+
// Try the next spelling; a missing flag is expected on some crafts.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Only a successful probe is cached. A binary that is mid-install, or that
|
|
166
|
+
// had not been written when the previous call ran, must be retried rather
|
|
167
|
+
// than remembered as version-less for the rest of the session.
|
|
168
|
+
if (version !== undefined) versionCache.set(path, version)
|
|
169
|
+
return version
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A ready-to-print diagnostic for a craft binary that could not be found.
|
|
174
|
+
* @param profile - the craft that was searched for.
|
|
175
|
+
* @param configured - explicit path from plugin configuration, if any.
|
|
176
|
+
* @param packageRoot - the calling plugin's package root, when it has one.
|
|
177
|
+
* @returns multi-line guidance naming every searched location.
|
|
178
|
+
*/
|
|
179
|
+
export function missingBinaryMessage(profile , configured , packageRoot ) {
|
|
180
|
+
const tried = candidates(profile, configured, packageRoot)
|
|
181
|
+
const lines = [
|
|
182
|
+
`${profile.product}: '${profile.bin}' was not found, so the ${profile.id} tools cannot run.`,
|
|
183
|
+
`Searched, in order:`,
|
|
184
|
+
...tried.slice(0, 10).map(entry => ` - ${entry.path} (${entry.source})`),
|
|
185
|
+
]
|
|
186
|
+
lines.push(
|
|
187
|
+
`Fix it by any of:`,
|
|
188
|
+
` - run \`node scripts/install-craft.mjs ${profile.id}\` (macOS, Windows and Linux)`,
|
|
189
|
+
` - set ${profile.envVar}=/absolute/path/to/${profile.bin}`,
|
|
190
|
+
` - download the CLI from https://github.com/storytold/${profile.id}/releases/latest and put it on PATH`,
|
|
191
|
+
` - pass { "path": "/absolute/path/to/${profile.bin}" } in this plugin's config`,
|
|
192
|
+
)
|
|
193
|
+
return lines.join('\n')
|
|
194
|
+
}
|