@dotcomjack/grux 1.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 +79 -0
- package/bin/grux.js +115 -0
- package/lib/link.js +108 -0
- package/lib/locate.js +86 -0
- package/lib/ui.js +87 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# grux
|
|
2
|
+
|
|
3
|
+
The one command front door to [Grux OS](https://gruxai.com).
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx grux
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
That finds Grux.app on your Mac, puts `grux` on your PATH, and hands you to the
|
|
10
|
+
real command line. After the first run you can drop the `npx` and just type
|
|
11
|
+
`grux`.
|
|
12
|
+
|
|
13
|
+
## What this package is, and is not
|
|
14
|
+
|
|
15
|
+
It is a **launcher**, about 200 lines, with **no dependencies**.
|
|
16
|
+
|
|
17
|
+
Every actual command is the Swift binary that ships inside `Grux.app`, so there
|
|
18
|
+
is one implementation of the grammar and no chance of two front doors drifting
|
|
19
|
+
apart. This package never reimplements a command, never caches an answer, and
|
|
20
|
+
never talks to the network.
|
|
21
|
+
|
|
22
|
+
It does not download or install Grux. The command line lives inside the app
|
|
23
|
+
bundle rather than beside it, so there is no separate artifact to fetch. If the
|
|
24
|
+
app is not on your Mac, the launcher says so, prints the download link, and
|
|
25
|
+
changes nothing.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npx grux # link, then run setup (or status when not a terminal)
|
|
31
|
+
npx grux status # anything else is passed straight through
|
|
32
|
+
npx grux status --json # including flags, verbatim
|
|
33
|
+
npx grux --link-only # just put grux on the PATH, run nothing
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`--link-only` is the single flag this launcher consumes, and only as the first
|
|
37
|
+
argument. Everything else reaches the Swift CLI untouched.
|
|
38
|
+
|
|
39
|
+
## Exit codes
|
|
40
|
+
|
|
41
|
+
The same four the Swift CLI uses, forwarded rather than reinvented.
|
|
42
|
+
|
|
43
|
+
| Code | Meaning |
|
|
44
|
+
|---|---|
|
|
45
|
+
| 0 | Done. |
|
|
46
|
+
| 1 | Failed. |
|
|
47
|
+
| 2 | Waiting on you, on this Mac. Nothing any flag can do. |
|
|
48
|
+
| 3 | Run `grux doctor`. |
|
|
49
|
+
|
|
50
|
+
`npx grux` on a Mac with no Grux.app exits **2**, because installing it is
|
|
51
|
+
something only you can do.
|
|
52
|
+
|
|
53
|
+
## Where the link goes
|
|
54
|
+
|
|
55
|
+
The first directory that is both writable by you and already on your `PATH`,
|
|
56
|
+
preferring `~/.local/bin`, then `/opt/homebrew/bin`, then `/usr/local/bin`.
|
|
57
|
+
|
|
58
|
+
It never uses `sudo`. An installer that asks for your password to place a
|
|
59
|
+
symlink has taught you that Grux asks for passwords, and that habit costs more
|
|
60
|
+
than the convenience is worth.
|
|
61
|
+
|
|
62
|
+
If a file named `grux` is already there and it is not one of ours, the launcher
|
|
63
|
+
refuses and tells you. It will not remove somebody else's program.
|
|
64
|
+
|
|
65
|
+
## Environment
|
|
66
|
+
|
|
67
|
+
| Variable | Effect |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `GRUX_APP` | Use this bundle and no other. A miss is a miss, with no fallback. |
|
|
70
|
+
| `NO_COLOR` | Honoured on presence, so `NO_COLOR=0` still means no colour. |
|
|
71
|
+
|
|
72
|
+
## Requirements
|
|
73
|
+
|
|
74
|
+
macOS 14 or later, and [Grux.app](https://github.com/dotcomjack/grux/releases/latest).
|
|
75
|
+
Node 18 or later, which `npx` already implies.
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT.
|
package/bin/grux.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// The front door. `npx grux`.
|
|
4
|
+
//
|
|
5
|
+
// This package is a LAUNCHER, not a second command line. It owns exactly three
|
|
6
|
+
// jobs: find Grux.app, put `grux` on your PATH, and get out of the way. Every
|
|
7
|
+
// actual command is the Swift binary inside the app bundle, so there is one
|
|
8
|
+
// implementation of the grammar and no chance of the two drifting.
|
|
9
|
+
//
|
|
10
|
+
// The one shim flag is `--link-only`, and it is only recognised as the first
|
|
11
|
+
// argument. Everything else is passed through untouched, which is why
|
|
12
|
+
// `npx grux status --json` behaves exactly like `grux status --json`.
|
|
13
|
+
|
|
14
|
+
import { spawnSync } from 'node:child_process'
|
|
15
|
+
import { locate, appVersion, RELEASES } from '../lib/locate.js'
|
|
16
|
+
import { link, pathLineFor } from '../lib/link.js'
|
|
17
|
+
import { out, err, fill, rail, row, accent, dim } from '../lib/ui.js'
|
|
18
|
+
|
|
19
|
+
const DONE = 0
|
|
20
|
+
const FAILED = 1
|
|
21
|
+
const WAITING = 2
|
|
22
|
+
|
|
23
|
+
function bail(code, lines) {
|
|
24
|
+
for (const l of lines) err(l)
|
|
25
|
+
process.exit(code)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function notMac() {
|
|
29
|
+
err(rail('LOOK'))
|
|
30
|
+
err(row('needed', 'Grux runs on macOS', 'Grux is a native Mac app and its command line talks to that app over a local socket, so there is nothing here for this platform to run.'))
|
|
31
|
+
err('')
|
|
32
|
+
err(fill(`This machine reports ${process.platform}.`, 2))
|
|
33
|
+
err('')
|
|
34
|
+
process.exit(FAILED)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function notInstalled() {
|
|
38
|
+
out(rail('LOOK'))
|
|
39
|
+
out(row('needed', 'Grux.app is not on this Mac', 'The command line ships inside the app bundle. It is not a separate download, so there is nothing this launcher can usefully install on its own.'))
|
|
40
|
+
out('')
|
|
41
|
+
out(fill('Download it, drag it to Applications, then run this again:', 2))
|
|
42
|
+
out('')
|
|
43
|
+
out(' ' + accent(RELEASES))
|
|
44
|
+
out('')
|
|
45
|
+
out(dim(fill('Nothing was changed on this Mac.', 2)))
|
|
46
|
+
out('')
|
|
47
|
+
process.exit(WAITING)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function main() {
|
|
51
|
+
if (process.platform !== 'darwin') notMac()
|
|
52
|
+
|
|
53
|
+
const argv = process.argv.slice(2)
|
|
54
|
+
const linkOnly = argv[0] === '--link-only'
|
|
55
|
+
const passthrough = linkOnly ? argv.slice(1) : argv
|
|
56
|
+
|
|
57
|
+
const found = locate()
|
|
58
|
+
if (!found) notInstalled()
|
|
59
|
+
|
|
60
|
+
// Linking is best effort and never fatal. A reader who cannot write to any
|
|
61
|
+
// PATH directory can still run every command through `npx grux`, so a
|
|
62
|
+
// failure here costs convenience and nothing else. Reporting it as a hard
|
|
63
|
+
// error would be the launcher lying about how much it matters.
|
|
64
|
+
let linked = null
|
|
65
|
+
let linkError = null
|
|
66
|
+
try {
|
|
67
|
+
linked = link(found.bin)
|
|
68
|
+
} catch (e) {
|
|
69
|
+
linkError = e
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const quiet = passthrough.length > 0 && !linkOnly
|
|
73
|
+
|
|
74
|
+
if (!quiet) report(found, linked, linkError)
|
|
75
|
+
|
|
76
|
+
if (linkOnly) process.exit(linked ? DONE : FAILED)
|
|
77
|
+
|
|
78
|
+
// A terminal that cannot answer a question cannot be walked through setup,
|
|
79
|
+
// so the non interactive path lands on `status`, which states where you are
|
|
80
|
+
// and names the command that moves you. Same grammar, no hang.
|
|
81
|
+
const isTTY = process.stdin.isTTY === true && process.stdout.isTTY === true
|
|
82
|
+
const args = passthrough.length > 0
|
|
83
|
+
? passthrough
|
|
84
|
+
: (isTTY ? ['setup'] : ['status', '--no-input'])
|
|
85
|
+
|
|
86
|
+
const r = spawnSync(found.bin, args, { stdio: 'inherit' })
|
|
87
|
+
if (r.error) {
|
|
88
|
+
bail(FAILED, ['', fill(`Could not run ${found.bin}: ${r.error.message}`, 2), ''])
|
|
89
|
+
}
|
|
90
|
+
process.exit(r.status === null ? FAILED : r.status)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function report(found, linked, linkError) {
|
|
94
|
+
out(rail('LOOK'))
|
|
95
|
+
const v = appVersion(found.app)
|
|
96
|
+
out(row('ready', `Grux.app${v ? ' ' + v : ''}`, found.app))
|
|
97
|
+
|
|
98
|
+
if (linked && linked.status === 'already') {
|
|
99
|
+
out(row('ready', 'grux is already on your PATH', linked.path))
|
|
100
|
+
} else if (linked) {
|
|
101
|
+
out(row('ready', linked.status === 'replaced' ? 'grux relinked' : 'grux is now on your PATH', linked.path))
|
|
102
|
+
} else {
|
|
103
|
+
out(row('optional', 'grux is not on your PATH', `Could not write a link: ${linkError && linkError.message}. Every command still works through "npx grux".`))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (linked && linked.needsPathLine) {
|
|
107
|
+
out('')
|
|
108
|
+
out(fill(`${linked.dir} is not on your PATH. Add this to your shell profile to use "grux" directly:`, 2))
|
|
109
|
+
out('')
|
|
110
|
+
out(' ' + accent(pathLineFor(linked.dir)))
|
|
111
|
+
}
|
|
112
|
+
out('')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
main()
|
package/lib/link.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readlinkSync, symlinkSync, unlinkSync, accessSync, constants } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join, delimiter } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Directories we are willing to put the symlink in, best first.
|
|
7
|
+
*
|
|
8
|
+
* All three are conventional and none of them needs sudo when they are already
|
|
9
|
+
* yours. We never write to a directory owned by root: an installer that asks
|
|
10
|
+
* for a password to drop a symlink has taught the person that Grux asks for
|
|
11
|
+
* passwords, and that is a habit worth more than the convenience.
|
|
12
|
+
*/
|
|
13
|
+
export function targets() {
|
|
14
|
+
return [
|
|
15
|
+
join(homedir(), '.local', 'bin'),
|
|
16
|
+
'/opt/homebrew/bin',
|
|
17
|
+
'/usr/local/bin',
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const pathDirs = () => (process.env.PATH || '').split(delimiter).filter(Boolean)
|
|
22
|
+
|
|
23
|
+
export const onPath = (dir) => pathDirs().includes(dir)
|
|
24
|
+
|
|
25
|
+
function writable(dir) {
|
|
26
|
+
try {
|
|
27
|
+
accessSync(dir, constants.W_OK)
|
|
28
|
+
return true
|
|
29
|
+
} catch {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Choose where the link goes.
|
|
36
|
+
*
|
|
37
|
+
* Preference order is deliberate: a directory that is BOTH already on PATH and
|
|
38
|
+
* writable wins, because linking there is complete on its own and needs no
|
|
39
|
+
* follow up from the reader. Only when nothing qualifies do we fall back to
|
|
40
|
+
* creating ~/.local/bin, which then owes the reader a PATH line.
|
|
41
|
+
*/
|
|
42
|
+
export function chooseTarget() {
|
|
43
|
+
const t = targets()
|
|
44
|
+
for (const dir of t) if (existsSync(dir) && writable(dir) && onPath(dir)) return { dir, needsPathLine: false }
|
|
45
|
+
for (const dir of t) if (existsSync(dir) && writable(dir)) return { dir, needsPathLine: true }
|
|
46
|
+
return { dir: t[0], needsPathLine: !onPath(t[0]), mustCreate: true }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @returns {{status: 'linked'|'already'|'replaced', path: string, needsPathLine: boolean, dir: string}}
|
|
51
|
+
* @throws {Error} when the directory cannot be created or the link cannot be written.
|
|
52
|
+
*/
|
|
53
|
+
export function link(binPath) {
|
|
54
|
+
const { dir, needsPathLine, mustCreate } = chooseTarget()
|
|
55
|
+
if (mustCreate || !existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
56
|
+
|
|
57
|
+
const dest = join(dir, 'grux')
|
|
58
|
+
let status = 'linked'
|
|
59
|
+
|
|
60
|
+
if (existsSync(dest) || isBrokenLink(dest)) {
|
|
61
|
+
// An existing `grux` that already points where we want is a no-op, and
|
|
62
|
+
// saying "linked" about it would be a small lie in the one place a reader
|
|
63
|
+
// is deciding whether anything happened.
|
|
64
|
+
if (isSymlinkTo(dest, binPath)) return { status: 'already', path: dest, needsPathLine, dir }
|
|
65
|
+
|
|
66
|
+
// Anything else at that name is replaced ONLY when it is a symlink we
|
|
67
|
+
// recognise as ours. A real file called `grux` belongs to somebody else
|
|
68
|
+
// and removing it is not ours to do.
|
|
69
|
+
if (!isOurSymlink(dest)) {
|
|
70
|
+
const e = new Error(`${dest} already exists and is not a Grux symlink`)
|
|
71
|
+
e.code = 'EOCCUPIED'
|
|
72
|
+
throw e
|
|
73
|
+
}
|
|
74
|
+
unlinkSync(dest)
|
|
75
|
+
status = 'replaced'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
symlinkSync(binPath, dest)
|
|
79
|
+
return { status, path: dest, needsPathLine, dir }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isBrokenLink(p) {
|
|
83
|
+
try {
|
|
84
|
+
return lstatSync(p).isSymbolicLink()
|
|
85
|
+
} catch {
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isSymlinkTo(p, target) {
|
|
91
|
+
try {
|
|
92
|
+
return lstatSync(p).isSymbolicLink() && readlinkSync(p) === target
|
|
93
|
+
} catch {
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Ours means: a symlink pointing into a Grux.app bundle. */
|
|
99
|
+
function isOurSymlink(p) {
|
|
100
|
+
try {
|
|
101
|
+
if (!lstatSync(p).isSymbolicLink()) return false
|
|
102
|
+
return readlinkSync(p).includes('Grux.app/Contents/MacOS/')
|
|
103
|
+
} catch {
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const pathLineFor = (dir) => `export PATH="${dir}:$PATH"`
|
package/lib/locate.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs'
|
|
2
|
+
import { execFileSync } from 'node:child_process'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
export const BUNDLE_ID = 'com.gruxai.grux'
|
|
7
|
+
export const RELEASES = 'https://github.com/dotcomjack/grux/releases/latest'
|
|
8
|
+
|
|
9
|
+
// The binary lives inside the app bundle. It is not a separate download and
|
|
10
|
+
// there is deliberately no second copy of it anywhere, because two copies of a
|
|
11
|
+
// client that speaks a versioned socket protocol is how you get a mismatch
|
|
12
|
+
// that reports itself as a connection failure.
|
|
13
|
+
const REL_BIN = 'Contents/MacOS/grux-cli'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Every place a Grux.app can honestly be, cheapest lookup first.
|
|
17
|
+
* Spotlight is last because it is the only one that can be slow, and it is the
|
|
18
|
+
* only one that can find an app the person put somewhere unusual.
|
|
19
|
+
*/
|
|
20
|
+
export function candidates() {
|
|
21
|
+
// GRUX_APP is both a test seam and the honest answer for somebody who keeps
|
|
22
|
+
// their applications somewhere we do not guess. It is checked first and it
|
|
23
|
+
// is checked the same way as any other candidate: a bundle that does not
|
|
24
|
+
// carry the binary is still not a find, even when it was named explicitly.
|
|
25
|
+
const override = process.env.GRUX_APP
|
|
26
|
+
if (override) return [override]
|
|
27
|
+
return [
|
|
28
|
+
'/Applications/Grux.app',
|
|
29
|
+
join(homedir(), 'Applications', 'Grux.app'),
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// When the caller names an app explicitly, a miss is a miss. Falling back to
|
|
34
|
+
// Spotlight there would quietly run a DIFFERENT build than the one asked for,
|
|
35
|
+
// which is the single worst outcome for a launcher whose whole job is to be
|
|
36
|
+
// unambiguous about which binary it started.
|
|
37
|
+
export const overridden = () => Boolean(process.env.GRUX_APP)
|
|
38
|
+
|
|
39
|
+
function viaSpotlight() {
|
|
40
|
+
try {
|
|
41
|
+
const stdout = execFileSync(
|
|
42
|
+
'mdfind',
|
|
43
|
+
[`kMDItemCFBundleIdentifier == '${BUNDLE_ID}'`],
|
|
44
|
+
{ encoding: 'utf8', timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
45
|
+
)
|
|
46
|
+
return stdout.split('\n').map((s) => s.trim()).filter(Boolean)
|
|
47
|
+
} catch {
|
|
48
|
+
// Spotlight being off, indexing, or absent is not an error. It is one
|
|
49
|
+
// lookup of several and the caller has other ways to answer.
|
|
50
|
+
return []
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @returns {{app: string, bin: string} | null} The first bundle that actually
|
|
56
|
+
* carries an executable CLI, or null. A bundle without the binary is NOT a
|
|
57
|
+
* find: it is a partial or an old build, and reporting it as located is how a
|
|
58
|
+
* caller ends up exec'ing a path that is not there.
|
|
59
|
+
*/
|
|
60
|
+
export function locate() {
|
|
61
|
+
const search = overridden() ? candidates() : [...candidates(), ...viaSpotlight()]
|
|
62
|
+
for (const app of search) {
|
|
63
|
+
const bin = join(app, REL_BIN)
|
|
64
|
+
if (existsSync(bin)) {
|
|
65
|
+
try {
|
|
66
|
+
return { app: realpathSync(app), bin: realpathSync(bin) }
|
|
67
|
+
} catch {
|
|
68
|
+
return { app, bin }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The app's marketing version, or null when it cannot be read. */
|
|
76
|
+
export function appVersion(app) {
|
|
77
|
+
try {
|
|
78
|
+
return execFileSync(
|
|
79
|
+
'/usr/libexec/PlistBuddy',
|
|
80
|
+
['-c', 'Print :CFBundleShortVersionString', join(app, 'Contents/Info.plist')],
|
|
81
|
+
{ encoding: 'utf8', timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
82
|
+
).trim() || null
|
|
83
|
+
} catch {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
}
|
package/lib/ui.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Presentation only. No policy decisions live here.
|
|
2
|
+
//
|
|
3
|
+
// The rules this file exists to keep, all of them borrowed from the Swift CLI
|
|
4
|
+
// so the two front doors cannot drift apart:
|
|
5
|
+
//
|
|
6
|
+
// Colour is never the only carrier. Every state has a glyph and a word
|
|
7
|
+
// first, and the colour is decoration on top of something already legible.
|
|
8
|
+
// That is what makes NO_COLOR and a pipe safe rather than lossy.
|
|
9
|
+
//
|
|
10
|
+
// NO_COLOR is honoured on PRESENCE, not on value. `NO_COLOR=0` still means
|
|
11
|
+
// no colour, because that is what the standard says and a reader who set it
|
|
12
|
+
// to zero was not asking for colour back.
|
|
13
|
+
|
|
14
|
+
const ESC = '['
|
|
15
|
+
|
|
16
|
+
const isTTY = process.stdout.isTTY === true
|
|
17
|
+
|
|
18
|
+
// Presence, not truthiness. See above.
|
|
19
|
+
const noColor = Object.prototype.hasOwnProperty.call(process.env, 'NO_COLOR')
|
|
20
|
+
|
|
21
|
+
export const colorEnabled = isTTY && !noColor && process.env.TERM !== 'dumb'
|
|
22
|
+
|
|
23
|
+
const wrap = (code) => (s) => (colorEnabled ? `${ESC}${code}m${s}${ESC}0m` : s)
|
|
24
|
+
|
|
25
|
+
// One accent, and it means exactly one thing: "this is the thing to read".
|
|
26
|
+
export const accent = wrap('38;5;179')
|
|
27
|
+
export const dim = wrap('2')
|
|
28
|
+
export const bold = wrap('1')
|
|
29
|
+
|
|
30
|
+
// The glyph vocabulary is shared with the Swift CLI. Same word, same meaning,
|
|
31
|
+
// in the app, in the CLI and here.
|
|
32
|
+
export const GLYPH = {
|
|
33
|
+
ready: '+',
|
|
34
|
+
needed: '!',
|
|
35
|
+
waiting: '~',
|
|
36
|
+
optional: '-',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A terminal narrower than 60 columns is a terminal we still owe a readable
|
|
40
|
+
// answer to, so 60 is the floor rather than the assumption.
|
|
41
|
+
export function width() {
|
|
42
|
+
const c = process.stdout.columns
|
|
43
|
+
if (!c || !Number.isFinite(c)) return 76
|
|
44
|
+
return Math.max(60, Math.min(c, 88))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Wrap `text` to the usable width, hanging continuation lines under the first
|
|
49
|
+
* character of the text rather than under the marker that introduced it. A
|
|
50
|
+
* wrapped bullet that lines up with the dash reads as a new item, which is a
|
|
51
|
+
* bug that has been shipped on this project before.
|
|
52
|
+
*/
|
|
53
|
+
export function fill(text, indent = 2, hanging = indent) {
|
|
54
|
+
const room = width() - indent
|
|
55
|
+
const words = String(text).split(/\s+/).filter(Boolean)
|
|
56
|
+
const lines = []
|
|
57
|
+
let line = ''
|
|
58
|
+
for (const w of words) {
|
|
59
|
+
if (line === '') line = w
|
|
60
|
+
else if (line.length + 1 + w.length <= room) line += ' ' + w
|
|
61
|
+
else { lines.push(line); line = w }
|
|
62
|
+
}
|
|
63
|
+
if (line !== '') lines.push(line)
|
|
64
|
+
return lines
|
|
65
|
+
.map((l, i) => ' '.repeat(i === 0 ? indent : hanging) + l)
|
|
66
|
+
.join('\n')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const out = (s = '') => process.stdout.write(s + '\n')
|
|
70
|
+
export const err = (s = '') => process.stderr.write(s + '\n')
|
|
71
|
+
|
|
72
|
+
/** The six beat rail, with the current beat bracketed. Mirrors the Swift CLI. */
|
|
73
|
+
const BEATS = ['LOOK', 'CHOOSE', 'COST', 'GRANT', 'HAND OFF', 'PROVE']
|
|
74
|
+
export function rail(current) {
|
|
75
|
+
const cells = BEATS.map((b) => (b === current ? `[${b}]` : ` ${b} `))
|
|
76
|
+
const painted = colorEnabled
|
|
77
|
+
? cells.map((c, i) => (BEATS[i] === current ? accent(c) : dim(c)))
|
|
78
|
+
: cells
|
|
79
|
+
return '\n ' + painted.join(' ') + '\n'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A row whose glyph describes the SUBJECT of the row, not how it got here. */
|
|
83
|
+
export function row(state, label, detail) {
|
|
84
|
+
const g = Object.prototype.hasOwnProperty.call(GLYPH, state) ? GLYPH[state] : ' '
|
|
85
|
+
const head = ` ${g} ${label}`
|
|
86
|
+
return detail ? `${head}\n${fill(detail, 6)}` : head
|
|
87
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dotcomjack/grux",
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"description": "The one command front door to Grux OS. Finds the app, puts grux on your PATH, and hands you to the real command line.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"grux": "bin/grux.js"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"os": [
|
|
13
|
+
"darwin"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"lib",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test test/*.test.js"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"grux",
|
|
25
|
+
"macos",
|
|
26
|
+
"cli",
|
|
27
|
+
"agent",
|
|
28
|
+
"local-first"
|
|
29
|
+
],
|
|
30
|
+
"homepage": "https://gruxai.com",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/dotcomjack/grux.git"
|
|
34
|
+
},
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/dotcomjack/grux/issues"
|
|
37
|
+
},
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"author": "DotcomJack",
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
}
|
|
43
|
+
}
|