gcf-board 0.2.0 → 0.2.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/README.md CHANGED
@@ -17,7 +17,15 @@ gcf-board # http://127.0.0.1:4317
17
17
  gcf-board --port 4400
18
18
  ```
19
19
 
20
- It prints `http://127.0.0.1:4317/?t=<token>`: open that URL once. It sets a cookie, and the plain address works after that. Add a project by its folder (one that has `gcf/`) under **Projects**.
20
+ It opens the board in your browser (the URL with the token, `http://127.0.0.1:4317/?t=<token>`, is printed too; `--no-open` skips the browser). Started again while it runs, it just opens the running board. Add a project by its folder (one that has `gcf/`) under **Projects**.
21
+
22
+ **Desktop shortcut**: run once
23
+
24
+ ```bash
25
+ gcf-board shortcut # or: gcf-board shortcut --port 4400
26
+ ```
27
+
28
+ It puts **GCF Board** on the desktop (Windows: a shortcut whose server console starts minimized; macOS: `GCF Board.command`; Linux: an application entry). Double-click it: the board starts and opens, or opens if it already runs. Run the command again after moving Node.js.
21
29
 
22
30
  ## What it does
23
31
 
@@ -0,0 +1,67 @@
1
+ // The board without a console: open it in the browser, and a desktop shortcut that starts it.
2
+ import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ const NAME = 'GCF Board';
7
+ /** Open a URL in the default browser; best effort, the URL is printed anyway. */
8
+ export function openInBrowser(url) {
9
+ const [cmd, args] = process.platform === 'win32'
10
+ ? ['cmd', ['/c', 'start', '""', url]]
11
+ : process.platform === 'darwin'
12
+ ? ['open', [url]]
13
+ : ['xdg-open', [url]];
14
+ try {
15
+ spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
16
+ }
17
+ catch {
18
+ // No browser to open: the operator opens the printed URL.
19
+ }
20
+ }
21
+ /** A PowerShell single-quoted string: a quote inside is doubled. */
22
+ const psQuote = (s) => `'${s.replaceAll("'", "''")}'`;
23
+ export function shortcutPlan(platform, node, main, port) {
24
+ const args = ['--port', String(port)];
25
+ if (platform === 'win32')
26
+ return {
27
+ // A .lnk is a binary file: WScript.Shell writes it. Minimized: the server's console stays out
28
+ // of the way.
29
+ powershell: [
30
+ `$d = [Environment]::GetFolderPath('Desktop')`,
31
+ `$s = (New-Object -ComObject WScript.Shell).CreateShortcut((Join-Path $d '${NAME}.lnk'))`,
32
+ `$s.TargetPath = ${psQuote(node)}`,
33
+ `$s.Arguments = ${psQuote(`"${main}" ${args.join(' ')}`)}`,
34
+ `$s.WindowStyle = 7`,
35
+ `$s.Description = 'Start the GCF board and open it in the browser'`,
36
+ `$s.Save()`,
37
+ `Join-Path $d '${NAME}.lnk'`,
38
+ ].join('; '),
39
+ };
40
+ const command = `"${node}" "${main}" ${args.join(' ')}`;
41
+ if (platform === 'darwin')
42
+ return {
43
+ file: path.join(os.homedir(), 'Desktop', `${NAME}.command`),
44
+ text: `#!/bin/sh\nexec ${command}\n`,
45
+ };
46
+ return {
47
+ file: path.join(os.homedir(), '.local', 'share', 'applications', 'gcf-board.desktop'),
48
+ text: `[Desktop Entry]\nType=Application\nName=${NAME}\nExec=${command}\nTerminal=false\n` +
49
+ 'Categories=Development;\n',
50
+ };
51
+ }
52
+ /** Write the shortcut for this machine; returns where it is. */
53
+ export function createShortcut(main, port) {
54
+ const plan = shortcutPlan(process.platform, process.execPath, main, port);
55
+ if ('powershell' in plan) {
56
+ const r = spawnSync('powershell', ['-NoProfile', '-Command', plan.powershell], {
57
+ encoding: 'utf8',
58
+ });
59
+ if (r.status !== 0)
60
+ throw new Error(`the shortcut was not made: ${r.stderr.trim()}`);
61
+ return r.stdout.trim();
62
+ }
63
+ mkdirSync(path.dirname(plan.file), { recursive: true });
64
+ writeFileSync(plan.file, plan.text);
65
+ chmodSync(plan.file, 0o755);
66
+ return plan.file;
67
+ }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
- // `gcf-board [--port <n>]`: the board on http://127.0.0.1:<port>, 4317 by default. It prints the
3
- // URL with this machine's token; open that one, the plain address answers 401.
2
+ // `gcf-board [--port <n>] [--no-open]`: the board on http://127.0.0.1:<port>, 4317 by default,
3
+ // opened in the browser with this machine's token. A board already on the port is opened, not
4
+ // started twice. `gcf-board shortcut [--port <n>]` puts a desktop shortcut that does the same.
4
5
  import { existsSync } from 'node:fs';
5
6
  import path from 'node:path';
6
7
  import { fileURLToPath } from 'node:url';
@@ -8,6 +9,7 @@ import { parseArgs } from 'node:util';
8
9
  import { boardToken } from './auth.js';
9
10
  import { aiFactoryEntry } from './cli.js';
10
11
  import { createApp } from './app.js';
12
+ import { createShortcut, openInBrowser } from './desktop.js';
11
13
  const HERE = path.dirname(fileURLToPath(import.meta.url));
12
14
  /**
13
15
  * The built page: `dist/web` beside `server/` when run from the source (the source `web/` is
@@ -20,14 +22,37 @@ function webDir() {
20
22
  : path.resolve(HERE, '..', 'web');
21
23
  }
22
24
  async function main() {
23
- const { values } = parseArgs({ options: { port: { type: 'string', default: '4317' } } });
25
+ const { values, positionals } = parseArgs({
26
+ allowPositionals: true,
27
+ options: {
28
+ port: { type: 'string', default: '4317' },
29
+ 'no-open': { type: 'boolean', default: false },
30
+ },
31
+ });
24
32
  const port = Number(values.port);
25
33
  if (!Number.isInteger(port) || port < 1 || port > 65535)
26
34
  throw new Error('--port is a number from 1 to 65535');
35
+ if (positionals[0] === 'shortcut') {
36
+ console.log(`gcf-board: shortcut ${createShortcut(fileURLToPath(import.meta.url), port)}`);
37
+ return;
38
+ }
39
+ if (positionals.length)
40
+ throw new Error(`unknown command: ${positionals.join(' ')}`);
27
41
  const token = await boardToken();
42
+ const url = `http://127.0.0.1:${port}/?t=${token}`;
43
+ const open = () => !values['no-open'] && openInBrowser(url);
28
44
  const app = createApp({ entry: aiFactoryEntry(), token, port, web: webDir() });
45
+ app.on('error', (e) => {
46
+ if (e.code !== 'EADDRINUSE')
47
+ throw e;
48
+ // A second start, from the shortcut most likely: the board runs already, show it.
49
+ console.log(`gcf-board: already running on port ${port}, opening it`);
50
+ open();
51
+ setTimeout(() => process.exit(0), 500);
52
+ });
29
53
  app.listen(port, '127.0.0.1', () => {
30
- console.log(`gcf-board: http://127.0.0.1:${port}/?t=${token}`);
54
+ console.log(`gcf-board: ${url}`);
55
+ open();
31
56
  });
32
57
  }
33
58
  main().catch((e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcf-board",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "A local web board for GCF game projects: shows `gcf board --json` and runs the same ai-factory gcf commands an operator runs in a console",
5
5
  "type": "module",
6
6
  "license": "MIT",