@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 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
- npx @quxkit/quxcloud link # pick the project this folder is
8
- npx @quxkit/quxcloud pull # bring it down
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quxkit/quxcloud",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Work on a QuxCloud project in your own editor.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
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 res = await fetch(`${url}${path}`, {
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
- say(`Next: ${bold('quxcloud link')} in your project folder.`);
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
- // npx @quxkit/quxcloud link point this folder at a project
6
- // npx @quxkit/quxcloud pull bring it down
7
- // npx @quxkit/quxcloud watch push every save
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
- logout: { run: logout, blurb: 'forget the key on this machine' },
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
- say(`\n${dim('Start with')} ${bold('quxcloud login')}${dim(', then')} ${bold('quxcloud link')}${dim(' in your project folder.')}`);
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 [, , command, ...rest] = process.argv;
38
- if (!command || command === 'help' || command === '--help' || command === '-h') {
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 (command === '--version' || command === '-v') {
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')} ${command}\n`);
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
- const urlFlag = rest.indexOf('--url');
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
  }
@@ -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
+ }