This is the last part of the general section of the course, and it brings everything so far together from VFP. Your application launches a console program, waits for it to finish, collects what it wrote on the output and the error channel, and checks its code to know whether it went well. With that you can use any tool with a CLI from VFP: git, curl, a compressor, a signing program.

There are three ways to launch it, and each falls short somewhere. At the end there is a function that combines the good parts of all three.

RUN: the usual one

Launching a command with RUN
RUN ping -n 2 127.0.0.1 > output.txt
? FILETOSTR("output.txt")

From part 1 you already know that RUN waits for the program to finish (and VFP sits frozen meanwhile), that RUN /N does not wait and that with /N the > does nothing. It lacks two things:

  • It does not give you the exit code. After a RUN you do not know whether the program went well.
  • It shows the console window while it runs, and in an application that confuses the user.

For a quick test in the Command window, it is fine. For an application, it is not.

WScript.Shell.Run: hidden window, and a code

WScript.Shell is a COM object that comes with Windows. Its Run method takes the command, how to show the window and whether to wait:

Launching a program with no window and reading its exit code
LOCAL loShell, lnCode
loShell = CREATEOBJECT("WScript.Shell")
lnCode  = loShell.Run("ping -n 1 -w 500 192.0.2.1", 0, .T.)
? lnCode

The 0 means no window, and the .T., wait for it to finish. With that, Run returns the program’s exit code: a ping to a machine that answers returns 0, and the one above, to a machine that does not exist, 1.

If you do not wait (.F.), Run comes back at once (0.015 seconds on mine) and always returns 0, because the program has not finished yet. That 0 does not mean it went well.

Run does not go through cmd, so redirection and cmd’s own commands (dir, ver, echo) do not work as they are. For that, you put cmd /c in front:

Run with the output to a file
lnCode = loShell.Run("cmd /c ping -n 1 127.0.0.1 > output.txt", 0, .T.)

What Run does not give you is the output: if you want it, you have to send it to a file and read that.

WScript.Shell.Exec: output without files, with two problems

Exec launches the program and gives you back an object with its three channels: StdOut, StdErr and StdIn. The output is read without going through any file:

Reading a program's output and error with Exec
LOCAL loShell, loProcess
loShell   = CREATEOBJECT("WScript.Shell")
loProcess = loShell.Exec("git estado")
? "Output:", loProcess.StdOut.ReadAll()
? "Error:", loProcess.StdErr.ReadAll()
DO WHILE loProcess.Status = 0
	INKEY(0.05, "H")
ENDDO
? "Code:", loProcess.ExitCode

With git estado the output arrives empty, the error brings git: 'estado' is not a git command. See 'git --help'. and the code is 1: the three channels from part 3, each on its own. It looks good, but Exec has two serious problems:

  1. It has no way to hide the window. Run takes how to show it; Exec has no such parameter.
  2. It can hang. StdOut.ReadAll() waits for the program to finish writing on its output. If meanwhile the program writes a lot on the error channel, the space Windows keeps for that channel fills up, the program stops to wait for someone to read it, and nobody reads it because VFP is still waiting for the output. I tried a program that writes 3,000 lines on the error channel: VFP sat waiting until I killed the process by hand.

Exec does not go through cmd either: loShell.Exec("ver") gives error 1429, “The system cannot find the file specified”, because ver is not a program but a cmd command.

A function that combines the good parts

The output comes out of Run by sending it to a temporary file, and the error to another. That way there is no window, no hang, and you have the code:

Function to launch a console program from VFP
FUNCTION RunConsole(tcCommand, tcOutput, tcError)
	* Runs a console command without showing a window, waits for it to end
	* and returns its exit code. The output and the error come back by
	* reference (with @), already converted from the console's code page 850
	* to VFP's 1252.
	LOCAL loShell, lcBase, lcOutputFile, lcErrorFile, lnCode
	lcBase       = ADDBS(GETENV("TEMP")) + "console-" + SYS(2015)
	lcOutputFile = lcBase + "-output.txt"
	lcErrorFile  = lcBase + "-error.txt"
	loShell = CREATEOBJECT("WScript.Shell")
	* The outer quotes are for cmd /c, which strips the first and the last:
	* that way the command can carry its own. 0: no window. .T.: wait.
	lnCode = loShell.Run([cmd /c "] + tcCommand + ;
		[ > "] + lcOutputFile + [" 2> "] + lcErrorFile + [""], 0, .T.)
	tcOutput = IIF(FILE(lcOutputFile), CPCONVERT(850, 1252, FILETOSTR(lcOutputFile)), "")
	tcError  = IIF(FILE(lcErrorFile), CPCONVERT(850, 1252, FILETOSTR(lcErrorFile)), "")
	ERASE (lcOutputFile)
	ERASE (lcErrorFile)
	RETURN lnCode
ENDFUNC

This is how it is used:

Calling the RunConsole function
LOCAL lcOutput, lcError, lnCode
lnCode = RunConsole("ver", @lcOutput, @lcError)
? lnCode, lcOutput

Three things about the function that are not obvious at first sight:

  • The outer quotes. cmd /c has an odd rule: if what you pass it starts with a quote, it strips the first and the last quote of the whole line. Without the outer pair, a command that started with a program path in quotes ("C:\...\My programs\curl.exe" ...) lost its final quote and never ran: code 1, no output and no error. With the outer pair, cmd strips those two and what is inside arrives intact, whatever quotes it has.
  • CPCONVERT, because of part 3: the console writes in code page 850, so ver arrives as Versión and not as Versi¢n. On a US English Windows the console usually uses 437: put the number chcp gives you.
  • SYS(2015) gives the temporary files a unique name, so two calls at once do not step on each other. And they are deleted at the end.

This is what it returned for each command I gave it:

CommandCodeWhat came back
ver0output: Microsoft Windows [Versión 10.0.26200.9457]
tasklist /fi "imagename eq explorer.exe" /fo csv /nh0output: the CSV line for explorer.exe
git estado1error: git: 'estado' is not a git command. ...
a-program-that-does-not-exist1error: cmd’s “is not recognized as an internal or external command” (in Spanish on mine)
a program that writes 3,000 lines on the error channel0error: the 3,000 lines, in under a second

The code it returns is cmd’s, which is that of the last program it ran. If you chain several in tcCommand with &, it is the last one’s.

A real example: does that website answer?

curl has come with Windows for years (C:\Windows\System32\curl.exe) and talks to any web server. It lets your application check whether a service answers before sending it anything:

Checking from VFP whether a website answers, with curl
LOCAL lcOutput, lcError, lnCode
lnCode = RunConsole("curl -s -f -o nul --max-time 10 https://irwinrodriguez.dev", @lcOutput, @lcError)
DO CASE
CASE lnCode = 0
	? "It answers"
CASE lnCode = 6
	? "That server name does not exist"
CASE lnCode = 22
	? "It answers, but with an error (404, 500...)"
CASE lnCode = 28
	? "No answer within 10 seconds"
OTHERWISE
	? "curl failed with code", lnCode
ENDCASE

The options, with what you learned in part 2: -s is a flag that removes curl’s progress bar (without it, the bar goes on the error channel, so it does not dirty the output, but it is not needed); -f makes a web error (a page that does not exist, a server failure) exit with code 22, and -o nul throws away the page content, because you only want to know whether it answers. --max-time 10 sets a limit, and if it is exceeded, the code is 28.

I saw each code come out here: 0 with the website, 22 with a page that does not exist, 6 with a server name that does not exist and 28 with an address that does not answer. Without -f, curl exits with 0 even if the page does not exist: as far as it is concerned, the website answered, even if with a 404. Always read in a program’s help what it considers an error before trusting its code.

Practice

Copy the RunConsole function into a .prg, do SET PROCEDURE TO that file and try from the Command window:

Trying the RunConsole function
LOCAL lcOutput, lcError
? RunConsole("ver", @lcOutput, @lcError), lcOutput
? RunConsole("where notepad", @lcOutput, @lcError), lcOutput
? RunConsole("git --version", @lcOutput, @lcError), lcOutput
? RunConsole("git estado", @lcOutput, @lcError), lcError
? RunConsole("curl -s -o nul -w %{http_code} https://irwinrodriguez.dev", @lcOutput, @lcError), lcOutput

(git --version only works if you have git installed; otherwise the code will be 1 and the error will tell you it cannot find it.)

That is the end of the general section of the course.