@4pm/cli 0.2.2 → 0.3.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.
Files changed (2) hide show
  1. package/dist/index.js +162 -37
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3725,18 +3725,41 @@ import {
3725
3725
  chmodSync,
3726
3726
  existsSync as existsSync2,
3727
3727
  readFileSync as readFileSync2,
3728
- rmSync,
3728
+ rmSync as rmSync2,
3729
3729
  writeFileSync as writeFileSync2
3730
3730
  } from "fs";
3731
3731
  import { homedir as homedir2 } from "os";
3732
3732
  import { join as join2 } from "path";
3733
3733
 
3734
3734
  // src/config/profile.ts
3735
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3735
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
3736
3736
  import { homedir } from "os";
3737
3737
  import { join } from "path";
3738
3738
  var DEFAULT_PROFILE = "default";
3739
- function resolveProfile(args) {
3739
+ function defaultPointerPath() {
3740
+ return join(homedir(), ".4pm", "default");
3741
+ }
3742
+ function defaultProfileName() {
3743
+ try {
3744
+ const userId = readFileSync(defaultPointerPath(), "utf8").trim();
3745
+ if (userId) return userId;
3746
+ } catch {
3747
+ }
3748
+ return DEFAULT_PROFILE;
3749
+ }
3750
+ function writeDefaultProfile(userId) {
3751
+ mkdirSync(join(homedir(), ".4pm"), { recursive: true });
3752
+ writeFileSync(defaultPointerPath(), userId, "utf8");
3753
+ }
3754
+ function clearDefaultProfileIf(name) {
3755
+ try {
3756
+ if (readFileSync(defaultPointerPath(), "utf8").trim() === name) {
3757
+ rmSync(defaultPointerPath(), { force: true });
3758
+ }
3759
+ } catch {
3760
+ }
3761
+ }
3762
+ function resolveProfileArg(args) {
3740
3763
  const index = args.indexOf("--profile");
3741
3764
  if (index >= 0) {
3742
3765
  const name = args[index + 1];
@@ -3746,7 +3769,7 @@ function resolveProfile(args) {
3746
3769
  const rest = [...args.slice(0, index), ...args.slice(index + 2)];
3747
3770
  return [name, rest];
3748
3771
  }
3749
- return [process.env.FOURPM_PROFILE ?? DEFAULT_PROFILE, args];
3772
+ return [process.env.FOURPM_PROFILE ?? null, args];
3750
3773
  }
3751
3774
  function profileDir(name) {
3752
3775
  const dir = join(homedir(), ".4pm", "profiles", name);
@@ -3777,7 +3800,7 @@ function readCredential(profileDir2, profileName) {
3777
3800
  readFileSync2(LEGACY_CRE_PATH, "utf8")
3778
3801
  );
3779
3802
  writeCredential(profileDir2, legacy);
3780
- rmSync(LEGACY_CRE_PATH, { force: true });
3803
+ rmSync2(LEGACY_CRE_PATH, { force: true });
3781
3804
  return legacy;
3782
3805
  } catch {
3783
3806
  return null;
@@ -3797,7 +3820,7 @@ function writeCredential(profileDir2, credential) {
3797
3820
  chmodSync(path, 384);
3798
3821
  }
3799
3822
  function deleteCredential(profileDir2) {
3800
- rmSync(credentialPath(profileDir2), { force: true });
3823
+ rmSync2(credentialPath(profileDir2), { force: true });
3801
3824
  }
3802
3825
 
3803
3826
  // src/core/fingerprint.ts
@@ -3884,15 +3907,17 @@ async function fetchCliVersion(serverUrl) {
3884
3907
  }
3885
3908
 
3886
3909
  // src/commands/link.ts
3887
- async function runLink(serverUrl, profileDir2, profileName) {
3888
- if (readCredential(profileDir2, profileName)) {
3910
+ async function runLink(serverUrl, explicitProfile) {
3911
+ const currentName = explicitProfile ?? defaultProfileName();
3912
+ if (readCredential(profileDir(currentName), currentName)) {
3913
+ const flag = explicitProfile ? ` --profile ${explicitProfile}` : "";
3889
3914
  console.log(
3890
- `Profile "${profileName}" is already linked (.cre). Run \`4pm unlink --profile ${profileName}\` first if you want to pair again.`
3915
+ `Profile "${currentName}" is already linked (.cre). Run \`4pm unlink${flag}\` first if you want to pair again.`
3891
3916
  );
3892
3917
  return;
3893
3918
  }
3894
3919
  const hashcode1 = randomBytes(32).toString("hex");
3895
- console.log(`\u2500\u2500 4PM cli pairing (profile: ${profileName}) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);
3920
+ console.log(`\u2500\u2500 4PM cli pairing (profile: ${explicitProfile ?? "default"}) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);
3896
3921
  console.log("1. Open the 4PM web \u2192 /machines, enter hashcode (1):");
3897
3922
  console.log(`
3898
3923
  ${hashcode1}
@@ -3901,15 +3926,23 @@ async function runLink(serverUrl, profileDir2, profileName) {
3901
3926
  const rl = createInterface({ input: process.stdin, output: process.stdout });
3902
3927
  const hashcode2 = (await rl.question("hashcode (2): ")).trim();
3903
3928
  rl.close();
3904
- const { hashcode3 } = await confirmPairing(serverUrl, hashcode2, collectFingerprint());
3905
- writeCredential(profileDir2, {
3929
+ const { hashcode3, userId, username } = await confirmPairing(
3930
+ serverUrl,
3931
+ hashcode2,
3932
+ collectFingerprint()
3933
+ );
3934
+ const profileName = explicitProfile ?? userId;
3935
+ writeCredential(profileDir(profileName), {
3906
3936
  serverUrl,
3907
3937
  hashcode3,
3908
3938
  scope: "unknown",
3909
3939
  // scope is decided by the server based on the pairing user (ADR-0010)
3910
3940
  pairedAt: (/* @__PURE__ */ new Date()).toISOString()
3911
3941
  });
3912
- console.log("\u2714 Linked successfully \u2014 saved .cre. Run `4pm start` to connect.");
3942
+ if (!explicitProfile) writeDefaultProfile(userId);
3943
+ console.log(
3944
+ `\u2714 Linked successfully as "${username}" \u2014 saved .cre. Run \`4pm start\` to connect.`
3945
+ );
3913
3946
  }
3914
3947
 
3915
3948
  // src/commands/start.ts
@@ -3930,7 +3963,7 @@ MCowBQYDK2VwAyEARlfzjaFhe5bhRrMp/ZuH6sVqg/S7f8B1dliuF21iDTw=
3930
3963
  `;
3931
3964
 
3932
3965
  // src/version.ts
3933
- var CLI_VERSION = "0.2.2";
3966
+ var CLI_VERSION = "0.3.1";
3934
3967
 
3935
3968
  // src/core/update.ts
3936
3969
  function compareSemver(a, b) {
@@ -4074,6 +4107,7 @@ var Role = {
4074
4107
  };
4075
4108
  var ALL_ROLES = Object.values(Role);
4076
4109
  var EXCLUSIVE_ROLES = [Role.ADMIN, Role.MACHINE];
4110
+ var PM_GRANTABLE_ROLES = [Role.SA_SE, Role.QA, Role.BA];
4077
4111
 
4078
4112
  // ../../22-packages/11-shared/11-constants/src/permissions.ts
4079
4113
  var PermissionCode = {
@@ -4260,6 +4294,13 @@ var SELF_SERVICE_CODES = [
4260
4294
  P.USER_READ_SELF,
4261
4295
  P.NOTIFICATION_READ
4262
4296
  ];
4297
+ var ADMIN_ONLY_CODES = [
4298
+ P.ORG_READ,
4299
+ P.ORG_UPDATE,
4300
+ P.ORG_DELETE,
4301
+ P.BILLING_READ,
4302
+ P.BILLING_MANAGE
4303
+ ];
4263
4304
  var TEAM_GRANTABLE_CODES = [
4264
4305
  P.TEAM_READ,
4265
4306
  P.TEAM_UPDATE,
@@ -4302,10 +4343,11 @@ var PROJECT_GRANTABLE_CODES = [
4302
4343
  P.MACHINE_PHYSIC_MANAGE
4303
4344
  ];
4304
4345
  var SELF_SERVICE_SET = new Set(SELF_SERVICE_CODES);
4346
+ var ADMIN_ONLY_SET = new Set(ADMIN_ONLY_CODES);
4305
4347
  var TEAM_GRANTABLE_SET = new Set(TEAM_GRANTABLE_CODES);
4306
4348
  var PROJECT_GRANTABLE_SET = new Set(PROJECT_GRANTABLE_CODES);
4307
4349
  function grantSubjectsFor(code) {
4308
- if (SELF_SERVICE_SET.has(code)) return [];
4350
+ if (SELF_SERVICE_SET.has(code) || ADMIN_ONLY_SET.has(code)) return [];
4309
4351
  const subjects = [GrantSubjectType.USER];
4310
4352
  if (TEAM_GRANTABLE_SET.has(code)) subjects.push(GrantSubjectType.TEAM);
4311
4353
  if (PROJECT_GRANTABLE_SET.has(code)) subjects.push(GrantSubjectType.PROJECT);
@@ -4835,6 +4877,7 @@ async function addProject(payload, onProgress) {
4835
4877
 
4836
4878
  // src/core/ws-client.ts
4837
4879
  var HEARTBEAT_MS = 3e4;
4880
+ var MAX_HASHCODE_INVALID_RETRIES = 5;
4838
4881
  var CLOSE_DRAINING = 4001;
4839
4882
  var OFFLINE_BUFFER_MAX_BYTES = 512 * 1024;
4840
4883
  var WsClient = class {
@@ -4859,16 +4902,32 @@ var WsClient = class {
4859
4902
  * Main lifecycle: connect + reconnect backoff until revoked.
4860
4903
  */
4861
4904
  async run() {
4905
+ let hashcodeInvalidStreak = 0;
4862
4906
  for (let attempt = 0; !this.stopped; attempt++) {
4863
4907
  try {
4864
4908
  await this.connectOnce();
4865
4909
  attempt = 0;
4910
+ hashcodeInvalidStreak = 0;
4866
4911
  } catch (err) {
4867
- if (err instanceof CliApiError && ["HASHCODE_INVALID", "HASHCODE_EXPIRED", "LINK_REVOKED"].includes(err.errorCode)) {
4868
- this.logout(err.errorCode);
4869
- return;
4912
+ if (err instanceof CliApiError) {
4913
+ if (["HASHCODE_EXPIRED", "LINK_REVOKED"].includes(err.errorCode)) {
4914
+ this.logout(err.errorCode);
4915
+ return;
4916
+ }
4917
+ if (err.errorCode === "HASHCODE_INVALID") {
4918
+ if (++hashcodeInvalidStreak > MAX_HASHCODE_INVALID_RETRIES) {
4919
+ this.logout(err.errorCode);
4920
+ return;
4921
+ }
4922
+ console.warn(
4923
+ `Token request rejected (HASHCODE_INVALID) \u2014 retrying (${hashcodeInvalidStreak}/${MAX_HASHCODE_INVALID_RETRIES}).`
4924
+ );
4925
+ } else {
4926
+ console.error(`Connection error: ${err}`);
4927
+ }
4928
+ } else {
4929
+ console.error(`Connection error: ${err}`);
4870
4930
  }
4871
- console.error(`Connection error: ${err}`);
4872
4931
  }
4873
4932
  if (this.stopped) return;
4874
4933
  const wait = backoffJitterMs(Math.min(attempt, 6), 1e3, 6e4);
@@ -4946,6 +5005,14 @@ var WsClient = class {
4946
5005
  this.socket?.close();
4947
5006
  return;
4948
5007
  }
5008
+ if (message.errorCode === "SESSION_REPLACED") {
5009
+ this.stopped = true;
5010
+ console.error(
5011
+ "Another session for this profile took over \u2014 this instance is stopping. (Are you running `4pm start` twice for the same profile?)"
5012
+ );
5013
+ this.socket?.close();
5014
+ return;
5015
+ }
4949
5016
  console.warn(`Server reported an error: ${message.errorCode}`);
4950
5017
  this.socket?.close();
4951
5018
  return;
@@ -5149,6 +5216,41 @@ var WsClient = class {
5149
5216
  }
5150
5217
  };
5151
5218
 
5219
+ // src/core/instance-lock.ts
5220
+ import { readFileSync as readFileSync5, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "fs";
5221
+ import { join as join6 } from "path";
5222
+ function lockPath(profileDir2) {
5223
+ return join6(profileDir2, "start.lock");
5224
+ }
5225
+ function isAlive(pid) {
5226
+ try {
5227
+ process.kill(pid, 0);
5228
+ return true;
5229
+ } catch (err) {
5230
+ return err.code === "EPERM";
5231
+ }
5232
+ }
5233
+ function lockHolder(profileDir2) {
5234
+ try {
5235
+ const pid = Number.parseInt(readFileSync5(lockPath(profileDir2), "utf8").trim(), 10);
5236
+ if (Number.isInteger(pid) && pid > 0 && isAlive(pid)) return pid;
5237
+ } catch {
5238
+ }
5239
+ return null;
5240
+ }
5241
+ function acquireInstanceLock(profileDir2) {
5242
+ if (lockHolder(profileDir2) !== null) return false;
5243
+ writeFileSync5(lockPath(profileDir2), String(process.pid), "utf8");
5244
+ return true;
5245
+ }
5246
+ function releaseInstanceLock(profileDir2) {
5247
+ try {
5248
+ const pid = Number.parseInt(readFileSync5(lockPath(profileDir2), "utf8").trim(), 10);
5249
+ if (pid === process.pid) rmSync3(lockPath(profileDir2), { force: true });
5250
+ } catch {
5251
+ }
5252
+ }
5253
+
5152
5254
  // src/commands/start.ts
5153
5255
  async function runStart(profileDir2, profileName) {
5154
5256
  const credential = readCredential(profileDir2, profileName);
@@ -5178,13 +5280,28 @@ async function runStart(profileDir2, profileName) {
5178
5280
  child.on("exit", (code) => process.exit(code ?? 0));
5179
5281
  return;
5180
5282
  }
5181
- initCommandHistory(profileDir2, profileName, config.commandHistoryUploadMinutes ?? 10);
5182
- console.log(`Connecting to ${credential.serverUrl} (profile: ${profileName})\u2026`);
5183
- await new WsClient({
5184
- credential,
5185
- machine: collectFingerprint(),
5186
- profileDir: profileDir2
5187
- }).run();
5283
+ if (!acquireInstanceLock(profileDir2)) {
5284
+ console.error(
5285
+ `Profile "${profileName}" is already running (pid ${lockHolder(profileDir2)}). Only one \`4pm start\` per profile is allowed.`
5286
+ );
5287
+ process.exitCode = 1;
5288
+ return;
5289
+ }
5290
+ const release = () => releaseInstanceLock(profileDir2);
5291
+ process.once("exit", release);
5292
+ process.once("SIGINT", () => process.exit(0));
5293
+ process.once("SIGTERM", () => process.exit(0));
5294
+ try {
5295
+ initCommandHistory(profileDir2, profileName, config.commandHistoryUploadMinutes ?? 10);
5296
+ console.log(`Connecting to ${credential.serverUrl} (profile: ${profileName})\u2026`);
5297
+ await new WsClient({
5298
+ credential,
5299
+ machine: collectFingerprint(),
5300
+ profileDir: profileDir2
5301
+ }).run();
5302
+ } finally {
5303
+ release();
5304
+ }
5188
5305
  }
5189
5306
 
5190
5307
  // src/commands/unlink.ts
@@ -5194,6 +5311,7 @@ function runUnlink(profileDir2, profileName) {
5194
5311
  return;
5195
5312
  }
5196
5313
  deleteCredential(profileDir2);
5314
+ clearDefaultProfileIf(profileName);
5197
5315
  console.log("\u2714 Deleted .cre \u2014 revoke the server-side link at web /machines.");
5198
5316
  }
5199
5317
 
@@ -5244,30 +5362,37 @@ function flagValue(args, name) {
5244
5362
  return index >= 0 ? args[index + 1] : void 0;
5245
5363
  }
5246
5364
  async function main() {
5247
- const [profileName, args] = resolveProfile(process.argv.slice(2));
5365
+ const [explicitProfile, args] = resolveProfileArg(process.argv.slice(2));
5248
5366
  const [command, ...rest] = args;
5249
- const dir = profileDir(profileName);
5250
5367
  switch (command) {
5251
5368
  case "link": {
5252
5369
  const serverUrl = flagValue(rest, "server") ?? process.env.FOURPM_SERVER ?? "http://localhost:3001";
5253
- await runLink(serverUrl.replace(/\/$/, ""), dir, profileName);
5370
+ await runLink(serverUrl.replace(/\/$/, ""), explicitProfile);
5254
5371
  break;
5255
5372
  }
5256
- case "start":
5257
- await runStart(dir, profileName);
5373
+ case "start": {
5374
+ const name = explicitProfile ?? defaultProfileName();
5375
+ await runStart(profileDir(name), name);
5258
5376
  break;
5259
- case "unlink":
5260
- runUnlink(dir, profileName);
5377
+ }
5378
+ case "unlink": {
5379
+ const name = explicitProfile ?? defaultProfileName();
5380
+ runUnlink(profileDir(name), name);
5261
5381
  break;
5382
+ }
5262
5383
  case "version":
5263
5384
  case "--version":
5264
- case "-v":
5265
- await runVersion(dir, profileName, flagValue(rest, "server"));
5385
+ case "-v": {
5386
+ const name = explicitProfile ?? defaultProfileName();
5387
+ await runVersion(profileDir(name), name, flagValue(rest, "server"));
5266
5388
  break;
5389
+ }
5267
5390
  case "update":
5268
- case "upgrade":
5269
- await runUpdate(dir, profileName, flagValue(rest, "server"));
5391
+ case "upgrade": {
5392
+ const name = explicitProfile ?? defaultProfileName();
5393
+ await runUpdate(profileDir(name), name, flagValue(rest, "server"));
5270
5394
  break;
5395
+ }
5271
5396
  default:
5272
5397
  console.log(
5273
5398
  [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4pm/cli",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "private": false,
5
5
  "description": "4PM CLI — drives Claude/Codex CLIs on AI worker machines (Node + TypeScript)",
6
6
  "type": "module",