@specforge/cli 0.2.4 → 0.2.6
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/dist/cli/__tests__/update-check.test.d.ts +2 -0
- package/dist/cli/__tests__/update-check.test.d.ts.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/update-check.d.ts +16 -0
- package/dist/cli/update-check.d.ts.map +1 -0
- package/dist/cli/update-check.js +95 -0
- package/dist/cli/update-check.js.map +1 -0
- package/dist/transport/__tests__/remote.test.d.ts +2 -0
- package/dist/transport/__tests__/remote.test.d.ts.map +1 -0
- package/dist/transport/remote.d.ts.map +1 -1
- package/dist/transport/remote.js +23 -4
- package/dist/transport/remote.js.map +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"update-check.test.d.ts","sourceRoot":"","sources":["../../../src/cli/__tests__/update-check.test.ts"],"names":[],"mappings":""}
|
package/dist/cli/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA2BpC;;GAEG;AACH,QAAA,MAAM,WAAW,EAAE,MAAoB,CAAC;AAqKxC;;GAEG;AACH,QAAA,MAAM,OAAO,SAAkB,CAAC;AAShC;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,IAAI,GAAE,MAAM,EAAiB,GAAG,IAAI,CAkB1D;AAED;;GAEG;AACH,OAAO,EAAE,OAAO,EAAE,CAAC;AAEnB;;GAEG;AACH,OAAO,EAAE,WAAW,EAAE,CAAC"}
|
package/dist/cli/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { resolveConfig } from "./config/loader.js";
|
|
|
6
6
|
import { colors } from "./ui/index.js";
|
|
7
7
|
import { printBanner } from "./ui/banner.js";
|
|
8
8
|
import { CHANNEL } from "../channel.js";
|
|
9
|
+
import { notifyUpdate } from "./update-check.js";
|
|
9
10
|
const BANNER_SKIP_COMMANDS = /* @__PURE__ */ new Set(["init", "login", "serve"]);
|
|
10
11
|
const require2 = createRequire(import.meta.url);
|
|
11
12
|
const pkg = require2("../../package.json");
|
|
@@ -127,6 +128,7 @@ registerServeCommand(program);
|
|
|
127
128
|
registerCommands(program);
|
|
128
129
|
configureHelp(program);
|
|
129
130
|
function runCLI(argv = process.argv) {
|
|
131
|
+
notifyUpdate({ current: CLI_VERSION, isJson: argv.includes("--json") });
|
|
130
132
|
try {
|
|
131
133
|
program.parse(argv);
|
|
132
134
|
} catch (error) {
|
package/dist/cli/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/cli/index.ts"],"sourcesContent":["/**\n * SpecForge MCP CLI Entry Point\n *\n * Main CLI application using Commander.js.\n * Sets up command routing, middleware, and global options.\n */\n\nimport { createRequire } from 'node:module';\nimport { Command } from 'commander';\nimport { authGuardHook } from './middleware/auth-guard.js';\nimport { withErrorHandler, debugLog, handleError } from './middleware/error-handler.js';\nimport { resolveConfig } from './config/loader.js';\nimport { colors } from './ui/index.js';\nimport { printBanner } from './ui/banner.js';\nimport { CHANNEL } from '../channel.js';\n\nconst BANNER_SKIP_COMMANDS = new Set(['init', 'login', 'serve']);\n\nconst require = createRequire(import.meta.url);\nconst pkg = require('../../package.json');\nimport {\n registerLoginCommand,\n registerInitCommand,\n registerConfigureCommand,\n registerDoctorCommand,\n registerStatusCommand,\n registerSwitchCommand,\n registerFeedbackCommand,\n registerMembersCommand,\n registerInvitationsCommand,\n} from './commands/index.js';\nimport { registerDebugCommands } from './commands/debug/index.js';\nimport { registerScaffoldCommand } from './commands/scaffold/index.js';\n\n/**\n * CLI version - read from package.json\n */\nconst CLI_VERSION: string = pkg.version;\n\n/**\n * CLI name for display — varies per build channel (`specforge` vs.\n * `specforge-canary`). The `SpecForge` brand text in banners stays fixed.\n */\nconst CLI_NAME = CHANNEL.name;\n\n/**\n * Create and configure the CLI program\n */\nfunction createProgram(): Command {\n const program = new Command();\n\n // Basic program info\n program\n .name(CLI_NAME)\n .description('SpecForge MCP CLI - Configure and interact with SpecForge')\n .version(CLI_VERSION, '-v, --version', 'Display version number');\n\n // Global options\n program\n .option('--debug', 'Enable debug output')\n .option('--no-color', 'Disable colored output')\n .option('--json', 'Output in JSON format');\n\n // Pre-action hook for auth guard\n program.hook('preAction', (thisCommand, actionCommand) => {\n const commandName = actionCommand.name();\n\n // Set debug mode from flag\n if (thisCommand.opts().debug) {\n process.env.SPECFORGE_DEBUG = 'true';\n }\n\n // Disable colors if requested\n if (thisCommand.opts().noColor) {\n // chalk.level = 0 is handled in colors module\n process.env.NO_COLOR = '1';\n }\n\n // Compact brand header for human-facing commands\n const jsonOutput = !!thisCommand.opts().json;\n const programState = thisCommand as Command & { _bannerPrinted?: boolean };\n if (\n !programState._bannerPrinted &&\n process.stdout.isTTY &&\n !jsonOutput &&\n !BANNER_SKIP_COMMANDS.has(commandName)\n ) {\n printBanner('compact');\n programState._bannerPrinted = true;\n }\n\n debugLog(`Running command: ${commandName}`);\n debugLog('Resolved config:', resolveConfig());\n\n // Run auth guard\n authGuardHook({ name: () => commandName });\n });\n\n return program;\n}\n\n/**\n * Register the default serve command (MCP server mode)\n *\n * This is the hidden default when no subcommand is provided.\n * Maintains backward compatibility with direct MCP server usage.\n */\nfunction registerServeCommand(program: Command): void {\n program\n .command('serve', { isDefault: true, hidden: true })\n .description('Start MCP server (default)')\n .action(withErrorHandler(async () => {\n debugLog('Starting MCP server');\n\n // Dynamic import to avoid loading server code for CLI commands\n const { loadConfig, validateConfig } = await import('../config/index.js');\n const { createServer, startServer } = await import('../server.js');\n\n // Load and validate config\n const config = await loadConfig();\n validateConfig(config);\n // Honor the resolved MCP output format (env > project > global > default).\n config.mcpOutputFormat = resolveConfig().mcpOutputFormat;\n\n // Create and start server\n const server = await createServer(config);\n await startServer(server);\n }));\n}\n\n/**\n * Register all CLI commands\n */\nfunction registerCommands(program: Command): void {\n registerLoginCommand(program);\n registerInitCommand(program);\n registerConfigureCommand(program);\n registerDoctorCommand(program);\n registerStatusCommand(program);\n registerSwitchCommand(program);\n registerDebugCommands(program);\n registerScaffoldCommand(program);\n registerFeedbackCommand(program);\n registerMembersCommand(program);\n registerInvitationsCommand(program);\n}\n\n/**\n * Configure help output styling\n */\nfunction configureHelp(program: Command): void {\n program.configureHelp({\n sortSubcommands: false,\n subcommandTerm: (cmd) => cmd.name(),\n });\n\n // Custom help with organized command groups\n program.addHelpText('after', `\n${colors.muted('Command Groups:')}\n\n ${colors.bold('Authentication')}\n login Authenticate with SpecForge API\n configure Read/write configuration\n\n ${colors.bold('Setup')}\n init Initialize SpecForge in a project\n scaffold Generate skills, agents, and commands\n\n ${colors.bold('Diagnostics')}\n doctor Health check\n status Project status and session info\n\n ${colors.bold('Context')}\n switch Switch active project or specification\n\n ${colors.bold('Collaboration')}\n members Manage project members\n invitations Manage invitations\n\n ${colors.bold('Debug')}\n debug call Direct MCP tool dispatch\n debug tools List available MCP tools\n debug test Connection test\n debug whoami Identity and config info\n\n ${colors.bold('Info')}\n feedback List and submit feedback\n\n${colors.muted('Examples:')}\n $ ${CLI_NAME} login # Authenticate with API key\n $ ${CLI_NAME} init # Initialize in current directory\n $ ${CLI_NAME} status # Show current status\n $ ${CLI_NAME} switch <id> # Switch active project/spec\n\n${colors.muted('Documentation:')}\n ${colors.primary('https://docs.specforge.com/cli')}\n\n${colors.muted('Report issues:')}\n ${colors.primary('https://github.com/specforge/mcp/issues')}\n`);\n}\n\n/**\n * The main CLI program instance\n */\nconst program = createProgram();\n\n// Register commands\nregisterServeCommand(program);\nregisterCommands(program);\n\n// Configure help\nconfigureHelp(program);\n\n/**\n * Run the CLI with given arguments\n *\n * @param argv - Command line arguments (defaults to process.argv)\n */\nexport function runCLI(argv: string[] = process.argv): void {\n try {\n program.parse(argv);\n } catch (error) {\n // Commander handles action-handler errors via `withErrorHandler`, but\n // anything thrown from a `preAction` hook (e.g. `resolveConfig()`\n // surfacing a `LEGACY_CONFIG_DETECTED` ConfigError for a project dir\n // with a legacy `.specforge.json`) reaches here. Use the shared\n // formatter so the user sees the message + hint instead of a silent\n // exit. Keep `debugLog` for stack-trace-under-debug ergonomics.\n debugLog('CLI parse error:', error);\n handleError(error);\n }\n}\n\n/**\n * Export program for testing and extension\n */\nexport { program };\n\n/**\n * Export version for external use\n */\nexport { CLI_VERSION };\n"],"mappings":"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","names":["require","program"]}
|
|
1
|
+
{"version":3,"sources":["../../src/cli/index.ts"],"sourcesContent":["/**\n * SpecForge MCP CLI Entry Point\n *\n * Main CLI application using Commander.js.\n * Sets up command routing, middleware, and global options.\n */\n\nimport { createRequire } from 'node:module';\nimport { Command } from 'commander';\nimport { authGuardHook } from './middleware/auth-guard.js';\nimport { withErrorHandler, debugLog, handleError } from './middleware/error-handler.js';\nimport { resolveConfig } from './config/loader.js';\nimport { colors } from './ui/index.js';\nimport { printBanner } from './ui/banner.js';\nimport { CHANNEL } from '../channel.js';\nimport { notifyUpdate } from './update-check.js';\n\nconst BANNER_SKIP_COMMANDS = new Set(['init', 'login', 'serve']);\n\nconst require = createRequire(import.meta.url);\nconst pkg = require('../../package.json');\nimport {\n registerLoginCommand,\n registerInitCommand,\n registerConfigureCommand,\n registerDoctorCommand,\n registerStatusCommand,\n registerSwitchCommand,\n registerFeedbackCommand,\n registerMembersCommand,\n registerInvitationsCommand,\n} from './commands/index.js';\nimport { registerDebugCommands } from './commands/debug/index.js';\nimport { registerScaffoldCommand } from './commands/scaffold/index.js';\n\n/**\n * CLI version - read from package.json\n */\nconst CLI_VERSION: string = pkg.version;\n\n/**\n * CLI name for display — varies per build channel (`specforge` vs.\n * `specforge-canary`). The `SpecForge` brand text in banners stays fixed.\n */\nconst CLI_NAME = CHANNEL.name;\n\n/**\n * Create and configure the CLI program\n */\nfunction createProgram(): Command {\n const program = new Command();\n\n // Basic program info\n program\n .name(CLI_NAME)\n .description('SpecForge MCP CLI - Configure and interact with SpecForge')\n .version(CLI_VERSION, '-v, --version', 'Display version number');\n\n // Global options\n program\n .option('--debug', 'Enable debug output')\n .option('--no-color', 'Disable colored output')\n .option('--json', 'Output in JSON format');\n\n // Pre-action hook for auth guard\n program.hook('preAction', (thisCommand, actionCommand) => {\n const commandName = actionCommand.name();\n\n // Set debug mode from flag\n if (thisCommand.opts().debug) {\n process.env.SPECFORGE_DEBUG = 'true';\n }\n\n // Disable colors if requested\n if (thisCommand.opts().noColor) {\n // chalk.level = 0 is handled in colors module\n process.env.NO_COLOR = '1';\n }\n\n // Compact brand header for human-facing commands\n const jsonOutput = !!thisCommand.opts().json;\n const programState = thisCommand as Command & { _bannerPrinted?: boolean };\n if (\n !programState._bannerPrinted &&\n process.stdout.isTTY &&\n !jsonOutput &&\n !BANNER_SKIP_COMMANDS.has(commandName)\n ) {\n printBanner('compact');\n programState._bannerPrinted = true;\n }\n\n debugLog(`Running command: ${commandName}`);\n debugLog('Resolved config:', resolveConfig());\n\n // Run auth guard\n authGuardHook({ name: () => commandName });\n });\n\n return program;\n}\n\n/**\n * Register the default serve command (MCP server mode)\n *\n * This is the hidden default when no subcommand is provided.\n * Maintains backward compatibility with direct MCP server usage.\n */\nfunction registerServeCommand(program: Command): void {\n program\n .command('serve', { isDefault: true, hidden: true })\n .description('Start MCP server (default)')\n .action(withErrorHandler(async () => {\n debugLog('Starting MCP server');\n\n // Dynamic import to avoid loading server code for CLI commands\n const { loadConfig, validateConfig } = await import('../config/index.js');\n const { createServer, startServer } = await import('../server.js');\n\n // Load and validate config\n const config = await loadConfig();\n validateConfig(config);\n // Honor the resolved MCP output format (env > project > global > default).\n config.mcpOutputFormat = resolveConfig().mcpOutputFormat;\n\n // Create and start server\n const server = await createServer(config);\n await startServer(server);\n }));\n}\n\n/**\n * Register all CLI commands\n */\nfunction registerCommands(program: Command): void {\n registerLoginCommand(program);\n registerInitCommand(program);\n registerConfigureCommand(program);\n registerDoctorCommand(program);\n registerStatusCommand(program);\n registerSwitchCommand(program);\n registerDebugCommands(program);\n registerScaffoldCommand(program);\n registerFeedbackCommand(program);\n registerMembersCommand(program);\n registerInvitationsCommand(program);\n}\n\n/**\n * Configure help output styling\n */\nfunction configureHelp(program: Command): void {\n program.configureHelp({\n sortSubcommands: false,\n subcommandTerm: (cmd) => cmd.name(),\n });\n\n // Custom help with organized command groups\n program.addHelpText('after', `\n${colors.muted('Command Groups:')}\n\n ${colors.bold('Authentication')}\n login Authenticate with SpecForge API\n configure Read/write configuration\n\n ${colors.bold('Setup')}\n init Initialize SpecForge in a project\n scaffold Generate skills, agents, and commands\n\n ${colors.bold('Diagnostics')}\n doctor Health check\n status Project status and session info\n\n ${colors.bold('Context')}\n switch Switch active project or specification\n\n ${colors.bold('Collaboration')}\n members Manage project members\n invitations Manage invitations\n\n ${colors.bold('Debug')}\n debug call Direct MCP tool dispatch\n debug tools List available MCP tools\n debug test Connection test\n debug whoami Identity and config info\n\n ${colors.bold('Info')}\n feedback List and submit feedback\n\n${colors.muted('Examples:')}\n $ ${CLI_NAME} login # Authenticate with API key\n $ ${CLI_NAME} init # Initialize in current directory\n $ ${CLI_NAME} status # Show current status\n $ ${CLI_NAME} switch <id> # Switch active project/spec\n\n${colors.muted('Documentation:')}\n ${colors.primary('https://docs.specforge.com/cli')}\n\n${colors.muted('Report issues:')}\n ${colors.primary('https://github.com/specforge/mcp/issues')}\n`);\n}\n\n/**\n * The main CLI program instance\n */\nconst program = createProgram();\n\n// Register commands\nregisterServeCommand(program);\nregisterCommands(program);\n\n// Configure help\nconfigureHelp(program);\n\n/**\n * Run the CLI with given arguments\n *\n * @param argv - Command line arguments (defaults to process.argv)\n */\nexport function runCLI(argv: string[] = process.argv): void {\n // Non-blocking update notice: prints a banner from a prior run's cached\n // registry check, and refreshes that cache in the background. Never awaited,\n // so it can't add latency or fail a command. Suppressed for --json / CI /\n // non-TTY / SPECFORGE_NO_UPDATE_CHECK.\n notifyUpdate({ current: CLI_VERSION, isJson: argv.includes('--json') });\n try {\n program.parse(argv);\n } catch (error) {\n // Commander handles action-handler errors via `withErrorHandler`, but\n // anything thrown from a `preAction` hook (e.g. `resolveConfig()`\n // surfacing a `LEGACY_CONFIG_DETECTED` ConfigError for a project dir\n // with a legacy `.specforge.json`) reaches here. Use the shared\n // formatter so the user sees the message + hint instead of a silent\n // exit. Keep `debugLog` for stack-trace-under-debug ergonomics.\n debugLog('CLI parse error:', error);\n handleError(error);\n }\n}\n\n/**\n * Export program for testing and extension\n */\nexport { program };\n\n/**\n * Export version for external use\n */\nexport { CLI_VERSION };\n"],"mappings":"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","names":["require","program"]}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compare dot-separated numeric versions. Returns 1 if a>b, -1 if a<b, 0 equal.
|
|
3
|
+
* Pre-release suffixes (e.g. `0.2.5-beta.1`) are stripped — a numeric compare is
|
|
4
|
+
* enough for the CLI's simple version line.
|
|
5
|
+
*/
|
|
6
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
7
|
+
/**
|
|
8
|
+
* Best-effort update check. Prints the banner synchronously from cache, then
|
|
9
|
+
* refreshes the cache in the background. Any failure is swallowed — a version
|
|
10
|
+
* check must never break a command.
|
|
11
|
+
*/
|
|
12
|
+
export declare function notifyUpdate(opts: {
|
|
13
|
+
current: string;
|
|
14
|
+
isJson: boolean;
|
|
15
|
+
}): void;
|
|
16
|
+
//# sourceMappingURL=update-check.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"update-check.d.ts","sourceRoot":"","sources":["../../src/cli/update-check.ts"],"names":[],"mappings":"AAuEA;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAU5D;AAyBD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAsB7E"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
5
|
+
import { CHANNEL } from "../channel.js";
|
|
6
|
+
const TTL_MS = 12 * 60 * 60 * 1e3;
|
|
7
|
+
const FETCH_TIMEOUT_MS = 3e3;
|
|
8
|
+
function cacheDir() {
|
|
9
|
+
return join(homedir(), CHANNEL.configDir);
|
|
10
|
+
}
|
|
11
|
+
function cachePath() {
|
|
12
|
+
return join(cacheDir(), "update-check.json");
|
|
13
|
+
}
|
|
14
|
+
function readCacheSync() {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(readFileSync(cachePath(), "utf8"));
|
|
17
|
+
if (typeof parsed.latest === "string" && typeof parsed.checkedAt === "number") {
|
|
18
|
+
return parsed;
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
async function writeCache(data) {
|
|
25
|
+
try {
|
|
26
|
+
await mkdir(cacheDir(), { recursive: true });
|
|
27
|
+
await writeFile(cachePath(), JSON.stringify(data), "utf8");
|
|
28
|
+
} catch {
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function fetchLatestVersion(packageName) {
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
34
|
+
try {
|
|
35
|
+
const url = `https://registry.npmjs.org/${packageName.replace("/", "%2F")}/latest`;
|
|
36
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
37
|
+
if (!res.ok) return null;
|
|
38
|
+
const body = await res.json();
|
|
39
|
+
return typeof body.version === "string" ? body.version : null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
} finally {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function compareVersions(a, b) {
|
|
47
|
+
const pa = a.split("-")[0].split(".").map((n) => parseInt(n, 10) || 0);
|
|
48
|
+
const pb = b.split("-")[0].split(".").map((n) => parseInt(n, 10) || 0);
|
|
49
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
50
|
+
const da = pa[i] ?? 0;
|
|
51
|
+
const db = pb[i] ?? 0;
|
|
52
|
+
if (da > db) return 1;
|
|
53
|
+
if (da < db) return -1;
|
|
54
|
+
}
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
function isSuppressed(isJson) {
|
|
58
|
+
return isJson || process.env.SPECFORGE_NO_UPDATE_CHECK === "1" || process.env.CI === "true" || process.env.CI === "1" || !process.stderr.isTTY;
|
|
59
|
+
}
|
|
60
|
+
function printBanner(current, latest) {
|
|
61
|
+
const useColor = !process.env.NO_COLOR && process.stderr.isTTY;
|
|
62
|
+
const dim = useColor ? "\x1B[2m" : "";
|
|
63
|
+
const yellow = useColor ? "\x1B[33m" : "";
|
|
64
|
+
const bold = useColor ? "\x1B[1m" : "";
|
|
65
|
+
const reset = useColor ? "\x1B[0m" : "";
|
|
66
|
+
const cmd = `npm i -g ${CHANNEL.packageName}`;
|
|
67
|
+
process.stderr.write(
|
|
68
|
+
`
|
|
69
|
+
${yellow}${bold}\u2B06 Update available${reset} ${dim}${current}${reset} \u2192 ${bold}${latest}${reset}
|
|
70
|
+
${dim}Run${reset} ${bold}${cmd}${reset} ${dim}to update.${reset}
|
|
71
|
+
|
|
72
|
+
`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
function notifyUpdate(opts) {
|
|
76
|
+
try {
|
|
77
|
+
if (isSuppressed(opts.isJson)) return;
|
|
78
|
+
const cache = readCacheSync();
|
|
79
|
+
if (cache && compareVersions(cache.latest, opts.current) > 0) {
|
|
80
|
+
printBanner(opts.current, cache.latest);
|
|
81
|
+
}
|
|
82
|
+
if (!cache || Date.now() - cache.checkedAt > TTL_MS) {
|
|
83
|
+
void fetchLatestVersion(CHANNEL.packageName).then((latest) => {
|
|
84
|
+
if (latest) return writeCache({ latest, checkedAt: Date.now() });
|
|
85
|
+
}).catch(() => {
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export {
|
|
92
|
+
compareVersions,
|
|
93
|
+
notifyUpdate
|
|
94
|
+
};
|
|
95
|
+
//# sourceMappingURL=update-check.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/cli/update-check.ts"],"sourcesContent":["/**\n * Update notifier — tells the user when a newer CLI is on npm.\n *\n * Channel-aware: checks `CHANNEL.packageName` (stable → `@specforge/cli`, canary\n * → `@specforge/canary-cli`). NEVER blocks a command: the banner is driven by a\n * cached result from a PRIOR run (read synchronously), and the current run\n * refreshes that cache in the background (fire-and-forget) when it's older than\n * the TTL. The very first run just seeds the cache; subsequent runs show the\n * banner — the standard update-notifier trade-off.\n *\n * Suppressed for `--json` output, under CI, on non-TTY stderr (pipes/scripts),\n * and via `SPECFORGE_NO_UPDATE_CHECK=1`.\n */\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\nimport { readFileSync } from 'node:fs';\nimport { mkdir, writeFile } from 'node:fs/promises';\nimport { CHANNEL } from '../channel.js';\n\nconst TTL_MS = 12 * 60 * 60 * 1000; // re-check the registry at most ~twice a day\nconst FETCH_TIMEOUT_MS = 3000;\n\ninterface UpdateCache {\n latest: string;\n checkedAt: number;\n}\n\nfunction cacheDir(): string {\n return join(homedir(), CHANNEL.configDir);\n}\nfunction cachePath(): string {\n return join(cacheDir(), 'update-check.json');\n}\n\nfunction readCacheSync(): UpdateCache | null {\n try {\n const parsed = JSON.parse(readFileSync(cachePath(), 'utf8')) as UpdateCache;\n if (typeof parsed.latest === 'string' && typeof parsed.checkedAt === 'number') {\n return parsed;\n }\n } catch {\n /* no cache / unreadable — treat as \"unknown\" */\n }\n return null;\n}\n\nasync function writeCache(data: UpdateCache): Promise<void> {\n try {\n await mkdir(cacheDir(), { recursive: true });\n await writeFile(cachePath(), JSON.stringify(data), 'utf8');\n } catch {\n /* best-effort */\n }\n}\n\nasync function fetchLatestVersion(packageName: string): Promise<string | null> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);\n try {\n const url = `https://registry.npmjs.org/${packageName.replace('/', '%2F')}/latest`;\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) return null;\n const body = (await res.json()) as { version?: unknown };\n return typeof body.version === 'string' ? body.version : null;\n } catch {\n return null;\n } finally {\n clearTimeout(timer);\n }\n}\n\n/**\n * Compare dot-separated numeric versions. Returns 1 if a>b, -1 if a<b, 0 equal.\n * Pre-release suffixes (e.g. `0.2.5-beta.1`) are stripped — a numeric compare is\n * enough for the CLI's simple version line.\n */\nexport function compareVersions(a: string, b: string): number {\n const pa = a.split('-')[0].split('.').map((n) => parseInt(n, 10) || 0);\n const pb = b.split('-')[0].split('.').map((n) => parseInt(n, 10) || 0);\n for (let i = 0; i < Math.max(pa.length, pb.length); i++) {\n const da = pa[i] ?? 0;\n const db = pb[i] ?? 0;\n if (da > db) return 1;\n if (da < db) return -1;\n }\n return 0;\n}\n\nfunction isSuppressed(isJson: boolean): boolean {\n return (\n isJson ||\n process.env.SPECFORGE_NO_UPDATE_CHECK === '1' ||\n process.env.CI === 'true' ||\n process.env.CI === '1' ||\n !process.stderr.isTTY\n );\n}\n\nfunction printBanner(current: string, latest: string): void {\n const useColor = !process.env.NO_COLOR && process.stderr.isTTY;\n const dim = useColor ? '\\x1b[2m' : '';\n const yellow = useColor ? '\\x1b[33m' : '';\n const bold = useColor ? '\\x1b[1m' : '';\n const reset = useColor ? '\\x1b[0m' : '';\n const cmd = `npm i -g ${CHANNEL.packageName}`;\n process.stderr.write(\n `\\n${yellow}${bold}⬆ Update available${reset} ${dim}${current}${reset} → ${bold}${latest}${reset}\\n` +\n ` ${dim}Run${reset} ${bold}${cmd}${reset} ${dim}to update.${reset}\\n\\n`,\n );\n}\n\n/**\n * Best-effort update check. Prints the banner synchronously from cache, then\n * refreshes the cache in the background. Any failure is swallowed — a version\n * check must never break a command.\n */\nexport function notifyUpdate(opts: { current: string; isJson: boolean }): void {\n try {\n if (isSuppressed(opts.isJson)) return;\n\n const cache = readCacheSync();\n if (cache && compareVersions(cache.latest, opts.current) > 0) {\n printBanner(opts.current, cache.latest);\n }\n\n // Refresh the cache when missing/stale — fire-and-forget so we never wait.\n if (!cache || Date.now() - cache.checkedAt > TTL_MS) {\n void fetchLatestVersion(CHANNEL.packageName)\n .then((latest) => {\n if (latest) return writeCache({ latest, checkedAt: Date.now() });\n })\n .catch(() => {\n /* ignore */\n });\n }\n } catch {\n /* never break the CLI over a version check */\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"remote.test.d.ts","sourceRoot":"","sources":["../../../src/transport/__tests__/remote.test.ts"],"names":[],"mappings":""}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remote.d.ts","sourceRoot":"","sources":["../../src/transport/remote.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAQlE,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B;
|
|
1
|
+
{"version":3,"file":"remote.d.ts","sourceRoot":"","sources":["../../src/transport/remote.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAQlE,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B;AAwBD,qBAAa,eAAgB,YAAW,UAAU;IAChD,OAAO,CAAC,MAAM,CAAwB;IACtC,4EAA4E;IAC5E,OAAO,CAAC,WAAW,CAAuB;gBAE9B,MAAM,EAAE,qBAAqB;IAIzC;;;;OAIG;YACW,OAAO;IAoCf,OAAO,CAAC,CAAC,GAAG,OAAO,EACvB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC5B,OAAO,CAAC,CAAC,CAAC;CAkEd"}
|
package/dist/transport/remote.js
CHANGED
|
@@ -6,6 +6,18 @@ import {
|
|
|
6
6
|
} from "./resolve-endpoint.js";
|
|
7
7
|
const DEFAULT_TIMEOUT = 3e4;
|
|
8
8
|
const MCP_CACHE_KEY = "mcp-endpoint-cache";
|
|
9
|
+
function safeUrl(u) {
|
|
10
|
+
try {
|
|
11
|
+
return new URL(u);
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function isChannelHost(apiUrl) {
|
|
17
|
+
const h = safeUrl(apiUrl)?.host;
|
|
18
|
+
if (!h) return false;
|
|
19
|
+
return h === safeUrl(CHANNEL.endpoint.primary)?.host || h === safeUrl(CHANNEL.endpoint.fallback)?.host;
|
|
20
|
+
}
|
|
9
21
|
class RemoteTransport {
|
|
10
22
|
config;
|
|
11
23
|
/** Resolved URL pinned for this transport instance after the first call. */
|
|
@@ -20,15 +32,22 @@ class RemoteTransport {
|
|
|
20
32
|
*/
|
|
21
33
|
async pickUrl() {
|
|
22
34
|
if (this.resolvedUrl) return this.resolvedUrl;
|
|
23
|
-
const
|
|
24
|
-
if (!
|
|
35
|
+
const apiUrl = safeUrl(this.config.apiUrl);
|
|
36
|
+
if (!apiUrl || !isChannelHost(this.config.apiUrl)) {
|
|
25
37
|
this.resolvedUrl = this.config.apiUrl;
|
|
26
38
|
return this.resolvedUrl;
|
|
27
39
|
}
|
|
28
|
-
|
|
40
|
+
const resolvedBase = await resolveEndpoint({
|
|
29
41
|
...CHANNEL.endpoint,
|
|
30
42
|
cacheKey: MCP_CACHE_KEY
|
|
31
43
|
});
|
|
44
|
+
const resolved = safeUrl(resolvedBase);
|
|
45
|
+
if (resolved && apiUrl) {
|
|
46
|
+
resolved.pathname = apiUrl.pathname;
|
|
47
|
+
this.resolvedUrl = resolved.toString().replace(/\/$/, "");
|
|
48
|
+
} else {
|
|
49
|
+
this.resolvedUrl = resolvedBase;
|
|
50
|
+
}
|
|
32
51
|
if (this.config.debug) {
|
|
33
52
|
console.log(`[RemoteTransport] Resolved MCP endpoint: ${this.resolvedUrl}`);
|
|
34
53
|
}
|
|
@@ -59,7 +78,7 @@ class RemoteTransport {
|
|
|
59
78
|
});
|
|
60
79
|
clearTimeout(timeoutId);
|
|
61
80
|
if (!response.ok) {
|
|
62
|
-
if (shouldInvalidateOnStatus(response.status) && this.config.apiUrl
|
|
81
|
+
if (shouldInvalidateOnStatus(response.status) && isChannelHost(this.config.apiUrl)) {
|
|
63
82
|
await invalidate(MCP_CACHE_KEY);
|
|
64
83
|
this.resolvedUrl = null;
|
|
65
84
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/transport/remote.ts"],"sourcesContent":["/**\n * RemoteTransport — HTTP POST transport to SpecForge API Gateway\n *\n * Extracted from ApiClient.call() to implement ITransport.\n *\n * Endpoint resolution: when `config.apiUrl` matches `CHANNEL.endpoint.primary`\n * (i.e. no `SPECFORGE_API_URL` override is active), the transport delegates\n * to `resolveEndpoint` on the first call so the URL flows from the\n * canary-channel client probe (PLAN-canary-channel.md §D) — primary first,\n * fallback on DNS-class failure, cached for 5 min. On a 401/403 the cache\n * is invalidated so the next call re-probes.\n */\n\nimport type { ITransport, TransportOrigin } from './interface.js';\nimport { CHANNEL } from '../channel.js';\nimport {\n resolveEndpoint,\n invalidate,\n shouldInvalidateOnStatus,\n} from './resolve-endpoint.js';\n\nexport interface RemoteTransportConfig {\n apiUrl: string;\n apiKey: string;\n debug?: boolean;\n timeout?: number;\n /**\n * Calling context, sent as the `X-SpecForge-Origin` header (M9.7). Defaults to\n * `mcp` (agent traffic); the human CLI surface passes `cli`.\n */\n origin?: TransportOrigin;\n}\n\nconst DEFAULT_TIMEOUT = 30000;\nconst MCP_CACHE_KEY = 'mcp-endpoint-cache';\n\nexport class RemoteTransport implements ITransport {\n private config: RemoteTransportConfig;\n /** Resolved URL pinned for this transport instance after the first call. */\n private resolvedUrl: string | null = null;\n\n constructor(config: RemoteTransportConfig) {\n this.config = config;\n }\n\n /**\n * Pick the URL for this call. When `config.apiUrl` is the channel default,\n * defer to the resolver. Otherwise (env override / explicit test fixture),\n * use the configured URL directly.\n */\n private async pickUrl(): Promise<string> {\n if (this.resolvedUrl) return this.resolvedUrl;\n
|
|
1
|
+
{"version":3,"sources":["../../src/transport/remote.ts"],"sourcesContent":["/**\n * RemoteTransport — HTTP POST transport to SpecForge API Gateway\n *\n * Extracted from ApiClient.call() to implement ITransport.\n *\n * Endpoint resolution: when `config.apiUrl` matches `CHANNEL.endpoint.primary`\n * (i.e. no `SPECFORGE_API_URL` override is active), the transport delegates\n * to `resolveEndpoint` on the first call so the URL flows from the\n * canary-channel client probe (PLAN-canary-channel.md §D) — primary first,\n * fallback on DNS-class failure, cached for 5 min. On a 401/403 the cache\n * is invalidated so the next call re-probes.\n */\n\nimport type { ITransport, TransportOrigin } from './interface.js';\nimport { CHANNEL } from '../channel.js';\nimport {\n resolveEndpoint,\n invalidate,\n shouldInvalidateOnStatus,\n} from './resolve-endpoint.js';\n\nexport interface RemoteTransportConfig {\n apiUrl: string;\n apiKey: string;\n debug?: boolean;\n timeout?: number;\n /**\n * Calling context, sent as the `X-SpecForge-Origin` header (M9.7). Defaults to\n * `mcp` (agent traffic); the human CLI surface passes `cli`.\n */\n origin?: TransportOrigin;\n}\n\nconst DEFAULT_TIMEOUT = 30000;\nconst MCP_CACHE_KEY = 'mcp-endpoint-cache';\n\n/** Parse a URL, returning null instead of throwing on malformed input. */\nfunction safeUrl(u: string): URL | null {\n try {\n return new URL(u);\n } catch {\n return null;\n }\n}\n\n/** True when `apiUrl` targets one of the channel's hosts (on any surface path). */\nfunction isChannelHost(apiUrl: string): boolean {\n const h = safeUrl(apiUrl)?.host;\n if (!h) return false;\n return (\n h === safeUrl(CHANNEL.endpoint.primary)?.host ||\n h === safeUrl(CHANNEL.endpoint.fallback)?.host\n );\n}\n\nexport class RemoteTransport implements ITransport {\n private config: RemoteTransportConfig;\n /** Resolved URL pinned for this transport instance after the first call. */\n private resolvedUrl: string | null = null;\n\n constructor(config: RemoteTransportConfig) {\n this.config = config;\n }\n\n /**\n * Pick the URL for this call. When `config.apiUrl` is the channel default,\n * defer to the resolver. Otherwise (env override / explicit test fixture),\n * use the configured URL directly.\n */\n private async pickUrl(): Promise<string> {\n if (this.resolvedUrl) return this.resolvedUrl;\n\n // Match the channel by HOST, not by exact URL. `createCliClient` rewrites the\n // default `/local` (MCP/agent) surface to `/cli` (human CLI), so an exact\n // `=== CHANNEL.endpoint.primary` check wrongly classified the CLI surface as\n // an \"override\" and skipped the resolver entirely — meaning `specforge login`\n // hit the dead primary with NO fallback. Comparing hosts makes BOTH surfaces\n // go through the primary→fallback probe.\n const apiUrl = safeUrl(this.config.apiUrl);\n if (!apiUrl || !isChannelHost(this.config.apiUrl)) {\n // A genuine override (env var / explicit test fixture) — use it verbatim.\n this.resolvedUrl = this.config.apiUrl;\n return this.resolvedUrl;\n }\n\n // Resolve the live host via the /health probes (primary → fallback), then\n // re-apply THIS transport's surface path (`/cli` or `/local`) onto the\n // resolved host so the fallback works on either surface.\n const resolvedBase = await resolveEndpoint({\n ...CHANNEL.endpoint,\n cacheKey: MCP_CACHE_KEY,\n });\n const resolved = safeUrl(resolvedBase);\n if (resolved && apiUrl) {\n resolved.pathname = apiUrl.pathname;\n this.resolvedUrl = resolved.toString().replace(/\\/$/, '');\n } else {\n this.resolvedUrl = resolvedBase;\n }\n if (this.config.debug) {\n console.log(`[RemoteTransport] Resolved MCP endpoint: ${this.resolvedUrl}`);\n }\n return this.resolvedUrl;\n }\n\n async execute<T = unknown>(\n operation: string,\n args: Record<string, unknown>\n ): Promise<T> {\n const timeout = this.config.timeout ?? DEFAULT_TIMEOUT;\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), timeout);\n\n try {\n if (this.config.debug) {\n console.log(`[RemoteTransport] Calling operation: ${operation}`, {\n args: Object.keys(args),\n });\n }\n\n const url = await this.pickUrl();\n const response = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${this.config.apiKey}`,\n // M9.7: server-derived feedback/telemetry provenance. The backend\n // reads this header (never a request-body `source` field).\n 'X-SpecForge-Origin': this.config.origin ?? 'mcp',\n },\n body: JSON.stringify({ operation, args }),\n signal: controller.signal,\n });\n\n clearTimeout(timeoutId);\n\n if (!response.ok) {\n // Auth failures may indicate the channel was rotated under us;\n // invalidate so the next call re-probes.\n if (\n shouldInvalidateOnStatus(response.status) &&\n isChannelHost(this.config.apiUrl)\n ) {\n await invalidate(MCP_CACHE_KEY);\n this.resolvedUrl = null;\n }\n const errorText = await response.text();\n const statusMessage = getStatusMessage(response.status);\n throw new Error(\n `API request failed: ${response.status} ${statusMessage}\\n${errorText}`\n );\n }\n\n // Transport is a dumb pipe (M9, approach A): return the raw response body.\n // Response-shape interpretation lives in the consumer that owns the\n // contract — `ApiClient.call` unwraps the `/cli` `{ data } / { error }`\n // shape; the agent `/local` path interprets the M8 envelope itself.\n const body = (await response.json()) as T;\n\n if (this.config.debug) {\n console.log(`[RemoteTransport] Operation completed: ${operation}`);\n }\n\n return body;\n } catch (error) {\n clearTimeout(timeoutId);\n if (error instanceof Error && error.name === 'AbortError') {\n throw new Error(\n `Request timed out after ${timeout}ms for operation: ${operation}`\n );\n }\n throw error;\n }\n }\n}\n\nfunction getStatusMessage(status: number): string {\n const messages: Record<number, string> = {\n 400: 'Bad Request',\n 401: 'Unauthorized - Check your API key',\n 403: 'Forbidden - Insufficient permissions',\n 404: 'Not Found',\n 429: 'Too Many Requests - Rate limit exceeded',\n 500: 'Internal Server Error',\n 501: 'Not Implemented',\n 502: 'Bad Gateway',\n 503: 'Service Unavailable',\n 504: 'Gateway Timeout',\n };\n return messages[status] || 'Unknown Error';\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specforge/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "MCP server for SpecForge - AI agent integration",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
"zod": "^3.22.0",
|
|
21
21
|
"@specforge/api-types": "0.4.12",
|
|
22
22
|
"@specforge/session-types": "0.4.11",
|
|
23
|
-
"@specforge/
|
|
24
|
-
"@specforge/
|
|
23
|
+
"@specforge/report-types": "0.4.3",
|
|
24
|
+
"@specforge/spec-types": "0.4.11"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/inquirer": "^9.0.9",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"@specforge/session-types",
|
|
62
62
|
"@specforge/report-types"
|
|
63
63
|
],
|
|
64
|
-
"gitHead": "
|
|
64
|
+
"gitHead": "05419e7f1635de01a3b1245fbca87cc04df3be62",
|
|
65
65
|
"scripts": {
|
|
66
66
|
"build": "tsup && tsc --emitDeclarationOnly --outDir dist",
|
|
67
67
|
"typecheck": "tsc --noEmit",
|