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
const platformBinaries = [
@@ -14,14 +14,22 @@ const platformBinaries = [
1414
];
1515

1616
const manifestPath = resolve('dist/manifest.json');
17-
const existingManifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf-8')) : {};
18-
const indexContent = readFileSync(resolve('dist/index.js'));
19-
const sha = existingManifest.sha || createHash('sha256').update(indexContent).digest('hex').slice(0, 12);
17+
const indexContent = readFileSync(resolve('dist/index.js'), 'utf-8');
18+
const sha = getBundledConstant(indexContent, 'BUILD_SHA');
19+
const version = getBundledConstant(indexContent, 'VERSION');
2020
const platforms = Object.fromEntries(
2121
platformBinaries.map(({ key, filename, source }) => {
2222
const content = 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+
function getBundledConstant(content, name) {
30+
const value = content.match(new RegExp(`(?:var|const|let)\\s+${name}\\s*=\\s*["']([^"']+)["']`))?.[1];
31+
if (!value) {
32+
throw new Error(`Missing bundled ${name} constant`);
33+
}
34+
return value;
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+
const result = 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+
const result = 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+
const result = 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
import yargs from 'yargs';
1010
import { hideBin } from 'yargs/helpers';
1111
import { performance } from 'perf_hooks';
12-
import { type ManagedToolMethod, tools, ToolSupport, type Schema, isDebug, MAX_CONTEXT_TOKENS } from '@internals/tools';
12+
import { type ManagedToolMethod, tools, ToolSupport, type Schema } 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
export const VERSION = '0.0.0';
@@ -46,11 +46,6 @@ yargsInstance.middleware(argv => {
4646
}
4747
});
4848

49-
async function exitWithToolError(result: unknown, message: string | undefined): Promise<never> {
50-
console.error(result === undefined ? colors.error(message ?? 'unknown error') : await renderResult(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
const upgradeTool = tools.find(tool => tool.metadata.command === 'cli.upgrade') as ManagedToolMethod<unknown>;
76-
const { result, status, message } = await runAsyncTool({}, upgradeTool);
71+
console.log(colors.info('Upgrading Elements CLI...'));
72+
const { result, status, message } = await runAsyncTool({}, upgradeTool, { interactiveProgress: false });
7773
if (status === 'complete') {
78-
await renderResult(result);
79-
process.exit(0);
74+
await exitWithCompleteToolResult({ result });
8075
} else {
8176
await exitWithToolError(result, message);
8277
}
@@ -100,9 +95,12 @@ tools
10095
const optionalArgs = Object.keys(properties ?? {}).filter(
10196
key => !required?.includes(key) || properties?.[key]?.default
10297
);
98+
const hasVariadicArg = requiredArgs.some(key => properties?.[key]?.type === 'array');
99+
const positionalArgs = requiredArgs.map(key => (properties?.[key]?.type === 'array' ? `<${key}..>` : `<${key}>`));
100+
const optionArgs = optionalArgs.map(key => `[${hasVariadicArg ? '--' : ''}${key}]`);
101+
const commandArgs = hasVariadicArg ? [...optionArgs, ...positionalArgs] : [...positionalArgs, ...optionArgs];
103102

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

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

114112
const argOptions = (prop: Schema) => ({
115113
describe: prop.description,
116-
type: prop.type as '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
const end = performance.now();
129128

130129
if (status === 'complete') {
131-
let formattedResult = await renderResult(result);
132-
if (isDebug()) {
133-
const tokens = formattedResult.length / 4;
134-
const pct = (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-
await notifyIfUpdateAvailable(BUILD_SHA);
141-
process.exit(0);
130+
await exitWithCompleteToolResult({
131+
result,
132+
tool,
133+
start,
134+
end,
135+
notifyUpdate: () => notifyIfUpdateAvailable(BUILD_SHA)
136+
});
142137
} else {
143138
await exitWithToolError(result, message);
144139
}
@@ -155,13 +150,31 @@ tools
155150
const propertySchema = properties?.[argName] ?? {};
156151
const v = await getArgValue(argName, propertySchema);
157152
argv[argName] = v;
158-
} else if (properties?.[argName]?.type === 'array' && typeof argv[argName] === 'string') {
159-
argv[argName] = (argv[argName] as string)
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+
const values = (Array.isArray(argv[argName]) ? argv[argName] : [argv[argName]])
161+
.flatMap(value => (typeof value === '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)