Skip to content

Commit e6979e0

Browse files
committed
fix(cli): version and updater checks
- Added support for versioning output files in the build process, ensuring that the correct version is written to bundled files. - Introduced a new utility function to replace bundled constants in the output files, improving maintainability. - Updated the installation script to utilize the embedded build SHA and version, enhancing the manifest generation. - Refactored error handling in the update check to ensure proper feedback when the SHA is missing. Signed-off-by: Cory Rylan <crylan@nvidia.com>
1 parent 29e3795 commit e6979e0

31 files changed

Lines changed: 667 additions & 214 deletions

‎.github/workflows/ci.yml‎

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ jobs:
2222
# On main, checkout credentials must match the release token used by that dry-run.
2323
token: ${{ github.ref == 'refs/heads/main' && secrets.RELEASE_TOKEN || github.token }}
2424
- uses: ./.github/actions/setup-ci
25-
# - uses: google/wireit@setup-github-actions-caching/v2
2625
- name: Run CI checks
2726
env:
2827
ELEMENTS_PAGES_BASE_URL: ${{vars.ELEMENTS_PAGES_BASE_URL}}
@@ -77,7 +76,6 @@ jobs:
7776
fetch-depth: 0
7877
lfs: true
7978
- uses: ./.github/actions/setup-ci
80-
# - uses: google/wireit@setup-github-actions-caching/v2
8179
- name: Run Lighthouse tests
8280
run: WIREIT_FAILURES=kill pnpm run lighthouse && node ./projects/internals/ci/cache-validate.js lighthouse && node ./projects/internals/ci/metrics.lighthouse.js
8381
- name: Write lighthouse job summary
@@ -120,6 +118,51 @@ jobs:
120118
GITHUB_TOKEN: ${{secrets.RELEASE_TOKEN}}
121119
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
122120
run: WIREIT_PARALLEL=1 WIREIT_LOGGER=metrics pnpm run release
121+
- name: Upload MCP server manifest
122+
uses: actions/upload-artifact@v7
123+
with:
124+
name: mcp-server
125+
retention-days: 1
126+
path: projects/cli/server.json
127+
128+
publish-mcp:
129+
needs: release
130+
runs-on: ubuntu-latest
131+
permissions:
132+
id-token: write
133+
concurrency: mcp-registry-publish
134+
steps:
135+
- name: Download MCP server manifest
136+
uses: actions/download-artifact@v8
137+
with:
138+
name: mcp-server
139+
path: projects/cli
140+
- name: Check MCP Registry
141+
id: registry
142+
run: |
143+
path=$(jq -r '[.name, .version] | map(@uri) | join("/versions/")' projects/cli/server.json)
144+
status=$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
145+
--retry 3 --retry-all-errors --connect-timeout 10 --max-time 30 \
146+
"https://registry.modelcontextprotocol.io/v0.1/servers/$path")
147+
148+
case "$status" in
149+
200) publish=false ;;
150+
404) publish=true ;;
151+
*)
152+
echo "Unexpected MCP Registry response: HTTP $status" >&2
153+
exit 1
154+
;;
155+
esac
156+
echo "publish=$publish" >> "$GITHUB_OUTPUT"
157+
- name: Publish to MCP Registry
158+
if: steps.registry.outputs.publish == 'true'
159+
run: |
160+
curl --fail --location --show-error \
161+
"https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" \
162+
| tar xz mcp-publisher
163+
./mcp-publisher validate projects/cli/server.json
164+
./mcp-publisher login github-oidc
165+
./mcp-publisher publish projects/cli/server.json
123166
124167
deploy-pages:
125168
needs: ci
@@ -131,9 +174,7 @@ jobs:
131174
environment:
132175
name: github-pages
133176
url: ${{steps.deployment.outputs.page_url}}
134-
concurrency:
135-
group: pages
136-
cancel-in-progress: false
177+
concurrency: pages
137178
steps:
138179
- name: Deploy to GitHub Pages
139180
id: deployment

‎projects/cli/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ npm install -g @nvidia-elements/cli
5252
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
5353
|`nve`| Show About and help output. |
5454
|`nve api.list [format]`| Get a list of all available Elements (`nve-*`) APIs and components. |
55-
|`nve api.get <names> [format]`| Get documentation for known components or attributes by name (`nve-*`).|
55+
|`nve api.get <names..> [--format <format>]`| Get documentation for one to five known components or attributes (`nve-*`). |
5656
|`nve api.template.validate <template>`| Check HTML templates using Elements APIs and components (`nve-*`). |
5757
|`nve api.imports.get <template>`| Get ESM imports for an HTML template using Elements APIs (`nve-*`). |
5858
|`nve api.tokens.list [format] [query]`| Get available semantic CSS custom properties and design tokens for theming. |

‎projects/cli/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"dist/**/*.js"
3636
],
3737
"scripts": {
38-
"dev": "pnpm run nve:install && npx @modelcontextprotocol/inspector@0.22.0 node ./dist/index.js mcp",
38+
"dev": "pnpm run cli:install && npx @modelcontextprotocol/inspector@0.22.0 node ./dist/index.js mcp",
3939
"ci": "wireit",
4040
"build": "wireit",
4141
"lint": "wireit",

‎projects/cli/scripts/write-install-manifest.mjs‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import{createHash}from'node:crypto';
5-
import{existsSync,readFileSync,writeFileSync}from'node:fs';
5+
import{readFileSync,writeFileSync}from'node:fs';
66
import{resolve}from'node:path';
77

88
constplatformBinaries=[
@@ -14,14 +14,22 @@ const platformBinaries = [
1414
];
1515

1616
constmanifestPath=resolve('dist/manifest.json');
17-
constexistingManifest=existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath,'utf-8')) : {};
18-
constindexContent=readFileSync(resolve('dist/index.js'));
19-
constsha=existingManifest.sha||createHash('sha256').update(indexContent).digest('hex').slice(0,12);
17+
constindexContent=readFileSync(resolve('dist/index.js'),'utf-8');
18+
constsha=getBundledConstant(indexContent,'BUILD_SHA');
19+
constversion=getBundledConstant(indexContent,'VERSION');
2020
constplatforms=Object.fromEntries(
2121
platformBinaries.map(({ key, filename, source })=>{
2222
constcontent=readFileSync(resolve('dist',source));
2323
return[key,{ filename,checksum: createHash('sha256').update(content).digest('hex')}];
2424
})
2525
);
2626

27-
writeFileSync(manifestPath,`${JSON.stringify({ sha, platforms },null,2)}\n`);
27+
writeFileSync(manifestPath,`${JSON.stringify({ sha, version, platforms },null,2)}\n`);
28+
29+
functiongetBundledConstant(content,name){
30+
constvalue=content.match(newRegExp(`(?:var|const|let)\\s+${name}\\s*=\\s*["']([^"']+)["']`))?.[1];
31+
if(!value){
32+
thrownewError(`Missing bundled ${name} constant`);
33+
}
34+
returnvalue;
35+
}

‎projects/cli/server.json‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3+
"name": "io.github.nvidia/elements",
4+
"title": "NVIDIA Elements",
5+
"description": "NVIDIA Elements UI design system and agent tooling for AI/ML factories, robotics, and autonomous vehicles.",
6+
"websiteUrl": "https://nvidia.github.io/elements/docs/mcp/",
7+
"repository": {
8+
"url": "https://github.com/NVIDIA/elements",
9+
"source": "github",
10+
"subfolder": "projects/cli"
11+
},
12+
"version": "2.1.0",
13+
"packages": [
14+
{
15+
"registryType": "npm",
16+
"registryBaseUrl": "https://registry.npmjs.org",
17+
"identifier": "@nvidia-elements/cli",
18+
"version": "2.1.0",
19+
"runtimeHint": "npx",
20+
"packageArguments": [
21+
{
22+
"type": "positional",
23+
"value": "mcp"
24+
}
25+
],
26+
"transport": {
27+
"type": "stdio"
28+
}
29+
}
30+
]
31+
}

‎projects/cli/src/index.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ describe('index', () => {
3737
expect(output).toContain('nve examples.get <id> [format]');
3838
});
3939

40+
it('should provide api.get with variadic names',()=>{
41+
expect(output).toContain('nve api.get [--format] <names..>');
42+
expect(output).not.toContain('nve api.get <names> [format]');
43+
});
44+
4045
it('should provide skills.list',()=>{
4146
expect(output).toContain('nve skills.list [format]');
4247
});
@@ -120,6 +125,56 @@ describe('index', () => {
120125
expect(combined).toContain('nve-foo');
121126
expect(combined).toContain('nve-bar');
122127
});
128+
129+
it('should pass space-separated API names as one array argument',()=>{
130+
constresult=spawnSync(process.execPath,['dist/index.js','api.get','nve-card','nve-input'],{
131+
timeout: 10000,
132+
encoding: 'utf-8',
133+
input: '',
134+
env: { ...process.env,CI: 'true'}
135+
});
136+
137+
expect(result.status).toBe(0);
138+
expect(result.stderr).toBe('');
139+
expect(result.stdout).toContain('nve-card');
140+
expect(result.stdout).toContain('nve-input');
141+
});
142+
143+
it('should accept an explicit format option after space-separated API names',()=>{
144+
constresult=spawnSync(
145+
process.execPath,
146+
['dist/index.js','api.get','nve-card','nve-input','--format','json'],
147+
{
148+
timeout: 10000,
149+
encoding: 'utf-8',
150+
input: '',
151+
env: { ...process.env,CI: 'true'}
152+
}
153+
);
154+
155+
expect(result.status).toBe(0);
156+
expect(result.stderr).toBe('');
157+
expect(JSON.parse(result.stdout)).toEqual([
158+
expect.objectContaining({name: 'nve-card'}),
159+
expect.objectContaining({name: 'nve-input'})
160+
]);
161+
});
162+
163+
it('should reject array arguments that exceed the schema limit',()=>{
164+
constresult=spawnSync(
165+
process.execPath,
166+
['dist/index.js','api.get','nve-card','nve-input','nve-button','nve-badge','nve-alert','nve-link'],
167+
{
168+
timeout: 10000,
169+
encoding: 'utf-8',
170+
input: '',
171+
env: { ...process.env,CI: 'true'}
172+
}
173+
);
174+
175+
expect(result.status).toBe(1);
176+
expect(result.stderr).toContain('api.get accepts at most 5 names.');
177+
});
123178
});
124179

125180
describe('tool errors',()=>{

‎projects/cli/src/index.ts‎

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ process.env.ELEMENTS_ENV = 'cli';
99
importyargsfrom'yargs';
1010
import{hideBin}from'yargs/helpers';
1111
import{performance}from'perf_hooks';
12-
import{typeManagedToolMethod,tools,ToolSupport,typeSchema,isDebug,MAX_CONTEXT_TOKENS}from'@internals/tools';
12+
import{typeManagedToolMethod,tools,ToolSupport,typeSchema}from'@internals/tools';
1313
import{installNve}from'./install.js';
14-
import{banner,colors,getArgValue,progressBar,renderResult,runAsyncTool}from'./utils.js';
14+
import{banner,colors,exitWithCompleteToolResult,exitWithToolError,getArgValue,runAsyncTool}from'./utils.js';
1515
import{notifyIfUpdateAvailable}from'./update.js';
1616

1717
exportconstVERSION='0.0.0';
@@ -46,11 +46,6 @@ yargsInstance.middleware(argv => {
4646
}
4747
});
4848

49-
asyncfunctionexitWithToolError(result: unknown,message: string|undefined): Promise<never>{
50-
console.error(result===undefined ? colors.error(message??'unknown error') : awaitrenderResult(result));
51-
process.exit(1);
52-
}
53-
5449
yargsInstance.command(
5550
'install [source]',
5651
false,
@@ -73,10 +68,10 @@ yargsInstance.command(
7368
async()=>{
7469
if(process.argv.includes('--upgrade')){
7570
constupgradeTool=tools.find(tool=>tool.metadata.command==='cli.upgrade')asManagedToolMethod<unknown>;
76-
const{ result, status, message }=awaitrunAsyncTool({},upgradeTool);
71+
console.log(colors.info('Upgrading Elements CLI...'));
72+
const{ result, status, message }=awaitrunAsyncTool({},upgradeTool,{interactiveProgress: false});
7773
if(status==='complete'){
78-
awaitrenderResult(result);
79-
process.exit(0);
74+
awaitexitWithCompleteToolResult({ result });
8075
}else{
8176
awaitexitWithToolError(result,message);
8277
}
@@ -100,9 +95,12 @@ tools
10095
constoptionalArgs=Object.keys(properties??{}).filter(
10196
key=>!required?.includes(key)||properties?.[key]?.default
10297
);
98+
consthasVariadicArg=requiredArgs.some(key=>properties?.[key]?.type==='array');
99+
constpositionalArgs=requiredArgs.map(key=>(properties?.[key]?.type==='array' ? `<${key}..>` : `<${key}>`));
100+
constoptionArgs=optionalArgs.map(key=>`[${hasVariadicArg ? '--' : ''}${key}]`);
101+
constcommandArgs=hasVariadicArg ? [...optionArgs, ...positionalArgs] : [...positionalArgs, ...optionArgs];
103102

104-
constcommand=
105-
`${tool.metadata.command}${[...requiredArgs.map(key=>`<${key}>`), ...optionalArgs.map(key=>`[${key}]`)].join(' ')}`.trim();
103+
constcommand=`${tool.metadata.command}${commandArgs.join(' ')}`.trim();
106104

107105
yargsInstance.command(
108106
command,
@@ -113,7 +111,8 @@ tools
113111

114112
constargOptions=(prop: Schema)=>({
115113
describe: prop.description,
116-
type: prop.typeas'string'|'number'|'boolean',
114+
type: (prop.type==='array' ? 'string' : prop.type)as'string'|'number'|'boolean',
115+
...(prop.type==='array' ? {array: true} : {}),
117116
choices: prop.enum??undefined,
118117
default: prop.default
119118
});
@@ -128,17 +127,13 @@ tools
128127
constend=performance.now();
129128

130129
if(status==='complete'){
131-
letformattedResult=awaitrenderResult(result);
132-
if(isDebug()){
133-
consttokens=formattedResult.length/4;
134-
constpct=(tokens/MAX_CONTEXT_TOKENS)*100;
135-
formattedResult+=`[debug]\n[command]: ${tool.metadata.command}`;
136-
formattedResult+=`\n[execution time]: ${((end-start)/1000).toFixed(2)} seconds`;
137-
formattedResult+=`\n[token usage]: ${progressBar(pct)}${tokens.toLocaleString()} / ${MAX_CONTEXT_TOKENS.toLocaleString()} (${(100-pct).toFixed(1)}% remaining)`;
138-
}
139-
console.log(formattedResult);
140-
awaitnotifyIfUpdateAvailable(BUILD_SHA);
141-
process.exit(0);
130+
awaitexitWithCompleteToolResult({
131+
result,
132+
tool,
133+
start,
134+
end,
135+
notifyUpdate: ()=>notifyIfUpdateAvailable(BUILD_SHA)
136+
});
142137
}else{
143138
awaitexitWithToolError(result,message);
144139
}
@@ -155,13 +150,31 @@ tools
155150
constpropertySchema=properties?.[argName]??{};
156151
constv=awaitgetArgValue(argName,propertySchema);
157152
argv[argName]=v;
158-
}elseif(properties?.[argName]?.type==='array'&&typeofargv[argName]==='string'){
159-
argv[argName]=(argv[argName]asstring)
160-
.split(',')
161-
.map(s=>s.trim())
162-
.filter(Boolean);
163153
}
164154
}
155+
156+
Object.entries(properties??{})
157+
.filter(([,property])=>property.type==='array')
158+
.forEach(([argName,property])=>{
159+
if(argv[argName]===undefined)return;
160+
constvalues=(Array.isArray(argv[argName]) ? argv[argName] : [argv[argName]])
161+
.flatMap(value=>(typeofvalue==='string' ? value.split(',') : []))
162+
.map(value=>value.trim())
163+
.filter(Boolean);
164+
if(property.minItems!==undefined&&values.length<property.minItems){
165+
console.error(
166+
colors.error(`${tool.metadata.command} accepts at least ${property.minItems}${argName}.`)
167+
);
168+
process.exit(1);
169+
}
170+
if(property.maxItems!==undefined&&values.length>property.maxItems){
171+
console.error(
172+
colors.error(`${tool.metadata.command} accepts at most ${property.maxItems}${argName}.`)
173+
);
174+
process.exit(1);
175+
}
176+
argv[argName]=values;
177+
});
165178
}
166179
]
167180
);

0 commit comments

Comments
 (0)