@quxkit/quxcloud 0.1.0 → 0.2.0
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 +35 -4
- package/package.json +1 -1
- package/src/api.mjs +12 -2
- package/src/commands.mjs +38 -3
- package/src/config.mjs +6 -0
- package/src/index.mjs +39 -15
- package/src/install.mjs +168 -0
package/README.md
CHANGED
|
@@ -4,9 +4,8 @@ Work on a QuxCloud project in your own editor.
|
|
|
4
4
|
|
|
5
5
|
```
|
|
6
6
|
npx @quxkit/quxcloud login # opens your browser; approve the code shown
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
npx @quxkit/quxcloud watch # every save goes back
|
|
7
|
+
quxcloud clone # pick the project this folder is, and bring it down
|
|
8
|
+
quxcloud watch # every save goes back
|
|
10
9
|
```
|
|
11
10
|
|
|
12
11
|
Nothing is typed into your shell. `login` prints a short code, opens QuxCloud
|
|
@@ -14,6 +13,35 @@ in your browser, and waits while you approve it there — so no key reaches your
|
|
|
14
13
|
history, your scrollback, or a screen share. The terminal receives an ordinary
|
|
15
14
|
workspace API key, listed under **API keys** and revocable there.
|
|
16
15
|
|
|
16
|
+
## The scope is typed once
|
|
17
|
+
|
|
18
|
+
`login` is the only command that carries `@quxkit/`, because it is the only one
|
|
19
|
+
you run before anything is installed. It installs the rest of itself:
|
|
20
|
+
|
|
21
|
+
| | |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `~/.config/quxcloud/lib` | the CLI — copied, not downloaded again; it has no dependencies |
|
|
24
|
+
| `~/.config/quxcloud/bin/quxcloud` | a three-line shim, put on your `PATH` with your say-so |
|
|
25
|
+
|
|
26
|
+
Not `npm i -g`, which binds the command to whichever Node you happened to be on
|
|
27
|
+
and needs `sudo` under a Homebrew prefix. If you decline the `PATH` line, the
|
|
28
|
+
command prints it for you to add yourself. `quxcloud upgrade` (or
|
|
29
|
+
`npx @quxkit/quxcloud@latest upgrade`) refreshes the copy;
|
|
30
|
+
`rm -rf ~/.config/quxcloud` removes it, key and all.
|
|
31
|
+
|
|
32
|
+
## The commands
|
|
33
|
+
|
|
34
|
+
| | |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `clone` | link this folder to a project and pull it — `--relink` to choose again |
|
|
37
|
+
| `pull` | bring the project down |
|
|
38
|
+
| `push` | send your edits back |
|
|
39
|
+
| `watch` | push on every save |
|
|
40
|
+
| `link` | point this folder at a different project |
|
|
41
|
+
| `whoami` | who this terminal is signed in as |
|
|
42
|
+
| `upgrade` | reinstall the `quxcloud` command |
|
|
43
|
+
| `logout` | forget the key on this machine |
|
|
44
|
+
|
|
17
45
|
## What does not come down
|
|
18
46
|
|
|
19
47
|
The `@quxkit` packages are licensed through your workspace and stay there, so
|
|
@@ -25,13 +53,16 @@ need to run the whole thing on your own machine.
|
|
|
25
53
|
## In CI
|
|
26
54
|
|
|
27
55
|
`QUXCLOUD_KEY` and `QUXCLOUD_PROJECT` still work and take precedence, so the
|
|
28
|
-
same CLI runs unattended without an interactive login.
|
|
56
|
+
same CLI runs unattended without an interactive login. Keep using
|
|
57
|
+
`npx @quxkit/quxcloud pull` there — a build agent has no shell profile to put
|
|
58
|
+
anything on, and nothing to gain from a second copy on disk.
|
|
29
59
|
|
|
30
60
|
## Where things are kept
|
|
31
61
|
|
|
32
62
|
| | |
|
|
33
63
|
|---|---|
|
|
34
64
|
| `~/.config/quxcloud/config.json` | the key, mode 0600, one per machine |
|
|
65
|
+
| `~/.config/quxcloud/bin`, `lib` | the installed `quxcloud` command |
|
|
35
66
|
| `./.quxcloud` | which project this folder is — no secrets, commit it or don't |
|
|
36
67
|
|
|
37
68
|
The bridge is on the paid plans.
|
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -8,14 +8,24 @@ export class ApiError extends Error {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export async function request(url, path, { method = 'GET', key, body } = {}) {
|
|
11
|
-
const
|
|
11
|
+
const options = {
|
|
12
12
|
method,
|
|
13
13
|
headers: {
|
|
14
14
|
...(key ? { authorization: `Bearer ${key}` } : {}),
|
|
15
15
|
...(body ? { 'content-type': 'application/json' } : {}),
|
|
16
16
|
},
|
|
17
17
|
...(body ? { body: JSON.stringify(body) } : {}),
|
|
18
|
-
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
let res;
|
|
21
|
+
try {
|
|
22
|
+
res = await fetch(`${url}${path}`, options);
|
|
23
|
+
} catch (cause) {
|
|
24
|
+
// Undici says "fetch failed" and puts the reason in `cause`. Unwrapped,
|
|
25
|
+
// it names neither the host nor what went wrong with it.
|
|
26
|
+
const why = cause?.cause?.code ?? cause?.cause?.message ?? cause?.message ?? 'unknown error';
|
|
27
|
+
throw new ApiError(`could not reach ${url} (${why}). Check the connection, or --url if this should be a different QuxCloud.`, 0);
|
|
28
|
+
}
|
|
19
29
|
const text = await res.text();
|
|
20
30
|
let json = {};
|
|
21
31
|
try {
|
package/src/commands.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { basename, dirname, join, relative, sep } from 'node:path';
|
|
|
12
12
|
|
|
13
13
|
import { request } from './api.mjs';
|
|
14
14
|
import { clearConfig, configPath, DEFAULT_URL, readConfig, readLink, resolve, writeConfig, writeLink } from './config.mjs';
|
|
15
|
+
import { installAndAnnounce } from './install.mjs';
|
|
15
16
|
import { ask, bold, choose, cyan, dim, die, green, openBrowser, say, spinner } from './ui.mjs';
|
|
16
17
|
|
|
17
18
|
const sha = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
@@ -56,7 +57,13 @@ export async function login(argv) {
|
|
|
56
57
|
spin.stop();
|
|
57
58
|
writeConfig({ ...readConfig(), url, key: result.json.key });
|
|
58
59
|
say(`${green('Signed in.')} The key is in ${dim(configPath())} and listed under API keys, revocable there.`);
|
|
59
|
-
|
|
60
|
+
|
|
61
|
+
// The one command that has to carry the scope is this one. Leave a
|
|
62
|
+
// `quxcloud` behind so no other command does.
|
|
63
|
+
say('');
|
|
64
|
+
await installAndAnnounce();
|
|
65
|
+
say('');
|
|
66
|
+
say(`Next: ${bold('quxcloud clone')} in your project folder.`);
|
|
60
67
|
return;
|
|
61
68
|
}
|
|
62
69
|
}
|
|
@@ -79,7 +86,7 @@ export async function whoami() {
|
|
|
79
86
|
|
|
80
87
|
/* ------------------------------------------------------------------- link -- */
|
|
81
88
|
|
|
82
|
-
export async function link() {
|
|
89
|
+
export async function link({ announceNext = true } = {}) {
|
|
83
90
|
const at = resolve({ needProject: false });
|
|
84
91
|
if (at.error) die(at.error);
|
|
85
92
|
const { json } = await request(at.url, '/api/cli/projects', { key: at.key });
|
|
@@ -97,7 +104,35 @@ export async function link() {
|
|
|
97
104
|
|
|
98
105
|
writeLink({ project: picked.id, name: picked.name, workspace: picked.workspace });
|
|
99
106
|
say(`\n${green('Linked')} this folder to ${bold(picked.name)}.`);
|
|
100
|
-
say(`Next: ${bold('quxcloud pull')}`);
|
|
107
|
+
if (announceNext) say(`Next: ${bold('quxcloud pull')}`);
|
|
108
|
+
return picked;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* ------------------------------------------------------------------ clone -- */
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Link and pull, which is what the first minute in a folder always is.
|
|
115
|
+
*
|
|
116
|
+
* Nobody links without pulling. Keeping them apart made the docs four lines
|
|
117
|
+
* long and made the second line a step you could forget between the first and
|
|
118
|
+
* the third. Already linked, this just pulls; `--relink` picks again.
|
|
119
|
+
*/
|
|
120
|
+
export async function clone(argv = {}) {
|
|
121
|
+
const existing = readLink();
|
|
122
|
+
if (existing.project && !argv.flags?.has('relink')) {
|
|
123
|
+
say(`Already linked to ${bold(existing.name ?? existing.project)}. ${dim('--relink to choose another.')}`);
|
|
124
|
+
} else {
|
|
125
|
+
await link({ announceNext: false });
|
|
126
|
+
}
|
|
127
|
+
await pull();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* ---------------------------------------------------------------- upgrade -- */
|
|
131
|
+
|
|
132
|
+
/** Re-install the shim from this copy — `npx @quxkit/quxcloud@latest upgrade`. */
|
|
133
|
+
export async function upgrade() {
|
|
134
|
+
const { version, lib } = await installAndAnnounce();
|
|
135
|
+
say(dim(`quxcloud ${version ?? '?'} in ${lib}`));
|
|
101
136
|
}
|
|
102
137
|
|
|
103
138
|
/* ------------------------------------------------------------------- sync -- */
|
package/src/config.mjs
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// ~/.config/quxcloud/config.json the key. One per machine, mode 0600, and
|
|
6
6
|
// NEVER in the project — a credential in a
|
|
7
7
|
// working tree is a credential in a commit.
|
|
8
|
+
// `bin/` and `lib/` beside it are the
|
|
9
|
+
// installed CLI — see install.mjs.
|
|
8
10
|
// ./.quxcloud which project this directory is. Belongs
|
|
9
11
|
// with the code, holds nothing secret, and
|
|
10
12
|
// is what makes `pull` work with no
|
|
@@ -41,6 +43,10 @@ export function clearConfig() {
|
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
export const configPath = () => CONFIG_FILE;
|
|
46
|
+
// The same directory holds the installed copy of the CLI. One place per
|
|
47
|
+
// machine for everything quxcloud keeps, and — unlike ~/.quxcloud — no
|
|
48
|
+
// collision with the `.quxcloud` link file of a project cloned into $HOME.
|
|
49
|
+
export const configDir = () => CONFIG_DIR;
|
|
44
50
|
|
|
45
51
|
export function readLink(root = process.cwd()) {
|
|
46
52
|
return readJson(join(root, LINK_FILE));
|
package/src/index.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// quxcloud — work on a QuxCloud project in your own editor.
|
|
3
3
|
//
|
|
4
|
-
// npx @quxkit/quxcloud login sign in through the browser
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// npx @quxkit/quxcloud login sign in through the browser, and install
|
|
5
|
+
// `quxcloud` so this is the last time the
|
|
6
|
+
// scope has to be typed
|
|
7
|
+
// quxcloud clone link this folder to a project and pull it
|
|
8
|
+
// quxcloud watch push every save
|
|
8
9
|
//
|
|
9
10
|
// npm rather than a downloadable binary, and the reason is Gatekeeper. An
|
|
10
11
|
// unsigned Mac binary opens to "cannot be opened because the developer cannot
|
|
@@ -13,42 +14,66 @@
|
|
|
13
14
|
// Anyone building a Next.js app already has Node, so `npx` is one command on
|
|
14
15
|
// all three platforms, needs no download, and updates itself.
|
|
15
16
|
|
|
16
|
-
import { link, login, logout, pull, push, watch, whoami } from './commands.mjs';
|
|
17
|
+
import { clone, link, login, logout, pull, push, upgrade, watch, whoami } from './commands.mjs';
|
|
18
|
+
import { onPath } from './install.mjs';
|
|
17
19
|
import { bold, die, dim, say } from './ui.mjs';
|
|
18
20
|
|
|
19
21
|
const COMMANDS = {
|
|
20
22
|
login: { run: login, blurb: 'sign in through your browser' },
|
|
21
|
-
|
|
22
|
-
whoami: { run: whoami, blurb: 'who this terminal is signed in as' },
|
|
23
|
-
link: { run: link, blurb: 'point this folder at a project' },
|
|
23
|
+
clone: { run: clone, blurb: 'link this folder to a project and pull it' },
|
|
24
24
|
pull: { run: pull, blurb: 'bring the project down' },
|
|
25
25
|
push: { run: push, blurb: 'send your edits back' },
|
|
26
26
|
watch: { run: watch, blurb: 'push on every save' },
|
|
27
|
+
link: { run: link, blurb: 'point this folder at a different project' },
|
|
28
|
+
whoami: { run: whoami, blurb: 'who this terminal is signed in as' },
|
|
29
|
+
upgrade: { run: upgrade, blurb: 'reinstall the `quxcloud` command' },
|
|
30
|
+
logout: { run: logout, blurb: 'forget the key on this machine' },
|
|
27
31
|
};
|
|
28
32
|
|
|
33
|
+
// Names people reach for that mean something already in the list. Cheaper to
|
|
34
|
+
// accept than to answer with `unknown command`.
|
|
35
|
+
const ALIASES = { setup: 'clone', start: 'clone', init: 'clone', install: 'upgrade', sync: 'push', status: 'whoami' };
|
|
36
|
+
|
|
29
37
|
function usage() {
|
|
30
38
|
say(`${bold('quxcloud')} — your editor, QuxCloud's runtime\n`);
|
|
31
39
|
for (const [name, { blurb }] of Object.entries(COMMANDS)) {
|
|
32
40
|
say(` ${bold(name.padEnd(8))} ${blurb}`);
|
|
33
41
|
}
|
|
34
|
-
|
|
42
|
+
// Only the first command carries the scope, so only the first command is
|
|
43
|
+
// shown with it.
|
|
44
|
+
say(`\n${dim('First time:')} ${bold('npx @quxkit/quxcloud login')}`);
|
|
45
|
+
say(`${dim('After that:')} ${bold('quxcloud clone')} ${dim('in your project folder, then')} ${bold('quxcloud watch')}`);
|
|
46
|
+
if (!onPath()) say(`\n${dim('`quxcloud` is not on this PATH yet — `npx @quxkit/quxcloud login` (or `upgrade`) puts it there.')}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Flags this small do not need a parser library. */
|
|
50
|
+
function parse(rest) {
|
|
51
|
+
const argv = { _: [], flags: new Set() };
|
|
52
|
+
for (let i = 0; i < rest.length; i++) {
|
|
53
|
+
const arg = rest[i];
|
|
54
|
+
if (arg === '--url') argv.url = rest[++i];
|
|
55
|
+
else if (arg.startsWith('--')) argv.flags.add(arg.slice(2));
|
|
56
|
+
else argv._.push(arg);
|
|
57
|
+
}
|
|
58
|
+
return argv;
|
|
35
59
|
}
|
|
36
60
|
|
|
37
|
-
const [, ,
|
|
38
|
-
if (!
|
|
61
|
+
const [, , word, ...rest] = process.argv;
|
|
62
|
+
if (!word || word === 'help' || word === '--help' || word === '-h') {
|
|
39
63
|
usage();
|
|
40
64
|
process.exit(0);
|
|
41
65
|
}
|
|
42
|
-
if (
|
|
66
|
+
if (word === '--version' || word === '-v') {
|
|
43
67
|
const { readFileSync } = await import('node:fs');
|
|
44
68
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
45
69
|
say(pkg.version);
|
|
46
70
|
process.exit(0);
|
|
47
71
|
}
|
|
48
72
|
|
|
73
|
+
const command = ALIASES[word] ?? word;
|
|
49
74
|
const entry = COMMANDS[command];
|
|
50
75
|
if (!entry) {
|
|
51
|
-
say(`${dim('unknown command')} ${
|
|
76
|
+
say(`${dim('unknown command')} ${word}\n`);
|
|
52
77
|
usage();
|
|
53
78
|
process.exit(1);
|
|
54
79
|
}
|
|
@@ -56,8 +81,7 @@ if (!entry) {
|
|
|
56
81
|
// One place where an unexpected failure becomes a sentence rather than a stack
|
|
57
82
|
// trace. A CLI that prints a stack is telling its user to read its source.
|
|
58
83
|
try {
|
|
59
|
-
|
|
60
|
-
await entry.run({ url: urlFlag === -1 ? undefined : rest[urlFlag + 1] });
|
|
84
|
+
await entry.run(parse(rest));
|
|
61
85
|
} catch (error) {
|
|
62
86
|
die(error?.message ?? String(error));
|
|
63
87
|
}
|
package/src/install.mjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Making `quxcloud` a word your shell knows.
|
|
2
|
+
//
|
|
3
|
+
// `npx @quxkit/quxcloud <cmd>` is the right way to run something once. It is
|
|
4
|
+
// the wrong way to run something ten times a day: eighteen characters of scope
|
|
5
|
+
// on every line, a package re-resolved on every invocation, and a name that
|
|
6
|
+
// does not match the one the docs use. So `login` — the one command that has
|
|
7
|
+
// to be typed in full, because nothing is installed yet — leaves a `quxcloud`
|
|
8
|
+
// behind.
|
|
9
|
+
//
|
|
10
|
+
// Not `npm i -g`: a global install needs whichever node the user happens to be
|
|
11
|
+
// on, writes into a prefix that is root-owned on a Homebrew node, and breaks
|
|
12
|
+
// when they switch nvm versions. Instead the package copies itself — it has no
|
|
13
|
+
// dependencies, so a copy is the whole thing — beside its config and drops a
|
|
14
|
+
// three-line shim next to it.
|
|
15
|
+
//
|
|
16
|
+
// ~/.config/quxcloud/lib/src/index.mjs the CLI itself
|
|
17
|
+
// ~/.config/quxcloud/bin/quxcloud exec node <lib>/src/index.mjs "$@"
|
|
18
|
+
//
|
|
19
|
+
// Beside the key, in the directory the CLI already owns — and deliberately not
|
|
20
|
+
// ~/.quxcloud, which would be the same path as the link file of a project
|
|
21
|
+
// cloned into the home directory.
|
|
22
|
+
//
|
|
23
|
+
// The only thing left is PATH, which a child process cannot set for its
|
|
24
|
+
// parent. We ask before touching a shell rc file, and when the answer is no
|
|
25
|
+
// (or there is nobody to ask) we print the one line to add.
|
|
26
|
+
|
|
27
|
+
import { appendFileSync, chmodSync, cpSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { basename, delimiter, dirname, join, resolve as resolvePath } from 'node:path';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
|
|
32
|
+
import { configDir } from './config.mjs';
|
|
33
|
+
import { ask, bold, dim, green, say } from './ui.mjs';
|
|
34
|
+
|
|
35
|
+
export const binDir = () => join(configDir(), 'bin');
|
|
36
|
+
const libDir = () => join(configDir(), 'lib');
|
|
37
|
+
export const shimPath = () => join(binDir(), process.platform === 'win32' ? 'quxcloud.cmd' : 'quxcloud');
|
|
38
|
+
|
|
39
|
+
/** The root of the package this process is running from. */
|
|
40
|
+
const packageRoot = () => resolvePath(dirname(fileURLToPath(import.meta.url)), '..');
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The same directory reached two ways has to compare equal.
|
|
44
|
+
*
|
|
45
|
+
* On macOS the temp dir is /var -> /private/var, and `import.meta.url` reports
|
|
46
|
+
* the resolved side while a configured path reports the symlink. Comparing the
|
|
47
|
+
* strings said "different", and `upgrade` from the installed copy then deleted
|
|
48
|
+
* the very files it was about to copy.
|
|
49
|
+
*/
|
|
50
|
+
const sameDir = (a, b) => {
|
|
51
|
+
const real = (p) => { try { return realpathSync(p); } catch { return resolvePath(p); } };
|
|
52
|
+
return real(a) === real(b);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const MARKER = '# added by quxcloud';
|
|
56
|
+
|
|
57
|
+
/** Is the shim's directory already somewhere the shell will look? */
|
|
58
|
+
export function onPath(env = process.env) {
|
|
59
|
+
const entries = (env.PATH ?? '').split(delimiter).filter(Boolean).map((p) => resolvePath(p));
|
|
60
|
+
return entries.includes(resolvePath(binDir()));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Copy the CLI beside the config and write the shim.
|
|
65
|
+
*
|
|
66
|
+
* Idempotent, and safe to run from the installed copy — which is what
|
|
67
|
+
* `quxcloud upgrade` does after npx has fetched a newer package.
|
|
68
|
+
*/
|
|
69
|
+
export function installShim() {
|
|
70
|
+
const source = packageRoot();
|
|
71
|
+
const lib = libDir();
|
|
72
|
+
mkdirSync(binDir(), { recursive: true });
|
|
73
|
+
|
|
74
|
+
if (!sameDir(source, lib)) {
|
|
75
|
+
// Staged, then swapped. Replacing rather than merging is the point — a
|
|
76
|
+
// file dropped in a later version has to disappear here too, or it stays
|
|
77
|
+
// loadable forever — but a copy that fails halfway must not leave the
|
|
78
|
+
// install gutted, so nothing is deleted until the new copy is complete.
|
|
79
|
+
const staged = `${lib}.incoming`;
|
|
80
|
+
rmSync(staged, { recursive: true, force: true });
|
|
81
|
+
mkdirSync(staged, { recursive: true });
|
|
82
|
+
cpSync(join(source, 'src'), join(staged, 'src'), { recursive: true });
|
|
83
|
+
cpSync(join(source, 'package.json'), join(staged, 'package.json'));
|
|
84
|
+
rmSync(lib, { recursive: true, force: true });
|
|
85
|
+
renameSync(staged, lib);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const entry = join(lib, 'src', 'index.mjs');
|
|
89
|
+
if (process.platform === 'win32') {
|
|
90
|
+
writeFileSync(shimPath(), `@echo off\r\nnode "${entry}" %*\r\n`);
|
|
91
|
+
} else {
|
|
92
|
+
writeFileSync(shimPath(), `#!/bin/sh\n${MARKER} — regenerated by \`quxcloud upgrade\`\nexec node "${entry}" "$@"\n`);
|
|
93
|
+
chmodSync(shimPath(), 0o755);
|
|
94
|
+
}
|
|
95
|
+
return { bin: binDir(), lib, version: version(lib) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function version(lib) {
|
|
99
|
+
try { return JSON.parse(readFileSync(join(lib, 'package.json'), 'utf8')).version; } catch { return null; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The rc file this person's shell actually reads, and the line to put in it. */
|
|
103
|
+
export function shellProfile(env = process.env, platform = process.platform) {
|
|
104
|
+
if (platform === 'win32') return null;
|
|
105
|
+
const shell = basename(env.SHELL ?? '');
|
|
106
|
+
const bin = binDir();
|
|
107
|
+
if (shell === 'fish') {
|
|
108
|
+
return { file: join(homedir(), '.config', 'fish', 'config.fish'), line: `fish_add_path ${bin}` };
|
|
109
|
+
}
|
|
110
|
+
if (shell === 'bash') {
|
|
111
|
+
// macOS Terminal opens login shells, which read .bash_profile and never
|
|
112
|
+
// .bashrc. Prefer whichever already exists.
|
|
113
|
+
const profile = join(homedir(), '.bash_profile');
|
|
114
|
+
const rc = join(homedir(), '.bashrc');
|
|
115
|
+
const file = platform === 'darwin' ? (existsSync(rc) && !existsSync(profile) ? rc : profile) : rc;
|
|
116
|
+
return { file, line: `export PATH="${bin}:$PATH"` };
|
|
117
|
+
}
|
|
118
|
+
return { file: join(homedir(), `.${shell || 'zsh'}rc`), line: `export PATH="${bin}:$PATH"` };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** True if this rc file already puts the shim on PATH. */
|
|
122
|
+
export function profileHasLine(file, line) {
|
|
123
|
+
try { return readFileSync(file, 'utf8').includes(line); } catch { return false; }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function writeProfile(file, line) {
|
|
127
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
128
|
+
appendFileSync(file, `\n${MARKER}\n${line}\n`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Install, then make sure the shell can find it — printing what happened.
|
|
133
|
+
*
|
|
134
|
+
* `consent` is asked only when there is a terminal to ask; unattended, we
|
|
135
|
+
* install and print, and change nobody's dotfiles behind their back.
|
|
136
|
+
*/
|
|
137
|
+
export async function installAndAnnounce({ interactive = process.stdin.isTTY } = {}) {
|
|
138
|
+
const installed = installShim();
|
|
139
|
+
const { bin } = installed;
|
|
140
|
+
const done = (path) => ({ ...installed, path });
|
|
141
|
+
|
|
142
|
+
if (onPath()) {
|
|
143
|
+
say(`${green('Installed.')} ${bold('quxcloud')} is on your PATH — the scope was only ever needed for ${bold('login')}.`);
|
|
144
|
+
return done('already');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const profile = shellProfile();
|
|
148
|
+
if (profile && profileHasLine(profile.file, profile.line)) {
|
|
149
|
+
say(`${green('Installed.')} ${bold(profile.file)} already adds it — open a new terminal and ${bold('quxcloud')} is a command.`);
|
|
150
|
+
return done('pending-reload');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (profile && interactive) {
|
|
154
|
+
const answer = (await ask(`Add ${bold(bin)} to your PATH in ${bold(profile.file)}? ${dim('[Y/n]')} `)).toLowerCase();
|
|
155
|
+
if (answer === '' || answer === 'y' || answer === 'yes') {
|
|
156
|
+
writeProfile(profile.file, profile.line);
|
|
157
|
+
say(`${green('Installed.')} Open a new terminal, or run ${bold(profile.line)} in this one.`);
|
|
158
|
+
say(dim('From then on it is `quxcloud pull`, not the whole scope.'));
|
|
159
|
+
return done('written');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
say(`${green('Installed')} to ${dim(bin)}. Add it to your PATH to drop the scope:`);
|
|
164
|
+
say('');
|
|
165
|
+
say(` ${bold(profile?.line ?? `set PATH=${bin};%PATH%`)}`);
|
|
166
|
+
say('');
|
|
167
|
+
return done('printed');
|
|
168
|
+
}
|