@profullstack/cli 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +47 -2
- package/index.d.ts +28 -0
- package/package.json +21 -4
- package/src/index.js +164 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Profullstack, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,48 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @profullstack/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The commands every Profullstack CLI shares, so no tool writes them twice:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
mytool update # update, the same way it was installed
|
|
7
|
+
mytool update --check # only say whether a newer version exists
|
|
8
|
+
mytool uninstall # remove it (asks first; --yes to skip; alias: remove)
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
It looks at where the running copy really lives and picks the matching tool:
|
|
12
|
+
|
|
13
|
+
| Installed with | `update` runs | `uninstall` runs |
|
|
14
|
+
| --- | --- | --- |
|
|
15
|
+
| our curl installer (and `w3bs install`) | the installer again, with your flags | `<prefix>/share/<name>/uninstall.sh` |
|
|
16
|
+
| `npm i -g` | `npm install -g pkg@latest` | `npm uninstall -g pkg` |
|
|
17
|
+
| `bun add -g` | `bun add -g pkg@latest` | `bun remove -g pkg` |
|
|
18
|
+
| `pnpm add -g` | `pnpm add -g pkg@latest` | `pnpm remove -g pkg` |
|
|
19
|
+
| `yarn global add` | `yarn global add pkg@latest` | `yarn global remove pkg` |
|
|
20
|
+
|
|
21
|
+
A copy it cannot place (a project's `node_modules`, a source checkout) is never
|
|
22
|
+
touched: it prints the command to run instead. `update` first asks the npm registry
|
|
23
|
+
for the latest version and does nothing when you already have it (`--force` reinstalls).
|
|
24
|
+
|
|
25
|
+
## Use
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
import { lifecycle, lifecycleUsage } from "@profullstack/cli";
|
|
29
|
+
|
|
30
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
31
|
+
if (await lifecycle(argv, {
|
|
32
|
+
name: "readm3", // command, and <prefix>/share/<name>
|
|
33
|
+
package: "@profullstack/readm3",
|
|
34
|
+
version: VERSION, // the running version
|
|
35
|
+
installer: "https://readm3.com/install.sh", // optional curl installer
|
|
36
|
+
installerArgs: () => desktopInstalled() ? ["--desktop"] : [], // flags to keep on update
|
|
37
|
+
})) return;
|
|
38
|
+
// ...the tool's own commands
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`lifecycle` returns `false` for any other command, sets `process.exitCode` on failure,
|
|
43
|
+
and refuses to uninstall without a terminal unless `--yes` is given. Put
|
|
44
|
+
`lifecycleUsage(name)` in your `--help`.
|
|
45
|
+
|
|
46
|
+
Also exported: `detectInstall`, `lifecycleCommand`, `latestVersion`, `compareVersions`.
|
|
47
|
+
|
|
48
|
+
No dependencies. Node 18+ and Bun. MIT.
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type InstallMethod = "installer" | "npm" | "bun" | "pnpm" | "yarn" | "local" | "unknown";
|
|
2
|
+
export interface Install { method: InstallMethod; share?: string; }
|
|
3
|
+
|
|
4
|
+
export interface LifecycleOptions {
|
|
5
|
+
/** The command name, and the folder our installer uses: <prefix>/share/<name>. */
|
|
6
|
+
name: string;
|
|
7
|
+
/** The npm package, e.g. "@profullstack/readm3". */
|
|
8
|
+
package: string;
|
|
9
|
+
/** The running version, compared with the registry's latest. */
|
|
10
|
+
version?: string;
|
|
11
|
+
/** The curl installer URL, re-run by `update` when the tool came from it. */
|
|
12
|
+
installer?: string;
|
|
13
|
+
/** Installer flags to keep on update, e.g. ["--desktop"] when the desktop app is installed. */
|
|
14
|
+
installerArgs?: () => string[] | Promise<string[]>;
|
|
15
|
+
/** The entry script of the running copy; defaults to the realpath of process.argv[1]. */
|
|
16
|
+
script?: string;
|
|
17
|
+
detect?: (input: { name: string; script?: string }) => Install;
|
|
18
|
+
io?: { stdin: { isTTY?: boolean }; stdout: { write(text: string): unknown }; stderr: { write(text: string): unknown } };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const LIFECYCLE_COMMANDS: string[];
|
|
22
|
+
export function lifecycleUsage(name: string): string;
|
|
23
|
+
export function detectInstall(input: { name: string; script?: string; exists?: (path: string) => boolean; npmRoot?: () => string | undefined }): Install;
|
|
24
|
+
export function lifecycleCommand(action: "update" | "uninstall", install: Install, options: { package: string; installer?: string; installerArgs?: string[] }): string[] | undefined;
|
|
25
|
+
export function latestVersion(pkg: string, registry?: string): Promise<string | undefined>;
|
|
26
|
+
export function compareVersions(a: string, b: string): -1 | 0 | 1;
|
|
27
|
+
/** Handles update/upgrade/uninstall/remove; false for any other command. */
|
|
28
|
+
export function lifecycle(argv: string[], options: LifecycleOptions): Promise<boolean>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "The commands every Profullstack CLI shares: `update` and `uninstall`, done the same way the tool was installed (curl installer, npm, bun, pnpm or yarn). No dependencies.",
|
|
6
|
+
"keywords": ["cli", "update", "self-update", "uninstall", "installer", "npm", "bun"],
|
|
7
|
+
"repository": { "type": "git", "url": "git+https://github.com/profullstack/cli.git" },
|
|
8
|
+
"homepage": "https://github.com/profullstack/cli#readme",
|
|
9
|
+
"bugs": { "url": "https://github.com/profullstack/cli/issues" },
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": "Profullstack, Inc.",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": { "types": "./index.d.ts", "import": "./src/index.js" },
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"types": "./index.d.ts",
|
|
17
|
+
"files": ["src", "index.d.ts", "README.md", "LICENSE"],
|
|
18
|
+
"sideEffects": false,
|
|
19
|
+
"engines": { "node": ">=18" },
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @profullstack/cli: the commands every Profullstack CLI should have and none should
|
|
3
|
+
* write twice. Today that is `update` and `uninstall` (alias `remove`): each finds out
|
|
4
|
+
* how the tool was installed (our curl installer, npm, bun, pnpm or yarn) and undoes or
|
|
5
|
+
* redoes it the same way, so `readm3 update` never installs a second copy beside the first.
|
|
6
|
+
*
|
|
7
|
+
* import { lifecycle } from "@profullstack/cli";
|
|
8
|
+
* if (await lifecycle(process.argv.slice(2), { name: "readm3", package: "@profullstack/readm3", version })) return;
|
|
9
|
+
*
|
|
10
|
+
* No dependencies; runs on Node 18+ and Bun.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
13
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
14
|
+
import { createInterface } from "node:readline/promises";
|
|
15
|
+
import { basename, dirname, join, sep } from "node:path";
|
|
16
|
+
|
|
17
|
+
export const LIFECYCLE_COMMANDS = ["update", "upgrade", "uninstall", "remove"];
|
|
18
|
+
|
|
19
|
+
/** Help lines for a tool's own --help. */
|
|
20
|
+
export function lifecycleUsage(name) {
|
|
21
|
+
return `Install
|
|
22
|
+
${name} update [--check] [--force] Update ${name} the way it was installed
|
|
23
|
+
${name} uninstall [--yes] Remove ${name} (also: ${name} remove)
|
|
24
|
+
`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How the running copy was installed, from where its entry script really lives.
|
|
29
|
+
* `installer` is our curl installer (also what `w3bs install` runs): it leaves
|
|
30
|
+
* <prefix>/share/<name>/uninstall.sh beside the copy it installed.
|
|
31
|
+
*/
|
|
32
|
+
export function detectInstall({ name, script, exists = existsSync, npmRoot = globalNpmRoot }) {
|
|
33
|
+
if (!script) return { method: "unknown" };
|
|
34
|
+
const path = script.split(sep).join("/");
|
|
35
|
+
for (let dir = dirname(script); dir !== dirname(dir); dir = dirname(dir)) {
|
|
36
|
+
if (basename(dir) === name && exists(join(dir, "uninstall.sh"))) return { method: "installer", share: dir };
|
|
37
|
+
}
|
|
38
|
+
if (path.includes("/.bun/install/global/")) return { method: "bun" };
|
|
39
|
+
if (/\/pnpm\/global\/|\/\.pnpm-global\/|\/pnpm-global\//.test(path)) return { method: "pnpm" };
|
|
40
|
+
if (/\/\.?yarn\/global\/|\/\.config\/yarn\/global\//.test(path)) return { method: "yarn" };
|
|
41
|
+
if (path.includes("/node_modules/")) {
|
|
42
|
+
const root = npmRoot();
|
|
43
|
+
if (root && path.startsWith(root.split(sep).join("/") + "/")) return { method: "npm" };
|
|
44
|
+
return { method: "local" };
|
|
45
|
+
}
|
|
46
|
+
return { method: "unknown" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function globalNpmRoot() {
|
|
50
|
+
const result = spawnSync("npm", ["root", "-g"], { encoding: "utf8", shell: process.platform === "win32" });
|
|
51
|
+
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const quote = (text) => `'${text.replaceAll("'", "'\\''")}'`;
|
|
55
|
+
|
|
56
|
+
/** The command that updates or removes an install, as [program, ...args]. */
|
|
57
|
+
export function lifecycleCommand(action, install, { package: pkg, installer, installerArgs = [] }) {
|
|
58
|
+
const latest = `${pkg}@latest`;
|
|
59
|
+
switch (install.method) {
|
|
60
|
+
case "installer":
|
|
61
|
+
if (action === "uninstall") return ["sh", join(install.share, "uninstall.sh")];
|
|
62
|
+
if (!installer) return undefined;
|
|
63
|
+
// INSTALL_PREFIX keeps a --prefix install where it is: share is <prefix>/share/<name>.
|
|
64
|
+
return ["sh", "-c", `curl -fsSL ${installer} | INSTALL_PREFIX=${quote(dirname(dirname(install.share)))} sh -s --${installerArgs.map((arg) => ` ${quote(arg)}`).join("")}`];
|
|
65
|
+
case "npm": return action === "update" ? ["npm", "install", "-g", latest] : ["npm", "uninstall", "-g", pkg];
|
|
66
|
+
case "bun": return action === "update" ? ["bun", "add", "-g", latest] : ["bun", "remove", "-g", pkg];
|
|
67
|
+
case "pnpm": return action === "update" ? ["pnpm", "add", "-g", latest] : ["pnpm", "remove", "-g", pkg];
|
|
68
|
+
case "yarn": return action === "update" ? ["yarn", "global", "add", latest] : ["yarn", "global", "remove", pkg];
|
|
69
|
+
default: return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The newest version on the registry, or undefined when it cannot be reached. */
|
|
74
|
+
export async function latestVersion(pkg, registry = process.env.npm_config_registry || "https://registry.npmjs.org") {
|
|
75
|
+
try {
|
|
76
|
+
const response = await fetch(`${registry.replace(/\/$/, "")}/${pkg.replace("/", "%2f")}/latest`, { signal: AbortSignal.timeout(10_000) });
|
|
77
|
+
if (!response.ok) return undefined;
|
|
78
|
+
return (await response.json()).version;
|
|
79
|
+
} catch { return undefined; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** -1, 0 or 1, for plain x.y.z versions; a prerelease sorts before its release. */
|
|
83
|
+
export function compareVersions(a, b) {
|
|
84
|
+
const parse = (v) => { const [core, pre] = String(v).replace(/^v/, "").split("-", 2); return { nums: core.split(".").map(Number), pre }; };
|
|
85
|
+
const x = parse(a), y = parse(b);
|
|
86
|
+
for (let i = 0; i < 3; i++) if ((x.nums[i] || 0) !== (y.nums[i] || 0)) return (x.nums[i] || 0) < (y.nums[i] || 0) ? -1 : 1;
|
|
87
|
+
if (x.pre === y.pre) return 0;
|
|
88
|
+
if (!x.pre) return 1;
|
|
89
|
+
if (!y.pre) return -1;
|
|
90
|
+
return x.pre < y.pre ? -1 : 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const describe = { installer: "the installer", npm: "npm", bun: "bun", pnpm: "pnpm", yarn: "yarn" };
|
|
94
|
+
|
|
95
|
+
function run([program, ...args]) {
|
|
96
|
+
return new Promise((done, fail) => {
|
|
97
|
+
const child = spawn(program, args, { stdio: "inherit", shell: process.platform === "win32" && program !== "sh" });
|
|
98
|
+
child.once("error", fail);
|
|
99
|
+
child.once("exit", (code) => done(code ?? 1));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function confirm(question, io) {
|
|
104
|
+
if (!io.stdin.isTTY) return false;
|
|
105
|
+
const rl = createInterface({ input: io.stdin, output: io.stdout });
|
|
106
|
+
try { return /^y(es)?$/i.test((await rl.question(`${question} [y/N] `)).trim()); } finally { rl.close(); }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Runs `update` / `uninstall` when argv asks for one; returns false for anything else,
|
|
111
|
+
* so the tool carries on with its own commands. Sets process.exitCode on failure.
|
|
112
|
+
*
|
|
113
|
+
* options: name, package, version (the running one), installer (the curl installer URL),
|
|
114
|
+
* installerArgs() (extra installer flags to keep on update, e.g. ["--desktop"]),
|
|
115
|
+
* script (defaults to process.argv[1]), io ({ stdin, stdout, stderr }).
|
|
116
|
+
*/
|
|
117
|
+
export async function lifecycle(argv, options) {
|
|
118
|
+
const [command, ...rest] = argv;
|
|
119
|
+
if (!LIFECYCLE_COMMANDS.includes(command)) return false;
|
|
120
|
+
const { name, package: pkg, version } = options;
|
|
121
|
+
const io = options.io ?? process;
|
|
122
|
+
const say = (text) => io.stdout.write(`${text}\n`);
|
|
123
|
+
const fail = (text, code = 1) => { io.stderr.write(`${name}: ${text}\n`); process.exitCode = code; return true; };
|
|
124
|
+
if (rest.includes("-h") || rest.includes("--help")) { io.stdout.write(lifecycleUsage(name)); return true; }
|
|
125
|
+
const action = command === "update" || command === "upgrade" ? "update" : "uninstall";
|
|
126
|
+
const known = action === "update" ? ["--check", "--force"] : ["--yes", "-y"];
|
|
127
|
+
const unknown = rest.find((arg) => !known.includes(arg));
|
|
128
|
+
if (unknown) return fail(`unknown option for ${command}: ${unknown}\n\n${lifecycleUsage(name)}`, 2);
|
|
129
|
+
|
|
130
|
+
const install = (options.detect ?? detectInstall)({ name, script: options.script ?? safeRealpath(process.argv[1]) });
|
|
131
|
+
const how = describe[install.method];
|
|
132
|
+
|
|
133
|
+
if (action === "update") {
|
|
134
|
+
const latest = await latestVersion(pkg);
|
|
135
|
+
if (latest && version) {
|
|
136
|
+
if (compareVersions(version, latest) >= 0 && !rest.includes("--force")) { say(`${name} ${version} is the latest.`); return true; }
|
|
137
|
+
say(`${name} ${version} → ${latest} available.`);
|
|
138
|
+
} else if (!latest) say(`Could not reach the npm registry to check for a newer ${name}.`);
|
|
139
|
+
if (rest.includes("--check")) return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const installerArgs = action === "update" && install.method === "installer" ? await (options.installerArgs?.() ?? []) : [];
|
|
143
|
+
const step = lifecycleCommand(action, install, { package: pkg, installer: options.installer, installerArgs });
|
|
144
|
+
if (!step) {
|
|
145
|
+
const manual = action === "update"
|
|
146
|
+
? `npm install -g ${pkg}@latest${options.installer ? `, or: curl -fsSL ${options.installer} | sh` : ""}`
|
|
147
|
+
: `npm uninstall -g ${pkg} (or remove it the way you installed it)`;
|
|
148
|
+
return fail(`cannot tell how this copy was installed (${install.method === "local" ? "a project's node_modules" : "not a global install"}). Run: ${manual}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (action === "uninstall" && !rest.includes("--yes") && !rest.includes("-y")) {
|
|
152
|
+
if (!io.stdin.isTTY) return fail(`not removing without a terminal to confirm; run: ${name} uninstall --yes`, 2);
|
|
153
|
+
if (!(await confirm(`Remove ${name} (installed with ${how})?`, io))) { say("Nothing removed."); return true; }
|
|
154
|
+
}
|
|
155
|
+
say(`${action === "update" ? "Updating" : "Removing"} ${name} with ${how}: ${step.join(" ")}`);
|
|
156
|
+
const code = await run(step);
|
|
157
|
+
if (code !== 0) return fail(`${step[0]} exited with ${code}`, code);
|
|
158
|
+
say(action === "update" ? `${name} is up to date.` : `${name} is removed.`);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function safeRealpath(path) {
|
|
163
|
+
try { return path ? realpathSync(path) : undefined; } catch { return path; }
|
|
164
|
+
}
|