@promptster/teams-cli 0.6.1 → 0.7.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.
@@ -1,31 +1,66 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
 
4
- // Platform shim: selects and execs the right prebuilt Go binary for this OS/arch.
4
+ // Launcher: execs the MANAGED binary (~/.promptster-teams/bin), falling back to
5
+ // the copy bundled in node_modules. See lib/resolve.js for why the binary that
6
+ // runs must not be the one npm is tracking.
7
+ //
8
+ // Preferring `managed` is what keeps npm honest: self-update rewrites the
9
+ // managed file, node_modules is never touched, so `npm ls` keeps telling the
10
+ // truth about what npm installed.
11
+
5
12
  const { spawnSync } = require("child_process");
6
- const path = require("path");
7
13
  const fs = require("fs");
8
14
 
9
- const MAP = {
10
- "darwin-x64": "promptster-teams-darwin-x64",
11
- "darwin-arm64": "promptster-teams-darwin-arm64",
12
- "linux-x64": "promptster-teams-linux-x64",
13
- "linux-arm64": "promptster-teams-linux-arm64",
14
- "win32-x64": "promptster-teams-win32-x64.exe",
15
- "win32-arm64": "promptster-teams-win32-arm64.exe",
16
- };
17
-
18
- const key = `${process.platform}-${process.arch}`;
19
- const binName = MAP[key];
20
- if (!binName) {
21
- console.error(`promptster-teams: unsupported platform ${key}`);
22
- process.exit(1);
15
+ const {
16
+ PLATFORMS,
17
+ bundledBinPath,
18
+ managedBinPath,
19
+ isGlobalInstall,
20
+ platformKey,
21
+ platformPackage,
22
+ } = require("../lib/resolve");
23
+
24
+ function usable(p) {
25
+ if (!p) return false;
26
+ try {
27
+ fs.accessSync(p, fs.constants.X_OK);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
23
32
  }
24
33
 
25
- const binPath = path.join(__dirname, "..", "binaries", binName);
26
- if (!fs.existsSync(binPath)) {
27
- console.error(`promptster-teams: binary not found at ${binPath}`);
28
- console.error("The package may have installed incorrectly; try reinstalling.");
34
+ const bundled = bundledBinPath();
35
+
36
+ // A project-local install runs ITS OWN binary, never the shared managed one.
37
+ // The managed binary is per-user; a lockfile is a per-project pin. Pointing a
38
+ // local install at the shared file would mean a repo pinning 0.5.0 executes
39
+ // whatever another repo last installed — the lockfile would select nothing.
40
+ //
41
+ // Otherwise: managed first. If postinstall could not write it (--ignore-scripts,
42
+ // read-only home, unresolvable HOME), fall back to bundled so the CLI still runs
43
+ // — it just self-updates inside node_modules the way it used to, and npm ls goes
44
+ // back to drifting. Working-but-drifting beats not working.
45
+ const managed = isGlobalInstall() ? managedBinPath() : null;
46
+ const binPath = usable(managed) ? managed : bundled;
47
+
48
+ if (!usable(binPath)) {
49
+ // The binary now arrives as a per-platform optionalDependency, and npm treats
50
+ // a missing optional dep as a SUCCESSFUL install — no error, no warning. So
51
+ // this is the first and only place an engineer learns anything is wrong, and
52
+ // a bare "binary not found" would send them hunting the wrong thing. Name the
53
+ // package that is actually missing and the most likely cause.
54
+ if (!PLATFORMS.includes(platformKey())) {
55
+ console.error(`promptster-teams: unsupported platform ${platformKey()}`);
56
+ console.error(`Supported: ${PLATFORMS.join(", ")}`);
57
+ process.exit(1);
58
+ }
59
+ console.error(
60
+ `promptster-teams: no binary found — ${platformPackage()} is not installed.`
61
+ );
62
+ console.error("This usually means the install used --omit=optional/--no-optional.");
63
+ console.error("Fix: npm i -g @promptster/teams-cli (without omitting optional deps)");
29
64
  process.exit(1);
30
65
  }
31
66
 
package/lib/resolve.js ADDED
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+
3
+ // Shared path/platform resolution for the npm package.
4
+ //
5
+ // WHY THIS PACKAGE IS A LAUNCHER, NOT THE INSTALL
6
+ // ------------------------------------------------
7
+ // The Go binary self-updates by renaming a verified new build over its own
8
+ // executable. When the executable it swaps lives inside node_modules, npm's
9
+ // metadata immediately starts lying: `npm ls` / `npm outdated` keep reporting
10
+ // the version npm installed while the binary underneath has moved on, and a
11
+ // reinstall silently writes the older binary back.
12
+ //
13
+ // You cannot fix that by rewriting package.json behind npm's back — that is a
14
+ // hack no mature CLI does, and it does not even work for project-local installs
15
+ // (npm ls reads the lockfile, not the installed package.json — verified).
16
+ //
17
+ // So the binary does not live in node_modules. postinstall.js copies it to the
18
+ // MANAGED path (~/.promptster-teams/bin — the same place install.sh writes),
19
+ // and bin/promptster-teams.js execs that. node_modules keeps a pristine copy
20
+ // that nothing ever mutates, so npm's metadata stays true by construction, and
21
+ // self-update stays fully automatic — which matters because this is a daemon:
22
+ // unlike claude/codex, there is no human reading a "please upgrade" nudge.
23
+ //
24
+ // That pristine copy arrives via a per-platform optionalDependency gated on
25
+ // npm's os/cpu fields, so an install downloads ONE binary (~18MB) instead of the
26
+ // six (~74.5MB) that used to ship in a single tarball. The tradeoff: optional
27
+ // deps fail SILENTLY, so every consumer of bundledBinPath() must handle null.
28
+ //
29
+ // Keep this file dependency-free: postinstall must run before anything is
30
+ // installed, and the launcher is on the hot path of every invocation.
31
+
32
+ const fs = require("fs");
33
+ const os = require("os");
34
+ const path = require("path");
35
+
36
+ // The platform-arch keys we publish a binary package for. The package name is
37
+ // always `<wrapper>-<key>` and the binary inside is always named canonically,
38
+ // so the key is the only thing that varies.
39
+ const PLATFORMS = [
40
+ "darwin-x64",
41
+ "darwin-arm64",
42
+ "linux-x64",
43
+ "linux-arm64",
44
+ "win32-x64",
45
+ "win32-arm64",
46
+ ];
47
+
48
+ const PKG_PREFIX = "@promptster/teams-cli";
49
+
50
+ function platformKey() {
51
+ return `${process.platform}-${process.arch}`;
52
+ }
53
+
54
+ function platformPackage(key) {
55
+ return `${PKG_PREFIX}-${key || platformKey()}`;
56
+ }
57
+
58
+ // bundledBinPath is the pristine copy npm installed — now inside the per-platform
59
+ // optionalDependency (@promptster/teams-cli-darwin-arm64, …) rather than a
60
+ // binaries/ directory carrying all six. npm skips the packages whose os/cpu do
61
+ // not match the host, so exactly one is ever present.
62
+ //
63
+ // Returns null when the platform package is absent. That is a REAL state, not a
64
+ // theoretical one: optional dependencies fail SILENTLY by design, so
65
+ // --omit=optional, a partial registry publish, or an unsupported platform all
66
+ // land here with npm reporting success. Callers must degrade, never assume.
67
+ //
68
+ // Resolved via require.resolve rather than a hardcoded ../../ path so it works
69
+ // wherever the package manager actually put it — hoisted to a root
70
+ // node_modules, nested, or in a pnpm store.
71
+ function bundledBinPath() {
72
+ const key = platformKey();
73
+ if (!PLATFORMS.includes(key)) return null;
74
+ const binName = key.startsWith("win32-")
75
+ ? "promptster-teams.exe"
76
+ : "promptster-teams";
77
+ try {
78
+ const pkgJson = require.resolve(`${platformPackage(key)}/package.json`);
79
+ const p = path.join(path.dirname(pkgJson), binName);
80
+ return fs.existsSync(p) ? p : null;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ // managedBinPath MUST stay in lockstep with Go's state.CanonicalInstallBin and
87
+ // install.sh's INSTALL_DIR. All three name the same file; if they diverge, an
88
+ // npm install and a curl install manage two different binaries on one box and
89
+ // PATH decides which one the engineer actually runs.
90
+ function managedBinPath() {
91
+ const home = os.homedir();
92
+ if (!home) return null;
93
+ const name =
94
+ process.platform === "win32" ? "promptster-teams.exe" : "promptster-teams";
95
+ return path.join(home, ".promptster-teams", "bin", name);
96
+ }
97
+
98
+ // isGlobalInstall reports whether this package was installed into a GLOBAL
99
+ // prefix rather than a project's node_modules.
100
+ //
101
+ // This gate is why a project-local install does not touch the managed binary at
102
+ // all. The managed binary is per-USER and shared; a project's lockfile is a
103
+ // deliberate per-PROJECT pin. If a local install pointed at the shared binary,
104
+ // a repo pinning 0.5.0 and one pinning 0.6.1 would both execute whichever
105
+ // version happens to be in ~/.promptster-teams/bin — the lockfile would select
106
+ // nothing at all. That is strictly worse than the npm-ls drift this design
107
+ // removes, so local installs stay entirely inside their own node_modules.
108
+ //
109
+ // Mirrors nudgeFor's layout rules in internal/selfupdate/selfupdate.go: match
110
+ // path SEGMENTS (never substrings — a directory called my-node_modules-backup
111
+ // is not npm), and split on both separators so the check is host-independent.
112
+ function pathSegments(p) {
113
+ return String(p || "").split(/[/\\]/).filter(Boolean);
114
+ }
115
+
116
+ function isGlobalInstall(selfDir) {
117
+ const segs = pathSegments(selfDir === undefined ? __dirname : selfDir);
118
+ if (!segs.includes("node_modules")) {
119
+ // Not under node_modules at all (e.g. `npm link`, a checkout). Not a
120
+ // project-local install, so it may manage the shared binary.
121
+ return true;
122
+ }
123
+ const pnpm = segs.includes(".pnpm") || segs.includes("pnpm");
124
+ const adjacent = (a, b) =>
125
+ segs.some((s, i) => s === a && segs[i + 1] === b);
126
+
127
+ // pnpm's global prefix, e.g. ~/Library/pnpm/global/5/node_modules/...
128
+ if (pnpm && segs.includes("global")) return true;
129
+ // npm's global prefix: <prefix>/lib/node_modules (unix) or
130
+ // <AppData>\npm\node_modules (windows).
131
+ if (!pnpm && (adjacent("lib", "node_modules") || adjacent("npm", "node_modules"))) {
132
+ return true;
133
+ }
134
+ return false;
135
+ }
136
+
137
+ // parseVersion turns "1.2.3" into [1,2,3]; returns null for anything that is
138
+ // not a plain 3-part numeric version. Deliberately strict rather than a semver
139
+ // dependency: the release pipeline only ever produces x.y.z, and an unparseable
140
+ // version must fall through to "unknown", never to a wrong comparison.
141
+ function parseVersion(s) {
142
+ const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(s || "").trim());
143
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
144
+ }
145
+
146
+ // isNewer mirrors the Go updater's strictly-newer gate: a is newer than b.
147
+ function isNewer(a, b) {
148
+ const x = parseVersion(a);
149
+ const y = parseVersion(b);
150
+ if (!x || !y) return false;
151
+ for (let i = 0; i < 3; i++) {
152
+ if (x[i] !== y[i]) return x[i] > y[i];
153
+ }
154
+ return false;
155
+ }
156
+
157
+ module.exports = {
158
+ PLATFORMS,
159
+ platformKey,
160
+ platformPackage,
161
+ bundledBinPath,
162
+ managedBinPath,
163
+ isGlobalInstall,
164
+ parseVersion,
165
+ isNewer,
166
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptster/teams-cli",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "On-device, auditable AI-coding capture for internal engineering teams",
5
5
  "keywords": [
6
6
  "promptster",
@@ -21,11 +21,13 @@
21
21
  },
22
22
  "scripts": {
23
23
  "build": "node scripts/build.js",
24
+ "postinstall": "node scripts/postinstall.js",
24
25
  "prepublishOnly": "node scripts/build.js && node scripts/check-binaries.js"
25
26
  },
26
27
  "files": [
27
28
  "bin/",
28
- "binaries/",
29
+ "lib/",
30
+ "scripts/postinstall.js",
29
31
  "README.md"
30
32
  ],
31
33
  "engines": {
@@ -33,5 +35,13 @@
33
35
  },
34
36
  "publishConfig": {
35
37
  "access": "public"
38
+ },
39
+ "optionalDependencies": {
40
+ "@promptster/teams-cli-linux-x64": "0.7.0",
41
+ "@promptster/teams-cli-linux-arm64": "0.7.0",
42
+ "@promptster/teams-cli-darwin-x64": "0.7.0",
43
+ "@promptster/teams-cli-darwin-arm64": "0.7.0",
44
+ "@promptster/teams-cli-win32-x64": "0.7.0",
45
+ "@promptster/teams-cli-win32-arm64": "0.7.0"
36
46
  }
37
47
  }
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // Installs the platform binary from node_modules to the MANAGED path
5
+ // (~/.promptster-teams/bin/promptster-teams), which is what actually runs and
6
+ // what self-update owns. See lib/resolve.js for why the binary must not run
7
+ // from inside node_modules.
8
+ //
9
+ // Contract: this script must NEVER fail an npm install. A postinstall that
10
+ // exits non-zero aborts `npm i -g` and leaves the engineer with no CLI at all —
11
+ // far worse than the drift it exists to fix. Every failure path warns and exits
12
+ // 0; bin/promptster-teams.js then falls back to the bundled binary, which works
13
+ // exactly as it did before this file existed.
14
+
15
+ const { spawnSync } = require("child_process");
16
+ const fs = require("fs");
17
+ const path = require("path");
18
+
19
+ const {
20
+ PLATFORMS,
21
+ bundledBinPath,
22
+ managedBinPath,
23
+ isGlobalInstall,
24
+ isNewer,
25
+ platformKey,
26
+ platformPackage,
27
+ } = require("../lib/resolve");
28
+
29
+ function warn(msg) {
30
+ console.warn(`promptster-teams: ${msg}`);
31
+ }
32
+
33
+ // binVersion asks a binary what it is. One that cannot answer (missing,
34
+ // corrupt, wrong arch, not executable) reports null.
35
+ //
36
+ // Both sides of the downgrade guard go through this deliberately. The obvious
37
+ // shortcut is to compare package.json's version against the managed binary —
38
+ // but that trusts package.json to describe the bytes sitting in binaries/, and
39
+ // when those two disagree the guard makes its decision on a fiction (and the
40
+ // success log prints a version that was never installed). The pipeline does gate
41
+ // package.json against the tag, so they agree today; comparing the actual binary
42
+ // to the actual binary means the guard cannot be wrong even if that gate is ever
43
+ // lost. It costs one ~10ms spawn on install.
44
+ function binVersion(bin) {
45
+ if (!bin || !fs.existsSync(bin)) return null;
46
+ try {
47
+ const r = spawnSync(bin, ["--version"], {
48
+ encoding: "utf8",
49
+ timeout: 10_000,
50
+ });
51
+ if (r.status !== 0 || !r.stdout) return null;
52
+ return r.stdout.trim().split("\n")[0].trim() || null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ // repairAutostart re-points an existing launchd/systemd/schtasks unit at the
59
+ // managed binary.
60
+ //
61
+ // THIS IS A MIGRATION, and without it this package is a silent regression for
62
+ // every engineer who has run `autostart enable`. The unit bakes an ABSOLUTE
63
+ // path at enable time. Installs predating the managed-binary layout baked
64
+ // <pkg>/binaries/promptster-teams-<platform> — a path that no longer exists in
65
+ // the package, because the binary moved to a per-platform dependency and the
66
+ // wrapper stopped shipping binaries/ entirely. So `npm i -g` deletes the exact
67
+ // file the supervisor is pointed at.
68
+ //
69
+ // Nothing about that fails loudly. The running daemon holds its inode and keeps
70
+ // capturing, so the upgrade looks clean; the unit only breaks at the NEXT LOGIN,
71
+ // when launchd runs a path that is gone. Capture never comes back — the precise
72
+ // failure autostart exists to prevent.
73
+ //
74
+ // Delegated to the Go binary (`autostart repair`) rather than reimplemented
75
+ // here: this must not grow a plist writer, a systemd unit writer and a schtasks
76
+ // caller in Node, three platforms out of sync with the Go ones. `repair` is a
77
+ // no-op when autostart was never enabled and never exits non-zero.
78
+ function repairAutostart(managed) {
79
+ try {
80
+ const r = spawnSync(managed, ["autostart", "repair"], {
81
+ encoding: "utf8",
82
+ timeout: 30_000,
83
+ });
84
+ const out = `${r.stdout || ""}${r.stderr || ""}`.trim();
85
+ if (out) console.log(out);
86
+ } catch {
87
+ // Best-effort: a failed repair leaves autostart pointing at the old path,
88
+ // which the engineer can fix with `promptster-teams autostart enable`.
89
+ // Aborting the install over it would be strictly worse.
90
+ }
91
+ }
92
+
93
+ function main() {
94
+ // A project-local install must not touch the shared managed binary. The
95
+ // lockfile is a per-PROJECT pin; the managed binary is per-USER. Letting a
96
+ // local install write it means two repos pinning different versions fight
97
+ // over one file and neither gets what its lockfile selected — a worse bug
98
+ // than the npm-ls drift this whole design removes. Local installs keep
99
+ // running out of their own node_modules, exactly as before.
100
+ if (!isGlobalInstall()) {
101
+ console.log(
102
+ "promptster-teams: project-local install — leaving ~/.promptster-teams/bin alone " +
103
+ "and running the version your lockfile pins"
104
+ );
105
+ return;
106
+ }
107
+
108
+ const bundled = bundledBinPath();
109
+ if (!bundled) {
110
+ // Either an unsupported platform, or the optionalDependency carrying the
111
+ // binary is absent. npm treats a missing optional dep as SUCCESS and says
112
+ // nothing, so this warning is the only signal the engineer will ever get
113
+ // that they have a CLI with no binary behind it. Say which package.
114
+ if (!PLATFORMS.includes(platformKey())) {
115
+ warn(`unsupported platform ${platformKey()} — no binary to install`);
116
+ return;
117
+ }
118
+ warn(`${platformPackage()} is not installed, so there is no binary to install.`);
119
+ warn("If you used --omit=optional or --no-optional, reinstall without it.");
120
+ return;
121
+ }
122
+ const managed = managedBinPath();
123
+ if (!managed) {
124
+ warn("could not resolve home directory — skipping binary install");
125
+ return;
126
+ }
127
+
128
+ const incoming = binVersion(bundled);
129
+ const current = binVersion(managed);
130
+
131
+ if (!incoming) {
132
+ warn(`bundled binary at ${bundled} did not report a version — skipping`);
133
+ return;
134
+ }
135
+
136
+ // Never downgrade. The managed binary self-updates forward on its own, so it
137
+ // is routinely NEWER than whatever version npm is installing (that is the
138
+ // normal steady state, not an error). Clobbering it would hand the daemon an
139
+ // older build that immediately re-updates — churn, plus a window on a version
140
+ // the engineer already moved past. Mirrors the Go updater's isNewer gate.
141
+ //
142
+ // A managed binary that cannot report a version (current === null) is treated
143
+ // as absent and overwritten: a corrupt or half-written file should be
144
+ // replaced, not preserved by a guard meant to protect a GOOD newer build.
145
+ if (current && !isNewer(incoming, current)) {
146
+ console.log(
147
+ `promptster-teams: ${managed} is ${current}; not replacing it with ${incoming}`
148
+ );
149
+ return;
150
+ }
151
+
152
+ try {
153
+ fs.mkdirSync(path.dirname(managed), { recursive: true });
154
+ // Write to a temp file in the SAME directory, then rename: rename is atomic
155
+ // on POSIX, so a concurrent `promptster-teams` exec sees either the whole
156
+ // old binary or the whole new one, never a half-written file. Copying
157
+ // straight onto `managed` would also fail with ETXTBSY on Linux if the
158
+ // daemon is running.
159
+ const tmp = `${managed}.tmp-${process.pid}`;
160
+ fs.copyFileSync(bundled, tmp);
161
+ fs.chmodSync(tmp, 0o755);
162
+
163
+ // Re-check immediately before the swap. The guard above and this rename are
164
+ // not atomic with respect to the Go self-updater, which renames onto this
165
+ // same path from a live daemon: read 1.0.0 -> daemon installs 1.2.0 ->
166
+ // rename 1.1.0 over it, and the guard has been defeated.
167
+ //
168
+ // This narrows the window from "the whole copy" to the microseconds around
169
+ // the rename rather than closing it. Closing it needs a lock protocol shared
170
+ // by a Node script and a Go daemon, which is a lot of machinery for this
171
+ // failure: rename is atomic so the file is always ONE whole valid binary
172
+ // (never corrupt), the only cost is running an older version, and the daemon
173
+ // re-updates forward within one check interval (<=30m). Deliberate tradeoff,
174
+ // not an oversight — see CLAUDE.md.
175
+ const stillCurrent = binVersion(managed);
176
+ if (stillCurrent && !isNewer(incoming, stillCurrent)) {
177
+ fs.unlinkSync(tmp);
178
+ console.log(
179
+ `promptster-teams: ${managed} changed to ${stillCurrent} mid-install; leaving it`
180
+ );
181
+ return;
182
+ }
183
+ fs.renameSync(tmp, managed);
184
+ console.log(
185
+ `promptster-teams: installed ${incoming} to ${managed}${
186
+ current ? ` (was ${current})` : ""
187
+ }`
188
+ );
189
+ repairAutostart(managed);
190
+ } catch (err) {
191
+ // Falls back to the bundled binary via bin/promptster-teams.js.
192
+ warn(`could not install to ${managed}: ${err.message}`);
193
+ warn("falling back to the bundled binary (npm ls may report a stale version)");
194
+ }
195
+ }
196
+
197
+ try {
198
+ main();
199
+ } catch (err) {
200
+ // Belt and braces: nothing here may abort an npm install.
201
+ warn(`postinstall skipped: ${err && err.message}`);
202
+ }
@@ -1,6 +0,0 @@
1
- 2c8a4c5553788f5b6b781dcec1ab33fae2a0bff8f094370d9b0b143e975442d5 promptster-teams-linux-x64
2
- 1b2cfba34071904e43e42ee6b87ea4c442f7bc50e62ffe51c58623cbee1f9de5 promptster-teams-linux-arm64
3
- e15413b163e1b5fac9254342881b2f6ae790dcfa433018cd4210750dc0df0133 promptster-teams-darwin-x64
4
- 1ced87baf08d4af80e0515b4206ccd6d110ffe311390ed69868b2451e8e8d4cf promptster-teams-darwin-arm64
5
- 86cd9607458ffb76de60d06919bde579f5d89f1dc625903645909c5a2373c7f1 promptster-teams-win32-x64.exe
6
- 40f6278f6ec4216b896081820fe2178f0ad96bc9f49cedb6ca8843f4c8d40f0f promptster-teams-win32-arm64.exe
@@ -1,4 +0,0 @@
1
- untrusted comment: signature from minisign secret key
2
- RUSPnqFjEKp3+S04GPMUnLjuEyer/6aoLJjkuc9K2bVb0eC+0S0muKBqw8x6NQnIfyI562lAQQNWkTxhv75vIzcQuvqCyPeDjQ8=
3
- trusted comment: timestamp:1784117828 file:SHA256SUMS hashed
4
- ym2SDj/OeLtNJIB3eyPWAF2PAWeNS8ckXRaNO0+Euxychcqs7+mpmJH4HwrRvhlO4MhE5m8dXyj0zWAJjC46Bg==
Binary file