@jals1212/pi-spaces-cli 0.2.0 → 0.3.1

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/CHANGELOG.md CHANGED
@@ -6,6 +6,60 @@ commands and options and a patch version fixes.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.3.1
10
+
11
+ ### Fixed
12
+
13
+ - On Windows the program is started through `cross-spawn`. npm shims such as
14
+ `pi.cmd`, paths with spaces, a trailing backslash, `%` and other `cmd.exe`
15
+ characters in the command or in Pi's arguments are passed as written.
16
+ - The picker reports a bad command (an unclosed quote) before it lists the
17
+ spaces, not after you have chosen one.
18
+
19
+ ### Changed
20
+
21
+ - Depends on `cross-spawn`.
22
+
23
+ ## 0.3.0
24
+
25
+ ### Added
26
+
27
+ - `pi-space --new <name>` creates a space from the shell, with the same
28
+ `settings.json` as `/space new <name>`: the base home's declaration of
29
+ `@jals1212/pi-spaces`, or `npm:@jals1212/pi-spaces`. It does not start Pi.
30
+ A local path in the base home is copied when it is a copy of the extension.
31
+ - `pi-space --remove <name>` deletes a space after asking in
32
+ the terminal, with the same safety checks as `/space remove`, then asks
33
+ whether to keep a backup in `.backups` (`[Y/n]`: Enter keeps one). `--yes`
34
+ skips the questions and keeps a backup, and is required without a terminal;
35
+ `--no-backup` deletes without one.
36
+ - `--command <cmd>` and `--dir <path>`, before the rest of the arguments, set
37
+ the program to run and the spaces folder for one call, instead of
38
+ `PI_SPACES_COMMAND` and `PI_SPACES_DIR`. With `--dir`, the Pi it starts gets
39
+ that folder as `PI_SPACES_DIR`.
40
+ - Quotes in `--command` and `PI_SPACES_COMMAND`: double or single quotes keep a
41
+ path with spaces in one word, as in `--command '"/opt/my launcher/run" --'`.
42
+ A quote that is never closed is an error (exit status 2) and starts nothing.
43
+
44
+ ### Changed
45
+
46
+ - `pi-space list` with no spaces suggests `pi-space --new <name>`.
47
+ - A `"` or `'` in `PI_SPACES_COMMAND` is now a quote, no longer part of a
48
+ word: a path with an apostrophe, such as `/home/o'neil/bin/run`, needs double
49
+ quotes around it.
50
+ - Needs `@jals1212/pi-spaces` 0.2.0 or later (for `backupSpace` and the
51
+ local-path rule of `--new`).
52
+
53
+ ### Fixed
54
+
55
+ - `pi-space --new` copies the base home's local path to this checkout of the
56
+ extension instead of writing `npm:@jals1212/pi-spaces`.
57
+ - `pi-space --remove` explains a backup that cannot be written (for example a
58
+ read-only spaces folder): the space was not removed, and `--no-backup`
59
+ removes it without one.
60
+ - A program whose path has spaces now starts, including on Windows, where
61
+ cmd.exe runs it and each word is now quoted for it.
62
+
9
63
  ## 0.2.0
10
64
 
11
65
  ### Added
package/README.md CHANGED
@@ -17,12 +17,28 @@ Node 20 or later. The Pi extension `@jals1212/pi-spaces` must be installed in th
17
17
  | `pi-space` | Shows a numbered list of spaces and starts Pi in the one you pick. |
18
18
  | `pi-space <name> [pi args...]` | Starts Pi in that space (`base` for the home Pi starts with). |
19
19
  | `pi-space list` | Lists the spaces. A space named `list` can still be opened from the picker. |
20
+ | `pi-space --new <name>` | Creates an empty space, as `/space new <name>` does in Pi. It does not start Pi. |
21
+ | `pi-space --remove <name> [--yes] [--no-backup]` | Deletes a space, as `/space remove <name>` does in Pi. It asks first (`[y/N]`), then whether to keep a backup in `<spaces folder>/.backups` (`[Y/n]`: Enter keeps one). `--yes` skips the questions and keeps a backup, and is required without a terminal; `--no-backup` deletes without one. |
20
22
  | `pi-space --help` | Shows the usage. |
21
23
  | `pi-space --version` | Prints the version. |
22
24
 
25
+ A new space gets a `settings.json` that declares the extension, so `/space` works inside it: the same declaration as in the base home's `settings.json` (`PI_SPACES_BASE_HOME`, else `PI_CODING_AGENT_DIR`, else `~/.pi/agent`), or `npm:@jals1212/pi-spaces` when the base home does not declare it. A local path counts when it is a copy of the extension: a folder whose `package.json` is named `@jals1212/pi-spaces`.
26
+
27
+ `--remove` refuses `base`, the space you are in, a space that holds the base home, and anything that is not a real folder inside the spaces folder, before it asks. A backup is the whole space folder, moved to `.backups/<name>-<UTC time>`; move it back to restore it. See [what it leaves on your machine](https://github.com/jals1212/pi-spaces#what-it-leaves-on-your-machine).
28
+
23
29
  ## Another program that runs Pi
24
30
 
25
- To start Pi through another program, set `PI_SPACES_COMMAND` in your shell profile; its words come before `--space`. Such a program usually has its own home, so point `PI_SPACES_DIR` at its spaces folder too, so `pi-space` lists the same spaces:
31
+ To start Pi through another program for one call, give `--command` and `--dir` before the rest; the words of `--command` come before `--space`. The words are split on spaces; double or single quotes keep a path with spaces in one word. Such a program usually has its own home, so `--dir` points at its spaces folder, and the Pi it starts gets that folder as `PI_SPACES_DIR` too. Nothing is exported, so a plain `pi` in the same shell is not affected:
32
+
33
+ ```sh
34
+ pi-space --command "my-launcher --" --dir ~/.my-launcher/spaces work # → my-launcher -- --space work
35
+ pi-space --dir ~/.my-launcher/spaces --new work # create one in that folder
36
+ pi-space --command '"/opt/my launcher/run" --' work # a path with spaces, in quotes
37
+ ```
38
+
39
+ Inside quotes a backslash is a plain character, so `"C:\Program Files\my-launcher\run.cmd"` works as written. A quote that is never closed is an error (exit status 2), and nothing is started.
40
+
41
+ An alias keeps it short: `alias my-space='pi-space --command "my-launcher --" --dir ~/.my-launcher/spaces'`. To make it the default instead, set the variables in your shell profile:
26
42
 
27
43
  ```sh
28
44
  export PI_SPACES_COMMAND="my-launcher --" # pi-space work → my-launcher -- --space work
@@ -31,8 +47,8 @@ export PI_SPACES_DIR=~/.my-launcher/spaces # the spaces next to that program's
31
47
 
32
48
  | Variable | Meaning |
33
49
  | --- | --- |
34
- | `PI_SPACES_COMMAND` | The program to run, split on spaces, before `--space <name>`. Default: `pi`. |
35
- | `PI_SPACES_DIR` | The spaces folder. A relative value is taken from your home folder. Default: `spaces` next to the base home. |
50
+ | `PI_SPACES_COMMAND` | The program to run, split on spaces (double or single quotes keep a path with spaces in one word, as in `"/opt/my launcher/run" --`), before `--space <name>`. Default: `pi`. `--command` wins for one call. |
51
+ | `PI_SPACES_DIR` | The spaces folder. A relative value is taken from your home folder. Default: `spaces` next to the base home. `--dir` wins for one call; a relative `--dir` is taken from the current folder. |
36
52
  | `PI_CODING_AGENT_DIR` | Pi's agent home; its parent holds the default spaces folder. Default: `~/.pi/agent`. |
37
53
 
38
54
  ## Exit status
@@ -40,8 +56,8 @@ export PI_SPACES_DIR=~/.my-launcher/spaces # the spaces next to that program's
40
56
  | Status | Meaning |
41
57
  | --- | --- |
42
58
  | 0 … | Pi's own exit status. |
43
- | 1 | An unknown or invalid space, an out-of-range pick, or the program could not start. |
44
- | 2 | No arguments and no terminal to pick in: the usage is printed. |
59
+ | 1 | An unknown or invalid space, a name `--new` cannot use (taken, `base` or invalid), a space `--remove` cannot delete, `--remove` without `--yes` and no terminal, an out-of-range pick, or the program could not start. |
60
+ | 2 | No arguments and no terminal to pick in, `--new` or `--remove` without exactly one name, or `--command` or `--dir` without a value or given twice: the usage is printed. A `--command` or `PI_SPACES_COMMAND` with an unclosed quote: the problem is printed. |
45
61
 
46
62
  ## More
47
63
 
package/dist/cli.js CHANGED
@@ -1,51 +1,172 @@
1
1
  // The optional `pi-space` command: pick a space and start Pi in it.
2
2
  // It only passes `--space <name>`; the extension does the switch, so it never sets PI_CODING_AGENT_DIR.
3
- import { spawn as nodeSpawn } from "node:child_process";
4
3
  import { readFileSync } from "node:fs";
5
4
  import { homedir } from "node:os";
5
+ import { dirname, join, resolve } from "node:path";
6
6
  import { createInterface } from "node:readline";
7
- import { BASE, listSpaces, spacesDir, validateName } from "@jals1212/pi-spaces/spaces";
8
-
9
- export const USAGE = `Usage: pi-space [<name> [pi args...] | list | --help | --version]
10
- pi-space pick a space and start Pi in it
11
- pi-space <name> ... start Pi in a space ("base" for the home Pi starts with)
12
- pi-space list list the spaces
13
- pi-space --version print the version
14
- PI_SPACES_COMMAND sets the program to run (default: pi), e.g. "my-launcher --".
7
+ import { fileURLToPath } from "node:url";
8
+ import crossSpawn from "cross-spawn";
9
+ import {
10
+ BACKUPS,
11
+ BASE,
12
+ backupSpace,
13
+ checkRemovable,
14
+ createSpace,
15
+
16
+ findOwnDeclaration,
17
+ listSpaces,
18
+
19
+ removeSpace,
20
+ SpaceError,
21
+ spacesDir,
22
+ validateName,
23
+ } from "@jals1212/pi-spaces/spaces";
24
+
25
+ export const USAGE = `Usage: pi-space [--command <cmd>] [--dir <path>] [<name> [pi args...] | list | --new <name> | --remove <name> [--yes] [--no-backup]]
26
+ pi-space --help | --version
27
+ pi-space pick a space and start Pi in it
28
+ pi-space <name> ... start Pi in a space ("base" for the home Pi starts with)
29
+ pi-space list list the spaces
30
+ pi-space --new <name> create a space
31
+ pi-space --remove <name> delete a space and everything in it, after asking, and keep a backup in .backups unless told not to
32
+ (--yes skips the questions; --no-backup deletes without a backup)
33
+ pi-space --version print the version
34
+ Options for this call only:
35
+ --command <cmd> the program to run instead of $PI_SPACES_COMMAND (default: pi), e.g. "my-launcher --"
36
+ --dir <path> the spaces folder instead of $PI_SPACES_DIR; the Pi it starts gets it too
15
37
  `;
16
38
 
17
39
  /** This package's version; package.json sits one folder above both src/ and dist/. */
18
40
  export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
19
41
 
20
-
42
+ /** --command and --dir: for this call only, so nothing has to be exported into the shell. */
43
+
44
+
45
+
46
+
47
+
48
+
21
49
 
22
50
 
23
51
 
24
52
 
53
+
54
+
25
55
 
26
56
 
27
-
57
+
58
+
59
+
60
+
61
+ const CALL_OPTIONS = { "--command": "command", "--dir": "dir" } ;
28
62
 
29
63
  /**
64
+ * --command and --dir come first; after a space name every argument belongs to Pi.
30
65
  * `list` is the subcommand, so a space named "list" can only be opened from the picker.
66
+ * `--new` and `--remove` are options, not subcommands, so they never take a space name.
31
67
  * A space name never starts with "-", so any other option in first place is unknown, not a name.
32
68
  */
33
69
  export function parseCli(args ) {
70
+ const options = {};
71
+ let index = 0;
72
+ while (Object.hasOwn(CALL_OPTIONS, args[index] ?? "")) {
73
+ const key = CALL_OPTIONS[args[index] ];
74
+ const value = args[index + 1];
75
+ if (!value || options[key] !== undefined) return { kind: "usage" };
76
+ options[key] = value;
77
+ index += 2;
78
+ }
79
+ const command = parseCommand(args.slice(index));
80
+ return command.kind === "usage" ? command : { ...command, ...options };
81
+ }
82
+
83
+ function parseCommand(args ) {
34
84
  const [first, ...rest] = args;
35
85
  if (first === undefined) return { kind: "picker" };
36
86
  if (first === "--help" || first === "-h") return { kind: "help" };
37
87
  if (first === "--version" || first === "-v") return { kind: "version" };
88
+ if (first === "--new") return rest.length === 1 ? { kind: "new", name: rest[0] } : { kind: "usage" };
89
+ if (first === "--remove") {
90
+ const flags = rest.filter((arg) => arg.startsWith("-"));
91
+ const names = rest.filter((arg) => !arg.startsWith("-"));
92
+ const yes = flags.filter((flag) => flag === "--yes" || flag === "-y").length;
93
+ const noBackup = flags.filter((flag) => flag === "--no-backup").length;
94
+ if (names.length !== 1 || yes > 1 || noBackup > 1 || yes + noBackup !== flags.length) return { kind: "usage" };
95
+ return { kind: "remove", name: names[0], yes: yes === 1, backup: noBackup === 0 };
96
+ }
38
97
  if (first.startsWith("-")) return { kind: "unknown-option", option: first };
39
98
  if (first === "list") return rest.length === 0 ? { kind: "list" } : { kind: "usage" };
40
99
  return { kind: "launch", name: first, args: rest };
41
100
  }
42
101
 
43
- /** The program and arguments that start Pi in a space: $PI_SPACES_COMMAND (default pi) plus --space <name>. */
102
+ /**
103
+ * The program and arguments that start Pi in a space: $PI_SPACES_COMMAND (default pi) plus --space <name>.
104
+ * Throws when the command has an unclosed quote.
105
+ */
44
106
  export function launchCommand(env , name , args ) {
45
- const [program = "pi", ...prefix] = (env.PI_SPACES_COMMAND ?? "").split(/\s+/).filter(Boolean);
107
+ const [program = "pi", ...prefix] = splitCommand(env.PI_SPACES_COMMAND ?? "");
46
108
  return { program, args: [...prefix, "--space", name, ...args] };
47
109
  }
48
110
 
111
+ /**
112
+ * Splits a command into words on whitespace. Double or single quotes keep whitespace inside a word and are
113
+ * removed. There are no backslash escapes, so a Windows path in quotes keeps its backslashes.
114
+ */
115
+ export function splitCommand(text ) {
116
+ const words = [];
117
+ let word = "";
118
+ // True from the first character or quote of a word, so "" alone is an empty word.
119
+ let started = false;
120
+ let quote ;
121
+ for (const char of text) {
122
+ if (quote) {
123
+ if (char === quote) quote = undefined;
124
+ else word += char;
125
+ } else if (char === '"' || char === "'") {
126
+ quote = char;
127
+ started = true;
128
+ } else if (/\s/.test(char)) {
129
+ if (started) words.push(word);
130
+ word = "";
131
+ started = false;
132
+ } else {
133
+ word += char;
134
+ started = true;
135
+ }
136
+ }
137
+ if (quote) throw new Error(`the command has an unclosed ${quote} quote`);
138
+ if (started) words.push(word);
139
+ return words;
140
+ }
141
+
142
+ const EXTENSION = "@jals1212/pi-spaces";
143
+
144
+ /**
145
+ * The extension's declaration for a new space's settings.json: the one in the base home's settings.json
146
+ * (PI_SPACES_BASE_HOME, else PI_CODING_AGENT_DIR, else ~/.pi/agent), else npm:@jals1212/pi-spaces.
147
+ */
148
+ export function newSpaceDeclaration(env , home ) {
149
+ const baseHome = env.PI_SPACES_BASE_HOME || agentHome(env, home);
150
+ let packageRoot ;
151
+ try {
152
+ packageRoot = dirname(fileURLToPath(import.meta.resolve(`${EXTENSION}/package.json`)));
153
+ } catch {
154
+ packageRoot = "";
155
+ }
156
+ return findOwnDeclaration(baseHome, EXTENSION, packageRoot, `npm:${EXTENSION}`);
157
+ }
158
+
159
+ /** The agent home Pi uses from this shell: PI_CODING_AGENT_DIR, else ~/.pi/agent. */
160
+ function agentHome(env , home ) {
161
+ return env.PI_CODING_AGENT_DIR ? env.PI_CODING_AGENT_DIR.replace(/^~(?=\/|$)/, home) : join(home, ".pi", "agent");
162
+ }
163
+
164
+
165
+
166
+
167
+
168
+
169
+
49
170
 
50
171
 
51
172
 
@@ -56,18 +177,23 @@ export function launchCommand(env , name , args )
56
177
 
57
178
 
58
179
 
180
+
181
+
59
182
 
60
-
183
+
61
184
 
62
185
 
63
186
 
64
- /** The real child process: stdio inherited, the environment unchanged, a shell on Windows so .cmd shims work. */
65
- export function spawnChild(program , args , options ) {
187
+ /**
188
+ * The real child process: stdio inherited, the environment unchanged. cross-spawn is plain child_process.spawn
189
+ * except on Windows, where it finds .cmd and .bat shims such as pi.cmd and escapes the arguments for them.
190
+ */
191
+ export function spawnChild(program , args , options ) {
66
192
  // Ctrl-C belongs to Pi while it runs; this process waits for it instead of dying first.
67
193
  const ignore = () => {};
68
194
  process.on("SIGINT", ignore);
69
195
  return new Promise ((resolve) => {
70
- const child = nodeSpawn(program, args, options);
196
+ const child = crossSpawn(program, args, options);
71
197
  child.once("error", (error) => resolve({ status: null, error }));
72
198
  child.once("close", (status) => resolve({ status }));
73
199
  }).finally(() => process.off("SIGINT", ignore));
@@ -87,8 +213,16 @@ export function processIo(proc = process) {
87
213
  }
88
214
 
89
215
  export async function main(argv , io ) {
90
- const dir = spacesDir(io.env, io.home);
91
216
  const command = parseCli(argv);
217
+ if (command.dir !== undefined || command.command !== undefined) {
218
+ // A relative --dir is relative to where the command runs; the child gets it absolute.
219
+ const env = { ...io.env };
220
+ if (command.dir !== undefined) env.PI_SPACES_DIR = resolve(command.dir);
221
+ if (command.command !== undefined) env.PI_SPACES_COMMAND = command.command;
222
+ io = { ...io, env, childEnv: command.dir !== undefined ? { ...io.env, PI_SPACES_DIR: env.PI_SPACES_DIR } : undefined };
223
+ }
224
+ const commandSource = command.command !== undefined ? "--command" : "PI_SPACES_COMMAND";
225
+ const dir = spacesDir(io.env, io.home);
92
226
  switch (command.kind) {
93
227
  case "help":
94
228
  io.stdout.write(USAGE);
@@ -105,18 +239,114 @@ export async function main(argv , io ) {
105
239
  case "list": {
106
240
  const spaces = listSpaces(dir);
107
241
  const lines = [`Spaces in ${dir}:`, ` ${BASE}`, ...spaces.map((name) => ` ${name}`)];
108
- if (spaces.length === 0) lines.push("No spaces yet. Create one with /space new <name> inside Pi.");
242
+ if (spaces.length === 0) lines.push("No spaces yet. Create one with pi-space --new <name>.");
109
243
  io.stdout.write(`${lines.join("\n")}\n`);
110
244
  return io.exit(0);
111
245
  }
246
+ case "new":
247
+ return create(command.name, dir, io);
248
+ case "remove":
249
+ return remove(command.name, command.yes, command.backup, dir, io);
112
250
  case "launch":
113
- return launch(command.name, command.args, dir, io);
251
+ return launch(command.name, command.args, dir, io, commandSource);
114
252
  case "picker":
115
- return pick(dir, io);
253
+ return pick(dir, io, commandSource);
254
+ }
255
+ }
256
+
257
+ function create(name , dir , io ) {
258
+ try {
259
+ const path = createSpace(dir, name, newSpaceDeclaration(io.env, io.home));
260
+ io.stdout.write(`Created space "${name}" at ${path}.\nStart Pi in it with: pi-space ${name}\n`);
261
+ return io.exit(0);
262
+ } catch (error) {
263
+ // A SpaceError is a rule (a bad or taken name); anything else is the filesystem, such as no permission.
264
+ io.stderr.write(`pi-space: ${error instanceof SpaceError ? error.message : `could not create space "${name}": ${(error ).message}`}\n`);
265
+ return io.exit(1);
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Without --yes it asks to remove ([y/N]), then, without --no-backup, whether to keep a backup ([Y/n]).
271
+ * With --yes it asks nothing and keeps a backup unless --no-backup.
272
+ */
273
+ async function remove(name , yes , backup , dir , io ) {
274
+ const current = agentHome(io.env, io.home);
275
+ const protect = io.env.PI_SPACES_BASE_HOME ? [io.env.PI_SPACES_BASE_HOME] : [];
276
+ let reader ;
277
+ try {
278
+ // Fail before asking when the space cannot be removed anyway.
279
+ const path = checkRemovable(dir, name, current, protect, io.home);
280
+ let keep = backup;
281
+ if (!yes) {
282
+ if (!io.stdin.isTTY) throw new SpaceError(`Removing a space needs confirmation; run pi-space --remove ${name} --yes.`);
283
+ // One reader for every question, so an answer typed ahead is not lost between them.
284
+ reader = lineReader(io.stdin);
285
+ io.stdout.write(`Remove space "${name}"? This deletes ${path} and everything in it: sign-ins, packages, settings and sessions. [y/N] `);
286
+ if (!isYes(await reader.next())) {
287
+ io.stdout.write(`Kept space "${name}".\n`);
288
+ return io.exit(0);
289
+ }
290
+ if (backup) {
291
+ io.stdout.write(`Keep a backup of "${name}" in ${join(dir, BACKUPS)}? [Y/n] `);
292
+ // Only a clear no skips the backup: Enter or the end of input keep one.
293
+ const answer = (await reader.next())?.trim().toLowerCase();
294
+ keep = answer !== "n" && answer !== "no";
295
+ }
296
+ }
297
+ if (keep) {
298
+ const saved = backupSpace(dir, name, current, protect, io.home);
299
+ io.stdout.write(`Removed space "${name}"; its backup is ${saved}.\n`);
300
+ } else {
301
+ removeSpace(dir, name, current, protect, io.home);
302
+ io.stdout.write(`Removed space "${name}".\n`);
303
+ }
304
+ return io.exit(0);
305
+ } catch (error) {
306
+ io.stderr.write(`pi-space: ${error instanceof SpaceError ? error.message : `could not remove space "${name}": ${(error ).message}`}\n`);
307
+ return io.exit(1);
308
+ } finally {
309
+ reader?.close();
116
310
  }
117
311
  }
118
312
 
119
- async function launch(name , args , dir , io ) {
313
+ function isYes(answer ) {
314
+ const word = answer?.trim().toLowerCase();
315
+ return word === "y" || word === "yes";
316
+ }
317
+
318
+
319
+
320
+
321
+
322
+
323
+
324
+ /** Reads lines one at a time from one readline interface, keeping lines that arrive before they are asked for. */
325
+ function lineReader(input ) {
326
+ const rl = createInterface({ input });
327
+ const lines = [];
328
+ const waiting = [];
329
+ let ended = false;
330
+ rl.on("line", (line) => {
331
+ const resolve = waiting.shift();
332
+ if (resolve) resolve(line);
333
+ else lines.push(line);
334
+ });
335
+ rl.once("close", () => {
336
+ ended = true;
337
+ for (const resolve of waiting.splice(0)) resolve(undefined);
338
+ });
339
+ return {
340
+ next: () => {
341
+ if (lines.length > 0) return Promise.resolve(lines.shift());
342
+ if (ended) return Promise.resolve(undefined);
343
+ return new Promise((resolve) => waiting.push(resolve));
344
+ },
345
+ close: () => rl.close(),
346
+ };
347
+ }
348
+
349
+ async function launch(name , args , dir , io , commandSource ) {
120
350
  if (name !== BASE) {
121
351
  const invalid = validateName(name);
122
352
  if (invalid) {
@@ -128,8 +358,17 @@ async function launch(name , args , dir , io )
128
358
  return io.exit(1);
129
359
  }
130
360
  }
131
- const { program, args: programArgs } = launchCommand(io.env, name, args);
132
- const result = await io.spawn(program, programArgs, { stdio: "inherit", shell: process.platform === "win32" });
361
+ let launched ;
362
+ try {
363
+ launched = launchCommand(io.env, name, args);
364
+ } catch (error) {
365
+ io.stderr.write(`pi-space: ${commandSource}: ${(error ).message}.\n`);
366
+ return io.exit(2);
367
+ }
368
+ const { program, args: programArgs } = launched;
369
+ const options = { stdio: "inherit" };
370
+ if (io.childEnv) options.env = io.childEnv;
371
+ const result = await io.spawn(program, programArgs, options);
133
372
  if (result.error) {
134
373
  io.stderr.write(`pi-space: could not start ${program}: ${result.error.message}\n`);
135
374
  return io.exit(1);
@@ -137,11 +376,18 @@ async function launch(name , args , dir , io )
137
376
  io.exit(result.status ?? 1);
138
377
  }
139
378
 
140
- async function pick(dir , io ) {
379
+ async function pick(dir , io , commandSource ) {
141
380
  if (!io.stdin.isTTY) {
142
381
  io.stderr.write(USAGE);
143
382
  return io.exit(2);
144
383
  }
384
+ // A bad command is reported before the list, not after the user has chosen.
385
+ try {
386
+ splitCommand(io.env.PI_SPACES_COMMAND ?? "");
387
+ } catch (error) {
388
+ io.stderr.write(`pi-space: ${commandSource}: ${(error ).message}.\n`);
389
+ return io.exit(2);
390
+ }
145
391
  const names = [BASE, ...listSpaces(dir)];
146
392
  io.stdout.write(`${[`Spaces in ${dir}:`, ...names.map((name, index) => `${index + 1}) ${name}`)].join("\n")}\nSpace number: `);
147
393
  const line = await readLine(io.stdin);
@@ -152,7 +398,7 @@ async function pick(dir , io ) {
152
398
  io.stderr.write(`pi-space: Choose a number from 1 to ${names.length}.\n`);
153
399
  return io.exit(1);
154
400
  }
155
- return launch(names[number - 1], [], dir, io);
401
+ return launch(names[number - 1], [], dir, io, commandSource);
156
402
  }
157
403
 
158
404
  /** One line from the stream, or undefined at end of input. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jals1212/pi-spaces-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "The pi-space command: pick a space and start Pi in it. Companion to the Pi extension @jals1212/pi-spaces.",
5
5
  "license": "MIT",
6
6
  "author": "jals1212",
@@ -35,10 +35,11 @@
35
35
  "scripts": {
36
36
  "build": "node --disable-warning=ExperimentalWarning scripts/build.mjs",
37
37
  "prepack": "npm run build",
38
- "test": "node ../scripts/link-self.mjs && npm run build --prefix .. && npm run build && node --test 'test/*.test.ts'"
38
+ "test": "node ../scripts/link-self.mjs && npm run build --prefix .. && npm run build && node --test \"test/*.test.ts\""
39
39
  },
40
40
  "dependencies": {
41
- "@jals1212/pi-spaces": "^0.1.1"
41
+ "cross-spawn": "^7.0.6",
42
+ "@jals1212/pi-spaces": "^0.2.0"
42
43
  },
43
44
  "publishConfig": {
44
45
  "access": "public",