@tarfa/cli 0.1.3 → 0.1.4

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.
@@ -3546,9 +3546,9 @@ function constantTimeEqual(a, b) {
3546
3546
  var REFUSAL = {
3547
3547
  notEnrolled: "device not enrolled",
3548
3548
  revoked: "device revoked",
3549
- // A device whose key is enrolled sent the token anyway: it should forget it.
3549
+ // Sent only by agents from before token enrolment was removed: a device whose key is enrolled
3550
+ // sent the token anyway, and token enrolment off (only a pairing code enrols a new phone).
3550
3551
  tokenNotNeeded: "token not needed",
3551
- // Token enrolment is off: only a pairing code enrols a new phone.
3552
3552
  enrolmentOff: "enrolment off",
3553
3553
  // No new phone can enrol, pairing code or not.
3554
3554
  enrolmentClosed: "enrolment closed",
@@ -3782,12 +3782,6 @@ function planSetup(flags, state) {
3782
3782
  }
3783
3783
  const addresses = [...derived ? [derived] : [], ...flags.addresses];
3784
3784
  if (addresses.length > MAX_ADDRESSES) return { error: `a machine takes at most ${MAX_ADDRESSES} addresses (got ${addresses.length})` };
3785
- const published = addresses.length > 0 || Boolean(withLegacyEnv(parseEnvFile(state.envText ?? "")).TARFA_PHONE_ADDRESSES);
3786
- if (published && state.roster === "on" && flags.tokenEnrolment === void 0) {
3787
- return {
3788
- error: "token enrolment is on in ~/.tarfa/devices.json, and this run gives phones an address: pass --token-enrolment off to switch it off (pairing codes only), or --token-enrolment on to keep it on deliberately"
3789
- };
3790
- }
3791
3785
  if (state.legacyService) steps.push({ kind: "stop-legacy-service" });
3792
3786
  if (state.stateDir === "legacy") steps.push({ kind: "move-state" });
3793
3787
  const adoption = state.adoption;
@@ -3796,12 +3790,7 @@ function planSetup(flags, state) {
3796
3790
  const env = planEnv(flags, state, addresses);
3797
3791
  if (env) steps.push(env);
3798
3792
  if (!state.hasKey) steps.push({ kind: "create-key" });
3799
- if (state.roster === "absent") steps.push({ kind: "write-roster", mode: flags.tokenEnrolment ?? "off" });
3800
- else if (flags.tokenEnrolment && state.roster !== flags.tokenEnrolment) steps.push({ kind: "write-roster", mode: flags.tokenEnrolment });
3801
- const mode = state.roster === "absent" ? flags.tokenEnrolment ?? "off" : flags.tokenEnrolment ?? state.roster;
3802
- if (mode === "on" && checks.some((line) => line.includes("internet") || line.includes("Funnel allowed"))) {
3803
- checks.push("warning: token enrolment is on, so whoever holds TARFA_TOKEN can enrol a phone on a public port: run `tarfa devices enrolment off` (or pass --token-enrolment off)");
3804
- }
3793
+ if (state.roster === "absent") steps.push({ kind: "write-roster" });
3805
3794
  const { kind: unitKind, installed } = state.service;
3806
3795
  const adopted = unitRunsLauncher(unitKind, installed, state.launcher.path);
3807
3796
  const serviceTarget = state.install === "package" || adopted ? { kind: "launcher", path: state.launcher.path } : { kind: "clone", repo: state.repo ?? "" };
@@ -3836,7 +3825,6 @@ function planEnv(flags, state, addresses) {
3836
3825
  const before = legacy.text;
3837
3826
  const values = parseEnvFile(before);
3838
3827
  const wanted = [];
3839
- if (!values.TARFA_TOKEN) wanted.push(["TARFA_TOKEN", state.newToken, "(generated)"]);
3840
3828
  wanted.push(["TARFA_MACHINE_ID", flags.machineId], ["TARFA_PROJECT_ROOTS", flags.projectRoots.join(":")]);
3841
3829
  if (flags.defaultPath) wanted.push(["TARFA_DEFAULT_PATH", flags.defaultPath]);
3842
3830
  wanted.push(["TARFA_PHONE_PORT", String(flags.phonePort)]);
@@ -3926,7 +3914,7 @@ function describeStep(step, envPath) {
3926
3914
  case "create-key":
3927
3915
  return "create the agent key (~/.tarfa/agent-key.json)";
3928
3916
  case "write-roster":
3929
- return `write ~/.tarfa/devices.json with enrolment ${step.mode}${step.mode === "off" ? " (pairing codes only)" : ""}`;
3917
+ return "write ~/.tarfa/devices.json (phones join with a `tarfa pair` code)";
3930
3918
  case "write-launcher":
3931
3919
  return `write the launcher the service runs (${step.path})`;
3932
3920
  case "install-unit":
@@ -3945,7 +3933,7 @@ function describeStep(step, envPath) {
3945
3933
  return `ask the node's Tailscale operator to run: ${step.line}`;
3946
3934
  }
3947
3935
  }
3948
- var VALUE_FLAGS = /* @__PURE__ */ new Set(["--machine-id", "--project-roots", "--default-path", "--phone-port", "--slug", "--tailscale", "--tailscale-port", "--address", "--pwa", "--token-enrolment"]);
3936
+ var VALUE_FLAGS = /* @__PURE__ */ new Set(["--machine-id", "--project-roots", "--default-path", "--phone-port", "--slug", "--tailscale", "--tailscale-port", "--address", "--pwa"]);
3949
3937
  var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["--pair", "--dry-run", "--adopt"]);
3950
3938
  function portNumber(text) {
3951
3939
  if (!/^\d{1,5}$/.test(text)) return null;
@@ -3999,8 +3987,6 @@ function parseSetupFlags(argv) {
3999
3987
  return { error: `--pwa must be the http(s) origin phones open the app at (got ${single.get("--pwa")})` };
4000
3988
  }
4001
3989
  }
4002
- const tokenEnrolment = single.get("--token-enrolment");
4003
- if (tokenEnrolment !== void 0 && tokenEnrolment !== "on" && tokenEnrolment !== "off") return { error: "--token-enrolment takes on or off" };
4004
3990
  return {
4005
3991
  machineId,
4006
3992
  projectRoots,
@@ -4013,8 +3999,7 @@ function parseSetupFlags(argv) {
4013
3999
  ...pwa !== void 0 ? { pwa } : {},
4014
4000
  pair: set.has("--pair"),
4015
4001
  dryRun: set.has("--dry-run"),
4016
- adopt: set.has("--adopt"),
4017
- ...tokenEnrolment !== void 0 ? { tokenEnrolment } : {}
4002
+ adopt: set.has("--adopt")
4018
4003
  };
4019
4004
  }
4020
4005
 
@@ -4211,7 +4196,6 @@ function loadConfig(rawEnv, home) {
4211
4196
  return {
4212
4197
  stateDir,
4213
4198
  machineId,
4214
- token: env.TARFA_TOKEN,
4215
4199
  port,
4216
4200
  phonePort,
4217
4201
  phoneAddresses: phoneAddresses(env),
@@ -4244,11 +4228,6 @@ function loadConfig(rawEnv, home) {
4244
4228
  ...env.TARFA_VOICE_DEVICE?.trim() ? { voiceDevice: env.TARFA_VOICE_DEVICE.trim() } : {}
4245
4229
  };
4246
4230
  }
4247
- function assertServerConfig(config) {
4248
- if (!config.token) {
4249
- throw new Error("TARFA_TOKEN is required to run the agent server");
4250
- }
4251
- }
4252
4231
  function configWarnings(rawEnv) {
4253
4232
  const env = withLegacyEnv(rawEnv);
4254
4233
  const warnings = legacyEnvNames(rawEnv).map(
@@ -4256,6 +4235,7 @@ function configWarnings(rawEnv) {
4256
4235
  );
4257
4236
  const relays = ["TARFA_RELAY_URLS", "TARFA_RELAY_URL"].filter((name) => env[name]?.trim());
4258
4237
  if (relays.length > 0) warnings.push(`relays are gone; remove ${relays.join(" and ")} from agent.env`);
4238
+ if (env.TARFA_TOKEN?.trim()) warnings.push("TARFA_TOKEN is ignored: phones join only with a `tarfa pair` link; remove it from agent.env");
4259
4239
  if (phoneAddresses(env).length === 0) {
4260
4240
  warnings.push("no TARFA_PHONE_ADDRESSES: phones reach this agent only once its phone port is published (tailscale serve, a reverse proxy)");
4261
4241
  }
@@ -4989,17 +4969,12 @@ function parseKeys(text, path) {
4989
4969
  }
4990
4970
 
4991
4971
  // src/application/device-roster.ts
4992
- var EMPTY_ROSTER = { enrolment: true, devices: [], revoked: [] };
4972
+ var EMPTY_ROSTER = { enrolment: true, tokenEnrolment: false, devices: [], revoked: [] };
4993
4973
  function enrolmentMode(roster) {
4994
- if (!roster.enrolment) return "closed";
4995
- return roster.tokenEnrolment === false ? "off" : "on";
4974
+ return roster.enrolment ? "open" : "closed";
4996
4975
  }
4997
4976
  function withEnrolmentMode(roster, mode) {
4998
- return { ...roster, enrolment: mode !== "closed", tokenEnrolment: mode === "on" };
4999
- }
5000
- function mayEnrol(roster, via) {
5001
- const mode = enrolmentMode(roster);
5002
- return mode === "on" || mode === "off" && via === "pairing";
4977
+ return { ...roster, enrolment: mode === "open", tokenEnrolment: false };
5003
4978
  }
5004
4979
  function standingOf(roster, x255192) {
5005
4980
  if (roster.revoked.some((d) => d.x25519 === x255192)) return "revoked";
@@ -5045,7 +5020,8 @@ function forgetDevice(roster, id2) {
5045
5020
  }
5046
5021
 
5047
5022
  // src/infrastructure/file-device-store.ts
5048
- import { readFile as readFile5 } from "node:fs/promises";
5023
+ import { mkdir as mkdir5, readFile as readFile5 } from "node:fs/promises";
5024
+ import { dirname as dirname6 } from "node:path";
5049
5025
  import { z as z2 } from "zod";
5050
5026
  var key = z2.string().regex(/^[A-Za-z0-9_-]{43}$/);
5051
5027
  var device = z2.object({ id: z2.string(), x25519: key, ed25519: key, name: z2.string(), enrolledAt: z2.string() });
@@ -5074,9 +5050,31 @@ var FileDeviceStore = class {
5074
5050
  if (!parsed.success) throw new Error(`device roster ${this.filePath} is malformed; fix or move it aside`);
5075
5051
  return parsed.data;
5076
5052
  }
5053
+ // Writes the roster once if the file lacks `tokenEnrolment: false`, missing file included, so an
5054
+ // agent from before token enrolment was removed, brought back, finds it off. True if it wrote.
5055
+ async sealTokenEnrolment() {
5056
+ let raw = null;
5057
+ try {
5058
+ raw = await readFile5(this.filePath, "utf8");
5059
+ } catch (err) {
5060
+ if (err.code !== "ENOENT") throw err;
5061
+ }
5062
+ if (raw !== null) {
5063
+ let parsed = null;
5064
+ try {
5065
+ parsed = JSON.parse(raw);
5066
+ } catch {
5067
+ return false;
5068
+ }
5069
+ if (parsed?.tokenEnrolment === false) return false;
5070
+ }
5071
+ await mkdir5(dirname6(this.filePath), { recursive: true, mode: 448 });
5072
+ await this.update((roster) => roster);
5073
+ return true;
5074
+ }
5077
5075
  async update(change) {
5078
5076
  return withFileLock(this.filePath, this.staleLockMs, async () => {
5079
- const next = change(await this.read());
5077
+ const next = { ...change(await this.read()), tokenEnrolment: false };
5080
5078
  await writeFileAtomic(this.filePath, `${JSON.stringify(next, null, 2)}
5081
5079
  `);
5082
5080
  return next;
@@ -5140,8 +5138,8 @@ var FilePairingStore = class {
5140
5138
  // src/infrastructure/agent-env-file.ts
5141
5139
  import { execFile as execFile3 } from "node:child_process";
5142
5140
  import { existsSync as existsSync3 } from "node:fs";
5143
- import { chmod, copyFile, mkdir as mkdir5, readFile as readFile7, writeFile as writeFile4 } from "node:fs/promises";
5144
- import { dirname as dirname6, resolve as resolve5 } from "node:path";
5141
+ import { chmod, copyFile, mkdir as mkdir6, readFile as readFile7, writeFile as writeFile4 } from "node:fs/promises";
5142
+ import { dirname as dirname7, resolve as resolve5 } from "node:path";
5145
5143
  import { fileURLToPath as fileURLToPath2 } from "node:url";
5146
5144
  import { promisify as promisify3 } from "node:util";
5147
5145
 
@@ -5161,10 +5159,10 @@ function withoutGitVars(env) {
5161
5159
  }
5162
5160
  async function mainCheckout(kind2 = installKind()) {
5163
5161
  if (kind2 === "package") return null;
5164
- const here = dirname6(fileURLToPath2(import.meta.url));
5162
+ const here = dirname7(fileURLToPath2(import.meta.url));
5165
5163
  try {
5166
5164
  const { stdout } = await exec3("git", ["-C", here, "rev-parse", "--path-format=absolute", "--git-common-dir"], { env: withoutGitVars(process.env) });
5167
- return dirname6(stdout.trim());
5165
+ return dirname7(stdout.trim());
5168
5166
  } catch {
5169
5167
  return resolve5(here, "..", "..", "..", "..");
5170
5168
  }
@@ -5194,7 +5192,7 @@ function agentEnvFile(path, now = () => /* @__PURE__ */ new Date()) {
5194
5192
  if (err.code !== "ENOENT") throw err;
5195
5193
  backup = null;
5196
5194
  }
5197
- await mkdir5(dirname6(path), { recursive: true, mode: 448 });
5195
+ await mkdir6(dirname7(path), { recursive: true, mode: 448 });
5198
5196
  await writeFile4(path, text, { mode: 384 });
5199
5197
  await chmod(path, 384);
5200
5198
  return backup;
@@ -5204,7 +5202,7 @@ function agentEnvFile(path, now = () => /* @__PURE__ */ new Date()) {
5204
5202
 
5205
5203
  // src/infrastructure/file-status-store.ts
5206
5204
  import { readFileSync as readFileSync2, writeFileSync, mkdirSync } from "node:fs";
5207
- import { dirname as dirname7 } from "node:path";
5205
+ import { dirname as dirname8 } from "node:path";
5208
5206
 
5209
5207
  // ../contracts/src/domain/workspace-status.ts
5210
5208
  var WORKSPACE_STATUSES = [
@@ -5390,6 +5388,7 @@ var machineStatsSchema = z6.object({
5390
5388
  }).optional()
5391
5389
  });
5392
5390
  var clientMessageSchema = z6.discriminatedUnion("type", [
5391
+ // `token` carries a `tarfa pair` code for a new phone, or nothing (an enrolled key needs none).
5393
5392
  z6.object({ type: z6.literal("auth"), token: z6.string() }),
5394
5393
  z6.object({ type: z6.literal("list") }),
5395
5394
  z6.object({ type: z6.literal("getRecentPaths") }),
@@ -5442,8 +5441,9 @@ var detachReasonSchema = z6.enum(["opened-elsewhere", "closed", "error"]);
5442
5441
  var PROTOCOL_VERSION = 1;
5443
5442
  var AGENT_FEATURES = { lastReply: "lastReply" };
5444
5443
  var agentMessageSchema = z6.discriminatedUnion("type", [
5445
- // `credential` is what authenticated the session; `enrolled` says the device key is enrolled (written), so the
5446
- // phone forgets its token and pairing secret. Both optional: an older agent sends neither.
5444
+ // `credential` is what authenticated the session (`token` only from an agent from before token
5445
+ // enrolment was removed); `enrolled` says the device key is enrolled (written), so the phone
5446
+ // forgets its pairing secret. Both optional: an older agent sends neither.
5447
5447
  // `pushed`: the agent served the phone at once (its hello asked) and has sent the opening replies
5448
5448
  // (workspaces, keyterms) behind it, so the phone does not ask for them.
5449
5449
  z6.object({
@@ -5495,7 +5495,7 @@ var agentMessageSchema = z6.discriminatedUnion("type", [
5495
5495
  z6.object({ type: z6.literal("voiceDone"), id: z6.string(), ok: z6.boolean(), error: z6.string().optional() }),
5496
5496
  z6.object({ type: z6.literal("detached"), workspaceId: z6.string(), reason: detachReasonSchema }),
5497
5497
  z6.object({ type: z6.literal("closed"), workspaceId: z6.string() }),
5498
- // A phone was just enrolled on this machine (a pairing code or the token), told to every other
5498
+ // A phone was just enrolled on this machine (a pairing code), told to every other
5499
5499
  // phone connected to it: a device nobody meant to add must not go unnoticed. An older PWA drops
5500
5500
  // the unknown type.
5501
5501
  z6.object({ type: z6.literal("deviceEnrolled"), id: z6.string(), name: z6.string(), at: z6.string() }),
@@ -5696,7 +5696,7 @@ var FileStatusStore = class {
5696
5696
  }
5697
5697
  }
5698
5698
  persist() {
5699
- mkdirSync(dirname7(this.filePath), { recursive: true });
5699
+ mkdirSync(dirname8(this.filePath), { recursive: true });
5700
5700
  writeFileSync(this.filePath, JSON.stringify(Object.fromEntries(this.statuses), null, 2), "utf8");
5701
5701
  }
5702
5702
  };
@@ -5725,8 +5725,8 @@ function parseEntry(value) {
5725
5725
  }
5726
5726
 
5727
5727
  // src/infrastructure/file-parked-store.ts
5728
- import { readFile as readFile8, writeFile as writeFile5, mkdir as mkdir6 } from "node:fs/promises";
5729
- import { dirname as dirname8 } from "node:path";
5728
+ import { readFile as readFile8, writeFile as writeFile5, mkdir as mkdir7 } from "node:fs/promises";
5729
+ import { dirname as dirname9 } from "node:path";
5730
5730
  var FileParkedStore = class {
5731
5731
  constructor(filePath) {
5732
5732
  this.filePath = filePath;
@@ -5760,7 +5760,7 @@ var FileParkedStore = class {
5760
5760
  await this.persist(rest);
5761
5761
  }
5762
5762
  async persist(entries) {
5763
- await mkdir6(dirname8(this.filePath), { recursive: true });
5763
+ await mkdir7(dirname9(this.filePath), { recursive: true });
5764
5764
  await writeFile5(this.filePath, JSON.stringify(entries, null, 2), "utf8");
5765
5765
  }
5766
5766
  };
@@ -5795,10 +5795,10 @@ function parseEntry2(value) {
5795
5795
  }
5796
5796
 
5797
5797
  // src/infrastructure/fs-file-writer.ts
5798
- import { mkdir as mkdir7, writeFile as writeFile6, access } from "node:fs/promises";
5798
+ import { mkdir as mkdir8, writeFile as writeFile6, access } from "node:fs/promises";
5799
5799
  var FsFileWriter = class {
5800
5800
  async ensureDir(dir) {
5801
- await mkdir7(dir, { recursive: true });
5801
+ await mkdir8(dir, { recursive: true });
5802
5802
  }
5803
5803
  async exists(path) {
5804
5804
  try {
@@ -6090,7 +6090,6 @@ export {
6090
6090
  stateDirLayout,
6091
6091
  migrateStateDir,
6092
6092
  loadConfig,
6093
- assertServerConfig,
6094
6093
  configWarnings,
6095
6094
  systemClock,
6096
6095
  systemPathResolver,
@@ -6116,7 +6115,6 @@ export {
6116
6115
  loadOrCreateAgentKeys,
6117
6116
  enrolmentMode,
6118
6117
  withEnrolmentMode,
6119
- mayEnrol,
6120
6118
  standingOf,
6121
6119
  enrolDevice,
6122
6120
  revokeDevice,
package/dist/cli.js CHANGED
@@ -63,7 +63,7 @@ import {
63
63
  unitRunsLauncher,
64
64
  withEnrolmentMode,
65
65
  withLegacyEnv
66
- } from "./chunk-X6FKPFHV.js";
66
+ } from "./chunk-OC5VX7K3.js";
67
67
 
68
68
  // src/cli.ts
69
69
  import { randomBytes } from "node:crypto";
@@ -344,7 +344,7 @@ async function apply(step, effects) {
344
344
  await effects.createKey();
345
345
  return describeStep(step, "").replace(/^create/, "created");
346
346
  case "write-roster":
347
- await effects.writeRoster(step.mode);
347
+ await effects.writeRoster();
348
348
  return describeStep(step, "").replace(/^write/, "wrote");
349
349
  case "stop-legacy-service":
350
350
  return `stopped the old agent service: ${(await effects.stopLegacyService()).join("; ")}`;
@@ -541,6 +541,10 @@ function findMise(home, pathVar, exists2 = existsSync) {
541
541
  const candidates = [join2(home, ".local", "bin", "mise"), "/opt/homebrew/bin/mise", "/usr/local/bin/mise", ...(pathVar ?? "").split(delimiter).filter(Boolean).map((dir) => join2(dir, "mise"))];
542
542
  return candidates.find(exists2) ?? null;
543
543
  }
544
+ function findInstalledCli(install, exists2 = existsSync) {
545
+ if (!install) return null;
546
+ return [join2(install, "bin", "tarfa"), join2(install, "node_modules", "@tarfa", "cli", "dist", "cli.js")].find(exists2) ?? null;
547
+ }
544
548
  function miseCli(home) {
545
549
  const run4 = async (mise, args, timeout) => {
546
550
  try {
@@ -555,9 +559,9 @@ function miseCli(home) {
555
559
  async locate(mise, version) {
556
560
  const install = await run4(mise, ["where", `npm:@tarfa/cli@${version}`], 6e4);
557
561
  const nodeHome = await run4(mise, ["where", "node"], 6e4);
558
- const cli = join2(install, "bin", "tarfa");
562
+ const cli = findInstalledCli(install);
559
563
  const node = join2(nodeHome, "bin", "node");
560
- if (!install || !existsSync(cli)) throw new Error(`mise knows no install of @tarfa/cli ${version} with a bin/tarfa`);
564
+ if (!cli) throw new Error(`mise knows no install of @tarfa/cli ${version} with a bin/tarfa or node_modules/@tarfa/cli/dist/cli.js`);
561
565
  if (!nodeHome || !existsSync(node)) throw new Error("mise knows no node");
562
566
  return { node, cli };
563
567
  },
@@ -909,7 +913,7 @@ var systemDeps = {
909
913
  exec: async (command, args) => (await run2(command, args, { env: childEnv() })).stdout,
910
914
  readFile: (path) => readFile5(path, "utf8")
911
915
  };
912
- var AGENT_COMMAND = /\bsrc\/serve\.ts\b|(^|[\s/])tarfa serve(\s|$)/;
916
+ var AGENT_COMMAND = /\bsrc\/serve\.ts\b|(^|[\s/])tarfa serve(\s|$)|\/@tarfa\/cli\/dist\/cli\.js serve(\s|$)/;
913
917
  function isFree(port) {
914
918
  return new Promise((resolve2, reject) => {
915
919
  const server = createServer();
@@ -1283,7 +1287,7 @@ var HELP = `usage: tarfa <command> [options] (\`tarfa setup --help\`, \`tarfa
1283
1287
  Set up this machine:
1284
1288
  setup write agent.env, the agent key, the service and the hooks, then check the phone port
1285
1289
  pair print a one-time pairing link and QR code for a phone
1286
- devices list, revoke or forget paired phones; switch enrolment on, off or closed
1290
+ devices list, revoke or forget paired phones; open or close enrolment
1287
1291
  update install the newest @tarfa/cli and apply it (package installs)
1288
1292
  apply rewrite the launcher and service if they changed, reinstall the hooks, restart
1289
1293
  install-hooks install the Claude Code and Codex hooks that report each workspace's status
@@ -1684,7 +1688,7 @@ agent key, a new device roster that admits pairing codes only (an existing one i
1684
1688
  user unit with linger, or launchd), the Claude Code hooks, and optionally a Tailscale address for
1685
1689
  phones. Rerunning it with the same flags, or with none, changes nothing: --machine-id, --project-roots,
1686
1690
  --phone-port and --default-path default to what the agent.env it finds holds. It refuses rather than
1687
- replace anything it did not make (an agent key, a token, another tailscale serve entry).
1691
+ replace anything it did not make (an agent key, another tailscale serve entry).
1688
1692
 
1689
1693
  --machine-id <id> the name phones show for this machine
1690
1694
  --project-roots <dir,dir> absolute folders new workspaces may be made in
@@ -1700,7 +1704,6 @@ replace anything it did not make (an agent key, a token, another tailscale serve
1700
1704
  4 in all), e.g. a reverse proxy
1701
1705
  --pwa <origin> where phones open the app (default https://tarfa.app; an existing
1702
1706
  TARFA_PWA_ORIGIN is kept unless this is given)
1703
- --token-enrolment on|off let TARFA_TOKEN enrol phones (off by default on a new roster)
1704
1707
  --pair print a pairing link and QR code at the end
1705
1708
  --adopt a machine whose service runs a clone's deploy/run-agent.sh: move it to
1706
1709
  the installed package (copies deploy/agent.env to ~/.tarfa/agent.env,
@@ -1722,8 +1725,7 @@ Several agents on one node (one per Linux user) share a Tailscale port, each und
1722
1725
  The node has one operator: a second user runs \`--tailscale none --slug <name> --address <url>\`
1723
1726
  with their own --phone-port, and setup prints the \`tailscale serve\` line for the operator to run:
1724
1727
  on the port of that --address (or --tailscale-port), as \`tailscale funnel\` when the port is already
1725
- on Funnel. Setup refuses to give phones an address while token enrolment is on, until
1726
- --token-enrolment says which way. Run as another user (sudo -u), set XDG_RUNTIME_DIR=/run/user/<uid>.
1728
+ on Funnel. Run as another user (sudo -u), set XDG_RUNTIME_DIR=/run/user/<uid>.
1727
1729
 
1728
1730
  Related: \`tarfa apply\` brings the service and hooks in line with the installed package and checks
1729
1731
  the agent came back; \`tarfa update\` installs a newer release with mise, then runs its apply.
@@ -1803,7 +1805,6 @@ ${SETUP_USAGE.split("\n")[0]}
1803
1805
  service: { kind: service.kind, template: await service.template(), installed: installedUnit, enabled: await service.enabled() },
1804
1806
  envPath,
1805
1807
  envText,
1806
- newToken: randomBytes(32).toString("hex"),
1807
1808
  hasKey: await readFile6(config.agentKeyPath).then(() => true, () => false),
1808
1809
  roster: hasRoster ? enrolmentMode(await devices2.read()) : "absent",
1809
1810
  hooksCurrent: await hooks.current(),
@@ -1828,9 +1829,9 @@ ${SETUP_USAGE.split("\n")[0]}
1828
1829
  createKey: async () => {
1829
1830
  await loadOrCreateAgentKeys(config.agentKeyPath);
1830
1831
  },
1831
- writeRoster: async (mode) => {
1832
+ writeRoster: async () => {
1832
1833
  await mkdir4(dirname4(config.devicesPath), { recursive: true, mode: 448 });
1833
- await devices2.update((roster) => withEnrolmentMode(roster, mode));
1834
+ await devices2.update((roster) => roster);
1834
1835
  },
1835
1836
  writeLauncher: (path, text) => atomicWrite(path, text, 493),
1836
1837
  copyEnv: (from, to) => copyEnvOnce(from, to),
@@ -1977,14 +1978,12 @@ function pairingInstructions(link, minutes = PAIRING_TTL_MS / 6e4) {
1977
1978
  ].join("\n");
1978
1979
  }
1979
1980
  var DEVICES_USAGE = [
1980
- "usage: tarfa devices [list | revoke <id|key> | forget <id|key> | enrolment on|off|closed]",
1981
- " enrolment on the token or a pairing code enrols a new phone",
1982
- " enrolment off only a pairing code from `tarfa pair` enrols one",
1981
+ "usage: tarfa devices [list | revoke <id|key> | forget <id|key> | enrolment open|closed]",
1982
+ " enrolment open a pairing code from `tarfa pair` enrols a new phone",
1983
1983
  " enrolment closed no new phone can enrol"
1984
1984
  ].join("\n");
1985
1985
  var ENROLMENT_MODES = {
1986
- on: "on (the token or a pairing code enrols a phone)",
1987
- off: "off (only a pairing code from `tarfa pair` enrols a phone)",
1986
+ open: "open (a pairing code from `tarfa pair` enrols a phone)",
1988
1987
  closed: "closed (no new phone can enrol)"
1989
1988
  };
1990
1989
  async function devices(store, [action = "list", arg]) {
@@ -1995,23 +1994,19 @@ async function devices(store, [action = "list", arg]) {
1995
1994
  case "forget": {
1996
1995
  if (!arg) throw new Error(DEVICES_USAGE);
1997
1996
  const found = { device: null };
1998
- const written = await store.update((roster) => {
1997
+ await store.update((roster) => {
1999
1998
  const result = action === "revoke" ? revokeDevice(roster, arg, /* @__PURE__ */ new Date()) : forgetDevice(roster, arg);
2000
1999
  found.device = result.device;
2001
2000
  return result.roster;
2002
2001
  });
2003
2002
  const { device } = found;
2004
2003
  if (!device) throw new Error(`no single device matches ${arg}; see tarfa devices, and name it by its full key if its id is shared`);
2005
- if (action === "forget") return `forgot ${device.id} (${sanitizeDeviceName(device.name)}); it can enrol again (a \`tarfa pair\` code, or the token while enrolment is on)`;
2006
- const lines = [`revoked ${device.id} (${sanitizeDeviceName(device.name)}); this key stays refused until forgotten`];
2007
- if (enrolmentMode(written) === "on") {
2008
- lines.push("enrolment is on: whoever holds the token can still enrol a new key.");
2009
- lines.push("Run `tarfa devices enrolment off`, or rotate the token, to stop that.");
2010
- }
2011
- return lines.join("\n");
2004
+ if (action === "forget") return `forgot ${device.id} (${sanitizeDeviceName(device.name)}); it can enrol again with a \`tarfa pair\` code`;
2005
+ return `revoked ${device.id} (${sanitizeDeviceName(device.name)}); this key stays refused until forgotten`;
2012
2006
  }
2013
2007
  case "enrolment": {
2014
- if (arg !== "on" && arg !== "off" && arg !== "closed") throw new Error(DEVICES_USAGE);
2008
+ if (arg === "on" || arg === "off") throw new Error(`token enrolment is gone: phones join only with a \`tarfa pair\` code. Use \`enrolment open\` or \`enrolment closed\``);
2009
+ if (arg !== "open" && arg !== "closed") throw new Error(DEVICES_USAGE);
2015
2010
  await store.update((roster) => withEnrolmentMode(roster, arg));
2016
2011
  return `enrolment: ${ENROLMENT_MODES[arg]}`;
2017
2012
  }
package/dist/serve.js CHANGED
@@ -25,7 +25,6 @@ import {
25
25
  ShortWorkspaceIds,
26
26
  TmuxAdapter,
27
27
  WrongStaticKeyError,
28
- assertServerConfig,
29
28
  audioMediaType,
30
29
  b64url,
31
30
  browseDir,
@@ -55,7 +54,6 @@ import {
55
54
  loadOrCreateAgentKeys,
56
55
  locateAgentEnv,
57
56
  loginShellResolver,
58
- mayEnrol,
59
57
  migrateStateDir,
60
58
  packageVersion,
61
59
  parseBrowserAgentMessage,
@@ -75,7 +73,7 @@ import {
75
73
  systemPathResolver,
76
74
  withLegacyEnv,
77
75
  writeFileAtomic
78
- } from "./chunk-X6FKPFHV.js";
76
+ } from "./chunk-OC5VX7K3.js";
79
77
 
80
78
  // src/serve.ts
81
79
  import { homedir as homedir2 } from "node:os";
@@ -1324,7 +1322,7 @@ var NoiseClientConnection = class {
1324
1322
  return this.keyAuth !== null && this.state.kind !== "failed" ? this.keyAuth : void 0;
1325
1323
  }
1326
1324
  // An accepted `auth` is answered with whether the device key is now enrolled: the phone drops
1327
- // its token or pairing secret on `enrolled`, so for a new device the answer waits for the
1325
+ // its pairing secret on `enrolled`, so for a new device the answer waits for the
1328
1326
  // roster to be written.
1329
1327
  send(message) {
1330
1328
  if (!this.session || this.state.kind === "failed") return;
@@ -1451,7 +1449,7 @@ var NoiseClientConnection = class {
1451
1449
  return;
1452
1450
  }
1453
1451
  this.keyAuth = standing === "enrolled" ? "device" : null;
1454
- this.state = standing === "enrolled" ? { kind: "open" } : { kind: "enrolling", hello, tokenEnrols: enrolment === "on" };
1452
+ this.state = standing === "enrolled" ? { kind: "open" } : { kind: "enrolling", hello };
1455
1453
  if (standing === "enrolled" && hello.push && this.keyAuthenticatedListeners.length > 0 && this.options.pushBudget?.take(key)) {
1456
1454
  this.awaitingPhone = true;
1457
1455
  this.keyAuthenticatedListeners.forEach((l) => l());
@@ -1509,16 +1507,16 @@ var NoiseClientConnection = class {
1509
1507
  route(raw) {
1510
1508
  if (this.state.kind === "failed") return;
1511
1509
  const message = raw;
1512
- if (this.state.kind === "enrolling" && (message?.type !== "auth" || message.token === "")) {
1510
+ const pairing = message?.type === "auth" && typeof message.token === "string" && PAIRING_SECRET.test(message.token);
1511
+ if (this.state.kind === "enrolling" && !pairing) {
1512
+ this.logRefusal("no-code", `phone: device ${deviceFingerprint(this.deviceKey)} not enrolled and sent no pairing code`);
1513
1513
  this.fail(REFUSAL.notEnrolled, DEVICE_REFUSED);
1514
1514
  return;
1515
1515
  }
1516
- const pairing = message?.type === "auth" && typeof message.token === "string" && PAIRING_SECRET.test(message.token);
1517
1516
  if (pairing && (this.state.kind === "enrolling" || this.keyAuth !== null)) {
1518
1517
  this.spendPairing(message.token, raw);
1519
1518
  return;
1520
1519
  }
1521
- if (this.refusesToken()) return;
1522
1520
  this.listeners.forEach((l) => l(raw));
1523
1521
  }
1524
1522
  spendPairing(secret, auth) {
@@ -1531,12 +1529,12 @@ var NoiseClientConnection = class {
1531
1529
  const held = this.held ?? [];
1532
1530
  this.held = null;
1533
1531
  if (this.state.kind === "failed") return;
1534
- if (!redemption.redeemed && redemption.reason) {
1535
- this.logRefusal(redemption.reason, `phone: device ${fingerprint} refused: ${redemption.reason}`);
1536
- this.fail(redemption.reason, DEVICE_REFUSED);
1532
+ if (!redemption.redeemed && (redemption.reason || this.keyAuth === null)) {
1533
+ const reason = redemption.reason ?? REFUSAL.notEnrolled;
1534
+ this.logRefusal(reason, `phone: device ${fingerprint} refused: ${reason}`);
1535
+ this.fail(reason, DEVICE_REFUSED);
1537
1536
  return;
1538
1537
  }
1539
- if (!redemption.redeemed && this.refusesToken()) return;
1540
1538
  this.listeners.forEach((l) => l(auth));
1541
1539
  held.forEach((raw) => this.drain(raw));
1542
1540
  };
@@ -1550,24 +1548,15 @@ var NoiseClientConnection = class {
1550
1548
  );
1551
1549
  }
1552
1550
  }
1553
- // An unknown device whose `auth` is about to be checked as a token, while only a pairing code
1554
- // may enrol it: refused here, so the token never gets as far as the handler.
1555
- refusesToken() {
1556
- if (this.state.kind !== "enrolling" || this.state.tokenEnrols || this.keyAuth !== null) return false;
1557
- this.logRefusal("token-off", `phone: device ${deviceFingerprint(this.deviceKey)} refused: token enrolment is off`);
1558
- this.fail(REFUSAL.enrolmentOff, DEVICE_REFUSED);
1559
- return true;
1560
- }
1561
1551
  // `declined`: the roster would not take the device (enrolment turned off or closed since the
1562
1552
  // handshake, or its id taken), so the credential that authenticated this session no longer
1563
1553
  // admits it. `failed`: the write itself failed; the session goes on unenrolled, and the phone
1564
1554
  // keeps its credential to try again.
1565
1555
  async enrol(hello) {
1566
1556
  const key = this.deviceKey;
1567
- const via = this.keyAuth === "pairing" ? "pairing" : "token";
1568
1557
  this.state = { kind: "open" };
1569
1558
  try {
1570
- const enrolled = await this.options.devices.enrol({ x25519: key, ed25519: hello.sig, name: hello.name }, via);
1559
+ const enrolled = await this.options.devices.enrol({ x25519: key, ed25519: hello.sig, name: hello.name });
1571
1560
  this.options.log?.(
1572
1561
  enrolled ? `phone: enrolled device ${deviceFingerprint(key)} "${hello.name}"` : `phone: device ${deviceFingerprint(key)} not enrolled (enrolment changed meanwhile, or its id is taken); closing its session`
1573
1562
  );
@@ -1581,7 +1570,6 @@ var NoiseClientConnection = class {
1581
1570
  try {
1582
1571
  const { enrolment } = await this.options.devices.check(this.deviceKey);
1583
1572
  if (enrolment === "closed") return REFUSAL.enrolmentClosed;
1584
- if (enrolment === "off" && this.keyAuth !== "pairing") return REFUSAL.enrolmentOff;
1585
1573
  } catch {
1586
1574
  }
1587
1575
  return REFUSAL.enrolmentRefused;
@@ -2305,17 +2293,11 @@ var ConnectionHandler = class {
2305
2293
  return;
2306
2294
  }
2307
2295
  if (message.type === "auth") {
2308
- const token = this.deps.config.token;
2309
- const isToken = token !== void 0 && constantTimeEqual(message.token, token);
2310
- const byKey = this.conn.keyCredential;
2311
- if (byKey === "device" && isToken && this.conn.refuse) {
2312
- this.conn.refuse(REFUSAL.tokenNotNeeded);
2313
- return;
2314
- }
2315
2296
  if (this.authed) return;
2316
- if (byKey || isToken) {
2297
+ const byKey = this.conn.keyCredential;
2298
+ if (byKey) {
2317
2299
  this.authed = true;
2318
- this.conn.send({ type: "authResult", ok: true, credential: byKey ?? "token" });
2300
+ this.conn.send({ type: "authResult", ok: true, credential: byKey });
2319
2301
  } else {
2320
2302
  this.conn.send({ type: "authResult", ok: false });
2321
2303
  this.conn.close();
@@ -3792,23 +3774,23 @@ var RosterDevices = class {
3792
3774
  const roster = await this.deps.store.read();
3793
3775
  return { standing: standingOf(roster, x25519), enrolment: enrolmentMode(roster) };
3794
3776
  }
3795
- async enrol(device, via) {
3777
+ async enrol(device) {
3796
3778
  let known = true;
3797
3779
  const roster = await this.deps.store.update((current) => {
3798
3780
  known = standingOf(current, device.x25519) !== "unknown";
3799
- return mayEnrol(current, via) ? enrolDevice(current, device, this.deps.now()) : current;
3781
+ return current.enrolment ? enrolDevice(current, device, this.deps.now()) : current;
3800
3782
  });
3801
3783
  this.deps.onChange();
3802
3784
  const entry = known ? void 0 : roster.devices.find((d) => d.x25519 === device.x25519);
3803
3785
  try {
3804
- if (entry) this.deps.onEnrolled?.({ id: entry.id, name: entry.name, at: entry.enrolledAt, x25519: entry.x25519 }, via);
3786
+ if (entry) this.deps.onEnrolled?.({ id: entry.id, name: entry.name, at: entry.enrolledAt, x25519: entry.x25519 });
3805
3787
  } catch {
3806
3788
  }
3807
3789
  return standingOf(roster, device.x25519) === "enrolled";
3808
3790
  }
3809
3791
  // Spends a pairing secret for the device (X25519 key) about to be enrolled, binding it to that
3810
3792
  // device. Atomic across processes, so two phones racing with one code cannot both get in. A
3811
- // secret this agent never issued (or has forgotten) gives no reason: it may be a token after all.
3793
+ // secret this agent never issued (or has forgotten) gives no reason.
3812
3794
  async redeemPairing(secret, device) {
3813
3795
  if (!(await this.deps.store.read()).enrolment) return { redeemed: false, reason: REFUSAL.enrolmentClosed };
3814
3796
  const outcome = await this.spend(secret, device);
@@ -3870,8 +3852,8 @@ function startDeviceGate(deps) {
3870
3852
  pairings: deps.pairings,
3871
3853
  now: () => /* @__PURE__ */ new Date(),
3872
3854
  onChange: wrote,
3873
- onEnrolled: (device, via) => {
3874
- log(`device enrolled: ${device.id} "${device.name}" (${via === "pairing" ? "pairing code" : "token"})`);
3855
+ onEnrolled: (device) => {
3856
+ log(`device enrolled: ${device.id} "${device.name}" (pairing code)`);
3875
3857
  server.broadcastDeviceEnrolled(device);
3876
3858
  }
3877
3859
  });
@@ -4041,7 +4023,7 @@ function limitedDevices(devices, limiter, roster) {
4041
4023
  if (known && standingOf(known, x25519) === "enrolled") return { standing: "enrolled", enrolment: enrolmentMode(known) };
4042
4024
  return limited(limiter, () => devices.check(x25519));
4043
4025
  },
4044
- enrol: (device, via) => devices.enrol(device, via),
4026
+ enrol: (device) => devices.enrol(device),
4045
4027
  redeemPairing: (secret, device) => limited(limiter, () => devices.redeemPairing(secret, device)),
4046
4028
  burnPairing: (secret, device) => devices.burnPairing(secret, device)
4047
4029
  };
@@ -4202,7 +4184,6 @@ async function main() {
4202
4184
  }
4203
4185
  const env = withLegacyEnv(process.env);
4204
4186
  const config = loadConfig(env, homedir2());
4205
- assertServerConfig(config);
4206
4187
  for (const warning of configWarnings(process.env)) process.stderr.write(`${warning}
4207
4188
  `);
4208
4189
  await securePrivateDir(config.stateDir);
@@ -4211,7 +4192,7 @@ async function main() {
4211
4192
  config.devicesPath,
4212
4193
  config.pairingPath,
4213
4194
  join7(config.stateDir, "token"),
4214
- // It holds TARFA_TOKEN, which enrols a phone.
4195
+ // The launcher sources it: whoever can write it runs code as this user.
4215
4196
  await locateAgentEnv(env, homedir2())
4216
4197
  ]);
4217
4198
  const hookToken = await writeHookToken(config.stateDir);
@@ -4283,10 +4264,12 @@ async function main() {
4283
4264
  const keys = await loadOrCreateAgentKeys(config.agentKeyPath);
4284
4265
  const log = (message) => process.stdout.write(`${message}
4285
4266
  `);
4267
+ const devices = new FileDeviceStore(config.devicesPath);
4268
+ if (await devices.sealTokenEnrolment()) log("tarfa agent: wrote token enrolment off into devices.json (phones join with `tarfa pair` only)");
4286
4269
  const gate = startDeviceGate({
4287
4270
  keys,
4288
4271
  server,
4289
- store: new FileDeviceStore(config.devicesPath),
4272
+ store: devices,
4290
4273
  pairings: new FilePairingStore(config.pairingPath),
4291
4274
  log,
4292
4275
  rosterPollMs: ROSTER_POLL_MS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tarfa/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Tarfa agent and CLI: serves your Claude Code terminal sessions (tmux workspaces) to the Tarfa phone app.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,8 +40,17 @@ for mise_bin in "$home/.local/bin/mise" /opt/homebrew/bin/mise /usr/local/bin/mi
40
40
  if [ -x "$mise_bin" ]; then
41
41
  install="$("$mise_bin" --cd "$home" where npm:@tarfa/cli 2>/dev/null || true)"
42
42
  node_home="$("$mise_bin" --cd "$home" where node 2>/dev/null || true)"
43
- if [ -n "$install" ] && [ -n "$node_home" ] && [ -x "$node_home/bin/node" ] && [ -f "$install/bin/tarfa" ]; then
44
- exec "$node_home/bin/node" "$install/bin/tarfa" serve
43
+ # npm links the CLI into <install>/bin; aube (a newer mise's npm backend) leaves it in the
44
+ # package under node_modules, whose .bin/tarfa is a shell script node cannot run.
45
+ cli=""
46
+ for candidate in "$install/bin/tarfa" "$install/node_modules/@tarfa/cli/dist/cli.js"; do
47
+ if [ -n "$install" ] && [ -f "$candidate" ]; then
48
+ cli="$candidate"
49
+ break
50
+ fi
51
+ done
52
+ if [ -n "$cli" ] && [ -n "$node_home" ] && [ -x "$node_home/bin/node" ]; then
53
+ exec "$node_home/bin/node" "$cli" serve
45
54
  fi
46
55
  exec "$mise_bin" exec -- tarfa serve
47
56
  fi
@@ -22,8 +22,17 @@ fi
22
22
  if [ -n "$mise_bin" ]; then
23
23
  install="$("$mise_bin" --cd "$HOME" where npm:@tarfa/cli 2>/dev/null || true)"
24
24
  node_home="$("$mise_bin" --cd "$HOME" where node 2>/dev/null || true)"
25
- if [ -n "$install" ] && [ -n "$node_home" ] && [ -x "$node_home/bin/node" ] && [ -f "$install/bin/tarfa" ]; then
26
- exec "$node_home/bin/node" "$install/bin/tarfa" "$@"
25
+ # npm links the CLI into <install>/bin; aube (a newer mise's npm backend) leaves it in the
26
+ # package under node_modules, whose .bin/tarfa is a shell script node cannot run.
27
+ cli=""
28
+ for candidate in "$install/bin/tarfa" "$install/node_modules/@tarfa/cli/dist/cli.js"; do
29
+ if [ -n "$install" ] && [ -f "$candidate" ]; then
30
+ cli="$candidate"
31
+ break
32
+ fi
33
+ done
34
+ if [ -n "$cli" ] && [ -n "$node_home" ] && [ -x "$node_home/bin/node" ]; then
35
+ exec "$node_home/bin/node" "$cli" "$@"
27
36
  fi
28
37
  fi
29
38