@4pm/cli 0.3.0 → 0.3.2

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 +277 -48
  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,26 +3926,35 @@ 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
3916
3949
  import { spawn as spawn2 } from "child_process";
3950
+ import { join as join9 } from "path";
3917
3951
 
3918
3952
  // src/core/update.ts
3919
3953
  import { execSync as execSync2 } from "child_process";
3920
3954
  import { createHash as createHash2, createPublicKey, verify } from "crypto";
3921
- import { mkdtempSync, writeFileSync as writeFileSync3 } from "fs";
3955
+ import { mkdtempSync, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
3922
3956
  import { tmpdir } from "os";
3923
- import { dirname, join as join3, sep } from "path";
3957
+ import { dirname as dirname2, join as join4, sep } from "path";
3924
3958
 
3925
3959
  // src/config/signing.ts
3926
3960
  var CLI_SIGNING_PUBLIC_KEY = `
@@ -3930,7 +3964,19 @@ MCowBQYDK2VwAyEARlfzjaFhe5bhRrMp/ZuH6sVqg/S7f8B1dliuF21iDTw=
3930
3964
  `;
3931
3965
 
3932
3966
  // src/version.ts
3933
- var CLI_VERSION = "0.3.0";
3967
+ import { readFileSync as readFileSync4 } from "fs";
3968
+ import { dirname, join as join3 } from "path";
3969
+ import { fileURLToPath } from "url";
3970
+ function readCliVersion() {
3971
+ try {
3972
+ const here = dirname(fileURLToPath(import.meta.url));
3973
+ const pkg = JSON.parse(readFileSync4(join3(here, "..", "package.json"), "utf8"));
3974
+ return pkg.version ?? "0.0.0";
3975
+ } catch {
3976
+ return "0.0.0";
3977
+ }
3978
+ }
3979
+ var CLI_VERSION = readCliVersion();
3934
3980
 
3935
3981
  // src/core/update.ts
3936
3982
  function compareSemver(a, b) {
@@ -3945,6 +3991,14 @@ function compareSemver(a, b) {
3945
3991
  function installedViaNpm() {
3946
3992
  return (process.argv[1] ?? "").split(sep).includes("node_modules");
3947
3993
  }
3994
+ function readInstalledVersion() {
3995
+ try {
3996
+ const pkgPath = join4(dirname2(dirname2(process.argv[1] ?? ".")), "package.json");
3997
+ return JSON.parse(readFileSync5(pkgPath, "utf8")).version ?? "0.0.0";
3998
+ } catch {
3999
+ return "0.0.0";
4000
+ }
4001
+ }
3948
4002
  function updateViaNpm(version) {
3949
4003
  execSync2(`npm i -g @4pm/cli@${version}`, { stdio: "inherit" });
3950
4004
  }
@@ -3971,9 +4025,9 @@ async function updateViaDownload(tarballUrl, checksum, signature) {
3971
4025
  throw new Error("Invalid tarball signature \u2014 aborting update (ADR-0015).");
3972
4026
  }
3973
4027
  }
3974
- const tmp = join3(mkdtempSync(join3(tmpdir(), "4pm-update-")), "cli.tgz");
4028
+ const tmp = join4(mkdtempSync(join4(tmpdir(), "4pm-update-")), "cli.tgz");
3975
4029
  writeFileSync3(tmp, buf);
3976
- const installRoot = dirname(dirname(process.argv[1] ?? "."));
4030
+ const installRoot = dirname2(dirname2(process.argv[1] ?? "."));
3977
4031
  execSync2(`tar -xzf "${tmp}" -C "${installRoot}" --strip-components=1`, {
3978
4032
  stdio: "inherit"
3979
4033
  });
@@ -4000,6 +4054,7 @@ async function updateToLatest(serverUrl) {
4000
4054
  }
4001
4055
  }
4002
4056
  async function checkAndUpdate(serverUrl, autoUpdate) {
4057
+ if (CLI_VERSION.startsWith("0.0.0")) return { action: "none" };
4003
4058
  let meta;
4004
4059
  try {
4005
4060
  meta = await fetchCliVersion(serverUrl);
@@ -4029,7 +4084,6 @@ async function checkAndUpdate(serverUrl, autoUpdate) {
4029
4084
  meta.source.signature
4030
4085
  );
4031
4086
  }
4032
- return { action: "updated", version: meta.latest };
4033
4087
  } catch (err) {
4034
4088
  if (mandatory) {
4035
4089
  console.error(`Update failed: ${err}`);
@@ -4038,6 +4092,17 @@ async function checkAndUpdate(serverUrl, autoUpdate) {
4038
4092
  console.warn(`Update failed (skipping \u2014 not mandatory): ${err}`);
4039
4093
  return { action: "none" };
4040
4094
  }
4095
+ const installed = readInstalledVersion();
4096
+ if (compareSemver(installed, meta.latest) < 0) {
4097
+ const msg = `Update reported success but the installed version is still ${installed} (expected ${meta.latest}) \u2014 it did not apply to the running install (check the global npm prefix vs the running binary).`;
4098
+ if (mandatory) {
4099
+ console.error(msg);
4100
+ return { action: "blocked", minSupported: meta.minSupported };
4101
+ }
4102
+ console.warn(`${msg} Skipping restart.`);
4103
+ return { action: "none" };
4104
+ }
4105
+ return { action: "updated", version: meta.latest };
4041
4106
  }
4042
4107
 
4043
4108
  // src/core/ws-client.ts
@@ -4074,6 +4139,7 @@ var Role = {
4074
4139
  };
4075
4140
  var ALL_ROLES = Object.values(Role);
4076
4141
  var EXCLUSIVE_ROLES = [Role.ADMIN, Role.MACHINE];
4142
+ var PM_GRANTABLE_ROLES = [Role.SA_SE, Role.QA, Role.BA];
4077
4143
 
4078
4144
  // ../../22-packages/11-shared/11-constants/src/permissions.ts
4079
4145
  var PermissionCode = {
@@ -4260,6 +4326,13 @@ var SELF_SERVICE_CODES = [
4260
4326
  P.USER_READ_SELF,
4261
4327
  P.NOTIFICATION_READ
4262
4328
  ];
4329
+ var ADMIN_ONLY_CODES = [
4330
+ P.ORG_READ,
4331
+ P.ORG_UPDATE,
4332
+ P.ORG_DELETE,
4333
+ P.BILLING_READ,
4334
+ P.BILLING_MANAGE
4335
+ ];
4263
4336
  var TEAM_GRANTABLE_CODES = [
4264
4337
  P.TEAM_READ,
4265
4338
  P.TEAM_UPDATE,
@@ -4302,10 +4375,11 @@ var PROJECT_GRANTABLE_CODES = [
4302
4375
  P.MACHINE_PHYSIC_MANAGE
4303
4376
  ];
4304
4377
  var SELF_SERVICE_SET = new Set(SELF_SERVICE_CODES);
4378
+ var ADMIN_ONLY_SET = new Set(ADMIN_ONLY_CODES);
4305
4379
  var TEAM_GRANTABLE_SET = new Set(TEAM_GRANTABLE_CODES);
4306
4380
  var PROJECT_GRANTABLE_SET = new Set(PROJECT_GRANTABLE_CODES);
4307
4381
  function grantSubjectsFor(code) {
4308
- if (SELF_SERVICE_SET.has(code)) return [];
4382
+ if (SELF_SERVICE_SET.has(code) || ADMIN_ONLY_SET.has(code)) return [];
4309
4383
  const subjects = [GrantSubjectType.USER];
4310
4384
  if (TEAM_GRANTABLE_SET.has(code)) subjects.push(GrantSubjectType.TEAM);
4311
4385
  if (PROJECT_GRANTABLE_SET.has(code)) subjects.push(GrantSubjectType.PROJECT);
@@ -4586,8 +4660,8 @@ ${JSON.stringify(req.spec)}`;
4586
4660
  }
4587
4661
 
4588
4662
  // src/core/command-history.ts
4589
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
4590
- import { join as join4 } from "path";
4663
+ import { existsSync as existsSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
4664
+ import { join as join5 } from "path";
4591
4665
  var MAX_ENTRIES = 1e3;
4592
4666
  var filePath = null;
4593
4667
  var entries = [];
@@ -4649,10 +4723,10 @@ async function uploadHistory(cfg) {
4649
4723
  }
4650
4724
  }
4651
4725
  function initCommandHistory(profileDir2, profileName, uploadMinutes = 10) {
4652
- filePath = join4(profileDir2, "command-history.json");
4726
+ filePath = join5(profileDir2, "command-history.json");
4653
4727
  if (existsSync4(filePath)) {
4654
4728
  try {
4655
- entries = JSON.parse(readFileSync4(filePath, "utf8"));
4729
+ entries = JSON.parse(readFileSync6(filePath, "utf8"));
4656
4730
  } catch {
4657
4731
  entries = [];
4658
4732
  }
@@ -4725,12 +4799,12 @@ async function readWorkerFile(path) {
4725
4799
  // src/core/git-diff.ts
4726
4800
  import { readFile as readFile2 } from "fs/promises";
4727
4801
  import { execFile as execFile2 } from "child_process";
4728
- import { basename, dirname as dirname2, resolve as resolve3 } from "path";
4802
+ import { basename, dirname as dirname3, resolve as resolve3 } from "path";
4729
4803
  import { promisify as promisify2 } from "util";
4730
4804
  var run2 = promisify2(execFile2);
4731
4805
  async function gitDiff(path) {
4732
4806
  const target = resolve3(path);
4733
- const dir = dirname2(target);
4807
+ const dir = dirname3(target);
4734
4808
  const base = basename(target);
4735
4809
  let newContent = "";
4736
4810
  try {
@@ -4784,13 +4858,13 @@ ${stderr}`;
4784
4858
  // src/core/scaffold.ts
4785
4859
  import { cp, mkdir, writeFile } from "fs/promises";
4786
4860
  import { execFile as execFile4 } from "child_process";
4787
- import { dirname as dirname3, join as join5, resolve as resolve4 } from "path";
4788
- import { fileURLToPath } from "url";
4861
+ import { dirname as dirname4, join as join6, resolve as resolve4 } from "path";
4862
+ import { fileURLToPath as fileURLToPath2 } from "url";
4789
4863
  import { promisify as promisify4 } from "util";
4790
4864
  var run4 = promisify4(execFile4);
4791
4865
  function sampleDir() {
4792
4866
  if (process.env.SCAFFOLD_SAMPLE_DIR) return process.env.SCAFFOLD_SAMPLE_DIR;
4793
- const here = dirname3(fileURLToPath(import.meta.url));
4867
+ const here = dirname4(fileURLToPath2(import.meta.url));
4794
4868
  return resolve4(here, "../../project-sample");
4795
4869
  }
4796
4870
  async function scaffoldProject(payload, onProgress) {
@@ -4803,7 +4877,7 @@ async function scaffoldProject(payload, onProgress) {
4803
4877
  if (payload.spec) {
4804
4878
  emit("spec", "Writing project.spec.json\u2026");
4805
4879
  await writeFile(
4806
- join5(target, "project.spec.json"),
4880
+ join6(target, "project.spec.json"),
4807
4881
  JSON.stringify(payload.spec, null, 2),
4808
4882
  "utf8"
4809
4883
  );
@@ -4833,6 +4907,72 @@ async function addProject(payload, onProgress) {
4833
4907
  }
4834
4908
  }
4835
4909
 
4910
+ // src/common/logger/logger.ts
4911
+ import { appendFile, mkdir as mkdir2, readdir as readdir2, unlink } from "fs/promises";
4912
+ import { join as join7 } from "path";
4913
+ var LEVEL_ORDER = { error: 0, warn: 1, info: 2, debug: 3 };
4914
+ var DAY_MS = 24 * 60 * 60 * 1e3;
4915
+ var DEFAULT_RETENTION_DAYS = 14;
4916
+ var CliLogger = class {
4917
+ dir = null;
4918
+ minLevel = "info";
4919
+ base = null;
4920
+ retentionDays = DEFAULT_RETENTION_DAYS;
4921
+ queue = Promise.resolve();
4922
+ /** Wire the profile log dir + level + base context; runs a retention sweep. */
4923
+ configure(opts) {
4924
+ this.dir = opts.dir;
4925
+ this.minLevel = opts.level;
4926
+ this.base = opts.base;
4927
+ this.retentionDays = opts.retentionDays ?? DEFAULT_RETENTION_DAYS;
4928
+ void this.cleanupOldLogs();
4929
+ }
4930
+ /** Log an error-level event. */
4931
+ error(event, fields) {
4932
+ this.write("error", event, fields);
4933
+ }
4934
+ /** Log a warn-level event. */
4935
+ warn(event, fields) {
4936
+ this.write("warn", event, fields);
4937
+ }
4938
+ /** Log an info-level event. */
4939
+ info(event, fields) {
4940
+ this.write("info", event, fields);
4941
+ }
4942
+ /** Log a debug-level event. */
4943
+ debug(event, fields) {
4944
+ this.write("debug", event, fields);
4945
+ }
4946
+ /** Serialize one JSONL line into today's file (no-op until configured / below level). */
4947
+ write(level, event, fields) {
4948
+ const dir = this.dir;
4949
+ if (!dir || !this.base) return;
4950
+ if (LEVEL_ORDER[level] > LEVEL_ORDER[this.minLevel]) return;
4951
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
4952
+ const entry = { timestamp, level, ...this.base, event, ...fields };
4953
+ const line = JSON.stringify(entry) + "\n";
4954
+ const path = join7(dir, `cli-${timestamp.slice(0, 10)}.jsonl`);
4955
+ this.queue = this.queue.then(() => mkdir2(dir, { recursive: true })).then(() => appendFile(path, line, "utf8")).catch((err) => {
4956
+ console.warn(`Failed to write cli log: ${err}`);
4957
+ });
4958
+ }
4959
+ /** Delete cli log files older than the retention window (best-effort). */
4960
+ async cleanupOldLogs() {
4961
+ const dir = this.dir;
4962
+ if (!dir) return;
4963
+ const files = await readdir2(dir).catch(() => []);
4964
+ const cutoff = Date.now() - this.retentionDays * DAY_MS;
4965
+ for (const file of files) {
4966
+ const match = /^cli-(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(file);
4967
+ if (!match) continue;
4968
+ if ((/* @__PURE__ */ new Date(`${match[1]}T00:00:00Z`)).getTime() < cutoff) {
4969
+ await unlink(join7(dir, file)).catch(() => void 0);
4970
+ }
4971
+ }
4972
+ }
4973
+ };
4974
+ var logger = new CliLogger();
4975
+
4836
4976
  // src/core/ws-client.ts
4837
4977
  var HEARTBEAT_MS = 3e4;
4838
4978
  var MAX_HASHCODE_INVALID_RETRIES = 5;
@@ -4889,6 +5029,7 @@ var WsClient = class {
4889
5029
  }
4890
5030
  if (this.stopped) return;
4891
5031
  const wait = backoffJitterMs(Math.min(attempt, 6), 1e3, 6e4);
5032
+ logger.info("ws.reconnect", { attempt, waitMs: wait });
4892
5033
  console.log(`Reconnecting in ${Math.round(wait / 1e3)}s\u2026`);
4893
5034
  await sleep(wait);
4894
5035
  }
@@ -4927,6 +5068,7 @@ var WsClient = class {
4927
5068
  socket.on("close", (code) => {
4928
5069
  this.clearHeartbeat();
4929
5070
  this.sessionKey = null;
5071
+ logger.debug("ws.close", { code });
4930
5072
  if (code === CLOSE_DRAINING) {
4931
5073
  console.log("Server draining \u2014 will reconnect (backoff + jitter).");
4932
5074
  }
@@ -4946,6 +5088,7 @@ var WsClient = class {
4946
5088
  if ("type" in message) {
4947
5089
  if (message.type === "hello_ack" && this.ecdh) {
4948
5090
  this.sessionKey = deriveSessionKey(this.ecdh, message.pubkey);
5091
+ logger.info("ws.connected", { version: CLI_VERSION });
4949
5092
  console.log("\u2714 Connected to the server (session encryption ready).");
4950
5093
  this.sendMachineStatus();
4951
5094
  this.flushOfflineBuffer();
@@ -4953,6 +5096,7 @@ var WsClient = class {
4953
5096
  return;
4954
5097
  }
4955
5098
  if (message.type === "error") {
5099
+ logger.warn("ws.error", { errorCode: message.errorCode });
4956
5100
  if (["LINK_REVOKED", "HASHCODE_EXPIRED"].includes(message.errorCode)) {
4957
5101
  this.logout(message.errorCode);
4958
5102
  return;
@@ -4963,6 +5107,14 @@ var WsClient = class {
4963
5107
  this.socket?.close();
4964
5108
  return;
4965
5109
  }
5110
+ if (message.errorCode === "SESSION_REPLACED") {
5111
+ this.stopped = true;
5112
+ console.error(
5113
+ "Another session for this profile took over \u2014 this instance is stopping. (Are you running `4pm start` twice for the same profile?)"
5114
+ );
5115
+ this.socket?.close();
5116
+ return;
5117
+ }
4966
5118
  console.warn(`Server reported an error: ${message.errorCode}`);
4967
5119
  this.socket?.close();
4968
5120
  return;
@@ -4987,6 +5139,10 @@ var WsClient = class {
4987
5139
  switch (message.channel) {
4988
5140
  case WsChannels.COMMAND_DISPATCH: {
4989
5141
  const dispatch = payload;
5142
+ logger.info("command.dispatch", {
5143
+ commandId: dispatch.commandId,
5144
+ cmd: dispatch.cmd
5145
+ });
4990
5146
  recordCommand({
4991
5147
  commandId: dispatch.commandId,
4992
5148
  projectId: dispatch.projectId,
@@ -5161,12 +5317,54 @@ var WsClient = class {
5161
5317
  this.stopped = true;
5162
5318
  this.clearHeartbeat();
5163
5319
  this.socket?.close();
5320
+ logger.warn("ws.logout", { reason });
5164
5321
  deleteCredential(this.context.profileDir);
5165
5322
  console.error(`Link is no longer valid (${reason}) \u2014 deleted .cre. Run \`4pm link\` to pair again.`);
5166
5323
  }
5167
5324
  };
5168
5325
 
5326
+ // src/core/instance-lock.ts
5327
+ import { readFileSync as readFileSync7, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "fs";
5328
+ import { join as join8 } from "path";
5329
+ function lockPath(profileDir2) {
5330
+ return join8(profileDir2, "start.lock");
5331
+ }
5332
+ function isAlive(pid) {
5333
+ try {
5334
+ process.kill(pid, 0);
5335
+ return true;
5336
+ } catch (err) {
5337
+ return err.code === "EPERM";
5338
+ }
5339
+ }
5340
+ function lockHolder(profileDir2) {
5341
+ try {
5342
+ const pid = Number.parseInt(readFileSync7(lockPath(profileDir2), "utf8").trim(), 10);
5343
+ if (Number.isInteger(pid) && pid > 0 && isAlive(pid)) return pid;
5344
+ } catch {
5345
+ }
5346
+ return null;
5347
+ }
5348
+ function acquireInstanceLock(profileDir2) {
5349
+ if (lockHolder(profileDir2) !== null) return false;
5350
+ writeFileSync5(lockPath(profileDir2), String(process.pid), "utf8");
5351
+ return true;
5352
+ }
5353
+ function releaseInstanceLock(profileDir2) {
5354
+ try {
5355
+ const pid = Number.parseInt(readFileSync7(lockPath(profileDir2), "utf8").trim(), 10);
5356
+ if (pid === process.pid) rmSync3(lockPath(profileDir2), { force: true });
5357
+ } catch {
5358
+ }
5359
+ }
5360
+
5169
5361
  // src/commands/start.ts
5362
+ function resolveLogLevel() {
5363
+ if (process.argv.includes("--verbose")) return "debug";
5364
+ const env = process.env.FOURPM_LOG_LEVEL;
5365
+ if (env === "error" || env === "warn" || env === "info" || env === "debug") return env;
5366
+ return "info";
5367
+ }
5170
5368
  async function runStart(profileDir2, profileName) {
5171
5369
  const credential = readCredential(profileDir2, profileName);
5172
5370
  if (!credential) {
@@ -5176,10 +5374,17 @@ async function runStart(profileDir2, profileName) {
5176
5374
  process.exitCode = 1;
5177
5375
  return;
5178
5376
  }
5377
+ logger.configure({
5378
+ dir: join9(profileDir2, "logs"),
5379
+ level: resolveLogLevel(),
5380
+ base: { version: CLI_VERSION, profile: profileName, scope: credential.scope }
5381
+ });
5382
+ logger.info("cli.start", { serverUrl: credential.serverUrl });
5179
5383
  const config = readProfileConfig(profileDir2);
5180
5384
  const autoUpdate = config.autoUpdate !== false && process.env.FOURPM_NO_UPDATE !== "1";
5181
5385
  const result = await checkAndUpdate(credential.serverUrl, autoUpdate);
5182
5386
  if (result.action === "blocked") {
5387
+ logger.error("update.blocked", { minSupported: result.minSupported });
5183
5388
  console.error(
5184
5389
  `CLI is older than minSupported (${result.minSupported}) and the update failed \u2014 cannot connect.`
5185
5390
  );
@@ -5187,6 +5392,7 @@ async function runStart(profileDir2, profileName) {
5187
5392
  return;
5188
5393
  }
5189
5394
  if (result.action === "updated") {
5395
+ logger.info("update.updated", { version: result.version });
5190
5396
  console.log("Restarting with the new version\u2026");
5191
5397
  const child = spawn2(process.execPath, process.argv.slice(1), {
5192
5398
  stdio: "inherit",
@@ -5195,13 +5401,28 @@ async function runStart(profileDir2, profileName) {
5195
5401
  child.on("exit", (code) => process.exit(code ?? 0));
5196
5402
  return;
5197
5403
  }
5198
- initCommandHistory(profileDir2, profileName, config.commandHistoryUploadMinutes ?? 10);
5199
- console.log(`Connecting to ${credential.serverUrl} (profile: ${profileName})\u2026`);
5200
- await new WsClient({
5201
- credential,
5202
- machine: collectFingerprint(),
5203
- profileDir: profileDir2
5204
- }).run();
5404
+ if (!acquireInstanceLock(profileDir2)) {
5405
+ console.error(
5406
+ `Profile "${profileName}" is already running (pid ${lockHolder(profileDir2)}). Only one \`4pm start\` per profile is allowed.`
5407
+ );
5408
+ process.exitCode = 1;
5409
+ return;
5410
+ }
5411
+ const release = () => releaseInstanceLock(profileDir2);
5412
+ process.once("exit", release);
5413
+ process.once("SIGINT", () => process.exit(0));
5414
+ process.once("SIGTERM", () => process.exit(0));
5415
+ try {
5416
+ initCommandHistory(profileDir2, profileName, config.commandHistoryUploadMinutes ?? 10);
5417
+ console.log(`Connecting to ${credential.serverUrl} (profile: ${profileName})\u2026`);
5418
+ await new WsClient({
5419
+ credential,
5420
+ machine: collectFingerprint(),
5421
+ profileDir: profileDir2
5422
+ }).run();
5423
+ } finally {
5424
+ release();
5425
+ }
5205
5426
  }
5206
5427
 
5207
5428
  // src/commands/unlink.ts
@@ -5211,6 +5432,7 @@ function runUnlink(profileDir2, profileName) {
5211
5432
  return;
5212
5433
  }
5213
5434
  deleteCredential(profileDir2);
5435
+ clearDefaultProfileIf(profileName);
5214
5436
  console.log("\u2714 Deleted .cre \u2014 revoke the server-side link at web /machines.");
5215
5437
  }
5216
5438
 
@@ -5261,30 +5483,37 @@ function flagValue(args, name) {
5261
5483
  return index >= 0 ? args[index + 1] : void 0;
5262
5484
  }
5263
5485
  async function main() {
5264
- const [profileName, args] = resolveProfile(process.argv.slice(2));
5486
+ const [explicitProfile, args] = resolveProfileArg(process.argv.slice(2));
5265
5487
  const [command, ...rest] = args;
5266
- const dir = profileDir(profileName);
5267
5488
  switch (command) {
5268
5489
  case "link": {
5269
5490
  const serverUrl = flagValue(rest, "server") ?? process.env.FOURPM_SERVER ?? "http://localhost:3001";
5270
- await runLink(serverUrl.replace(/\/$/, ""), dir, profileName);
5491
+ await runLink(serverUrl.replace(/\/$/, ""), explicitProfile);
5271
5492
  break;
5272
5493
  }
5273
- case "start":
5274
- await runStart(dir, profileName);
5494
+ case "start": {
5495
+ const name = explicitProfile ?? defaultProfileName();
5496
+ await runStart(profileDir(name), name);
5275
5497
  break;
5276
- case "unlink":
5277
- runUnlink(dir, profileName);
5498
+ }
5499
+ case "unlink": {
5500
+ const name = explicitProfile ?? defaultProfileName();
5501
+ runUnlink(profileDir(name), name);
5278
5502
  break;
5503
+ }
5279
5504
  case "version":
5280
5505
  case "--version":
5281
- case "-v":
5282
- await runVersion(dir, profileName, flagValue(rest, "server"));
5506
+ case "-v": {
5507
+ const name = explicitProfile ?? defaultProfileName();
5508
+ await runVersion(profileDir(name), name, flagValue(rest, "server"));
5283
5509
  break;
5510
+ }
5284
5511
  case "update":
5285
- case "upgrade":
5286
- await runUpdate(dir, profileName, flagValue(rest, "server"));
5512
+ case "upgrade": {
5513
+ const name = explicitProfile ?? defaultProfileName();
5514
+ await runUpdate(profileDir(name), name, flagValue(rest, "server"));
5287
5515
  break;
5516
+ }
5288
5517
  default:
5289
5518
  console.log(
5290
5519
  [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4pm/cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "private": false,
5
5
  "description": "4PM CLI — drives Claude/Codex CLIs on AI worker machines (Node + TypeScript)",
6
6
  "type": "module",