@p4code/cli 0.0.48 → 0.0.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.mjs CHANGED
@@ -55,6 +55,7 @@ import * as Exit from "effect/Exit";
55
55
  import * as SchemaGetter from "effect/SchemaGetter";
56
56
  import * as Config from "effect/Config";
57
57
  import * as NodeReadline from "node:readline";
58
+ import * as Clock from "effect/Clock";
58
59
  import * as Cache from "effect/Cache";
59
60
  import * as Deferred from "effect/Deferred";
60
61
  import * as Equal from "effect/Equal";
@@ -62,7 +63,6 @@ import * as Semaphore from "effect/Semaphore";
62
63
  import * as Scope from "effect/Scope";
63
64
  import { parse } from "yaml";
64
65
  import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
65
- import * as Clock from "effect/Clock";
66
66
  import * as Metric from "effect/Metric";
67
67
  import * as Queue from "effect/Queue";
68
68
  import { cast, dual } from "effect/Function";
@@ -121,7 +121,7 @@ const closeServer = (server) => {
121
121
  * NetService - Service tag for startup networking helpers.
122
122
  */
123
123
  var NetService = class extends Context.Service()("@p4code/shared/Net/NetService") {};
124
- const make$71 = () => {
124
+ const make$72 = () => {
125
125
  /**
126
126
  * Returns true when a TCP server can bind to {host, port}.
127
127
  * `EADDRNOTAVAIL` is treated as available so IPv6-absent hosts don't fail
@@ -230,10 +230,10 @@ const make$71 = () => {
230
230
  })
231
231
  };
232
232
  };
233
- const layer$67 = Layer.sync(NetService, make$71);
233
+ const layer$68 = Layer.sync(NetService, make$72);
234
234
  //#endregion
235
235
  //#region package.json
236
- var version = "0.0.48";
236
+ var version = "0.0.50";
237
237
  //#endregion
238
238
  //#region src/config.ts
239
239
  /**
@@ -254,8 +254,8 @@ var ServerConfig$1 = class extends Context.Service()("@p4code/cli/config/ServerC
254
254
  /** @deprecated Import and use `layerTest` from this module. */
255
255
  static layerTest = (cwd, baseDirOrPrefix) => layerTest$2(cwd, baseDirOrPrefix);
256
256
  };
257
- const make$70 = (config) => ServerConfig$1.of(config);
258
- const layer$66 = (config) => Layer.succeed(ServerConfig$1, make$70(config));
257
+ const make$71 = (config) => ServerConfig$1.of(config);
258
+ const layer$67 = (config) => Layer.succeed(ServerConfig$1, make$71(config));
259
259
  const deriveServerPaths = Effect.fn(function* (baseDir, devUrl, options = {}) {
260
260
  const { join } = yield* Path.Path;
261
261
  const stateDir = join(baseDir, devUrl !== void 0 && !options.baseDirIsExplicit ? "dev" : "userdata");
@@ -869,7 +869,7 @@ function compareSemverVersions(left, right) {
869
869
  * timeout, every failure swallowed, and never anything that changes an exit
870
870
  * code. A version check that can break a command is worse than no check.
871
871
  */
872
- const PACKAGE_NAME = "@p4code/cli";
872
+ const PACKAGE_NAME$1 = "@p4code/cli";
873
873
  const CHECK_INTERVAL = Duration.hours(24);
874
874
  const REQUEST_TIMEOUT = Duration.seconds(3);
875
875
  /**
@@ -889,7 +889,7 @@ const encodeCacheEntry = Schema$1.encodeEffect(CacheEntryJson);
889
889
  const NpmLatest = Schema$1.Struct({ version: Schema$1.optional(Schema$1.String) });
890
890
  const fetchLatestVersion = Effect.fn("cli.updateCheck.fetch")(function* () {
891
891
  const client = yield* HttpClient.HttpClient;
892
- const request = HttpClientRequest.get(`https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`).pipe(HttpClientRequest.setHeader("accept", "application/json"));
892
+ const request = HttpClientRequest.get(`https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME$1)}/latest`).pipe(HttpClientRequest.setHeader("accept", "application/json"));
893
893
  const response = yield* client.execute(request).pipe(Effect.timeout(REQUEST_TIMEOUT));
894
894
  if (response.status < 200 || response.status >= 300) return null;
895
895
  const version = (yield* response.json.pipe(Effect.flatMap(Schema$1.decodeUnknownEffect(NpmLatest)))).version?.trim();
@@ -923,7 +923,7 @@ const resolveLatestVersion = Effect.fn("cli.updateCheck.resolve")(function* (sta
923
923
  * `service update` restarts it onto the new one.
924
924
  */
925
925
  function formatUpdateReminder(input) {
926
- return [`Update available: ${PACKAGE_NAME}@${input.latestVersion} (running ${input.currentVersion}).`, ` npm i -g ${PACKAGE_NAME}@latest && p4c service update`].join("\n");
926
+ return [`Update available: ${PACKAGE_NAME$1}@${input.latestVersion} (running ${input.currentVersion}).`, ` npm i -g ${PACKAGE_NAME$1}@latest && p4c service update`].join("\n");
927
927
  }
928
928
  /**
929
929
  * The reminder line, or null when there is nothing to say. Callers print it;
@@ -8736,7 +8736,7 @@ function deriveAuthClientMetadata(input) {
8736
8736
  //#endregion
8737
8737
  //#region src/auth/EnvironmentAuthPolicy.ts
8738
8738
  var EnvironmentAuthPolicy = class extends Context.Service()("@p4code/cli/auth/EnvironmentAuthPolicy") {};
8739
- const make$69 = Effect.gen(function* () {
8739
+ const make$70 = Effect.gen(function* () {
8740
8740
  const config = yield* ServerConfig$1;
8741
8741
  const isRemoteReachable = isRemoteReachableHost(config.host);
8742
8742
  const policy = config.mode === "desktop" ? isRemoteReachable ? "remote-reachable" : "desktop-managed-local" : isRemoteReachable ? "remote-reachable" : "loopback-browser";
@@ -8754,7 +8754,7 @@ const make$69 = Effect.gen(function* () {
8754
8754
  };
8755
8755
  return EnvironmentAuthPolicy.of({ getDescriptor: () => Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuthPolicy.getDescriptor")) });
8756
8756
  });
8757
- const layer$65 = Layer.effect(EnvironmentAuthPolicy, make$69);
8757
+ const layer$66 = Layer.effect(EnvironmentAuthPolicy, make$70);
8758
8758
  //#endregion
8759
8759
  //#region src/persistence/Errors.ts
8760
8760
  function summarizeSchemaIssue(issue) {
@@ -8935,7 +8935,7 @@ function toPersistenceSqlOrDecodeError$6(sqlOperation, decodeOperation, correlat
8935
8935
  cause
8936
8936
  });
8937
8937
  }
8938
- const make$68 = Effect.gen(function* () {
8938
+ const make$69 = Effect.gen(function* () {
8939
8939
  const sql = yield* SqlClient.SqlClient;
8940
8940
  const createSessionRow = SqlSchema.void({
8941
8941
  Request: CreateAuthSessionInput,
@@ -9069,7 +9069,7 @@ const make$68 = Effect.gen(function* () {
9069
9069
  setLastConnectedAt
9070
9070
  };
9071
9071
  });
9072
- const layer$64 = Layer.effect(AuthSessionRepository, make$68);
9072
+ const layer$65 = Layer.effect(AuthSessionRepository, make$69);
9073
9073
  //#endregion
9074
9074
  //#region src/auth/ServerSecretStore.ts
9075
9075
  const secretStoreErrorContext = {
@@ -9136,7 +9136,7 @@ const isSecretStoreError = Schema$1.is(SecretStoreError);
9136
9136
  const isPlatformError = (value) => Predicate.isTagged(value, "PlatformError");
9137
9137
  const isSecretAlreadyExistsError = (error) => "cause" in error && isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists";
9138
9138
  var ServerSecretStore = class extends Context.Service()("@p4code/cli/auth/ServerSecretStore") {};
9139
- const make$67 = Effect.gen(function* () {
9139
+ const make$68 = Effect.gen(function* () {
9140
9140
  const crypto = yield* Crypto.Crypto;
9141
9141
  const fileSystem = yield* FileSystem.FileSystem;
9142
9142
  const path = yield* Path.Path;
@@ -9206,7 +9206,7 @@ const make$67 = Effect.gen(function* () {
9206
9206
  remove
9207
9207
  });
9208
9208
  });
9209
- const layer$63 = Layer.effect(ServerSecretStore, make$67);
9209
+ const layer$64 = Layer.effect(ServerSecretStore, make$68);
9210
9210
  //#endregion
9211
9211
  //#region src/auth/SessionStore.ts
9212
9212
  var MalformedSessionTokenError = class extends Schema$1.TaggedErrorClass()("MalformedSessionTokenError", {}) {
@@ -9444,7 +9444,7 @@ function toAuthClientSession(input) {
9444
9444
  current: false
9445
9445
  };
9446
9446
  }
9447
- const make$66 = Effect.gen(function* () {
9447
+ const make$67 = Effect.gen(function* () {
9448
9448
  const crypto = yield* Crypto.Crypto;
9449
9449
  const serverConfig = yield* ServerConfig$1;
9450
9450
  const secretStore = yield* ServerSecretStore;
@@ -9758,7 +9758,7 @@ const make$66 = Effect.gen(function* () {
9758
9758
  markDisconnected
9759
9759
  });
9760
9760
  });
9761
- const layer$62 = Layer.effect(SessionStore, make$66).pipe(Layer.provideMerge(layer$64));
9761
+ const layer$63 = Layer.effect(SessionStore, make$67).pipe(Layer.provideMerge(layer$65));
9762
9762
  //#endregion
9763
9763
  //#region src/persistence/AuthPairingLinks.ts
9764
9764
  const AuthPairingLinkRecord = Schema$1.Struct({
@@ -9819,7 +9819,7 @@ function toPersistenceSqlOrDecodeError$5(sqlOperation, decodeOperation, correlat
9819
9819
  cause
9820
9820
  });
9821
9821
  }
9822
- const make$65 = Effect.gen(function* () {
9822
+ const make$66 = Effect.gen(function* () {
9823
9823
  const sql = yield* SqlClient.SqlClient;
9824
9824
  const createPairingLinkRow = SqlSchema.void({
9825
9825
  Request: CreateAuthPairingLinkInput,
@@ -9954,7 +9954,7 @@ const make$65 = Effect.gen(function* () {
9954
9954
  getByCredential
9955
9955
  };
9956
9956
  });
9957
- const layer$61 = Layer.effect(AuthPairingLinkRepository, make$65);
9957
+ const layer$62 = Layer.effect(AuthPairingLinkRepository, make$66);
9958
9958
  //#endregion
9959
9959
  //#region src/auth/PairingGrantStore.ts
9960
9960
  var UnknownBootstrapCredentialError = class extends Schema$1.TaggedErrorClass()("UnknownBootstrapCredentialError", {}) {
@@ -10049,7 +10049,7 @@ const DEV_STARTUP_TTL_HOURS = Duration.hours(24);
10049
10049
  const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
10050
10050
  const PAIRING_TOKEN_LENGTH = 12;
10051
10051
  const PAIRING_TOKEN_REJECTION_LIMIT = Math.floor(256 / 32) * 32;
10052
- const make$64 = Effect.gen(function* () {
10052
+ const make$65 = Effect.gen(function* () {
10053
10053
  const crypto = yield* Crypto.Crypto;
10054
10054
  const config = yield* ServerConfig$1;
10055
10055
  const pairingLinks = yield* AuthPairingLinkRepository;
@@ -10247,7 +10247,7 @@ const make$64 = Effect.gen(function* () {
10247
10247
  consume
10248
10248
  });
10249
10249
  });
10250
- const layer$60 = Layer.effect(PairingGrantStore, make$64).pipe(Layer.provideMerge(layer$61));
10250
+ const layer$61 = Layer.effect(PairingGrantStore, make$65).pipe(Layer.provideMerge(layer$62));
10251
10251
  //#endregion
10252
10252
  //#region src/persistence/Migrations/001_OrchestrationEvents.ts
10253
10253
  var _001_OrchestrationEvents_default = Effect.gen(function* () {
@@ -12020,7 +12020,7 @@ function parseBearerToken(request) {
12020
12020
  const token = header.slice(7).trim();
12021
12021
  return token.length > 0 ? token : null;
12022
12022
  }
12023
- const make$63 = Effect.gen(function* () {
12023
+ const make$64 = Effect.gen(function* () {
12024
12024
  const policy = yield* EnvironmentAuthPolicy;
12025
12025
  const bootstrapCredentials = yield* PairingGrantStore;
12026
12026
  const sessions = yield* SessionStore;
@@ -12215,9 +12215,9 @@ const make$63 = Effect.gen(function* () {
12215
12215
  issueStartupPairingUrl
12216
12216
  });
12217
12217
  });
12218
- const layer$59 = Layer.effect(EnvironmentAuth, make$63).pipe(Layer.provideMerge(layer$60), Layer.provideMerge(layer$62), Layer.provideMerge(layer$65));
12219
- const storageLayer = Layer.mergeAll(layer$63, layerConfig);
12220
- const runtimeLayer = layer$59.pipe(Layer.provideMerge(storageLayer));
12218
+ const layer$60 = Layer.effect(EnvironmentAuth, make$64).pipe(Layer.provideMerge(layer$61), Layer.provideMerge(layer$63), Layer.provideMerge(layer$66));
12219
+ const storageLayer = Layer.mergeAll(layer$64, layerConfig);
12220
+ const runtimeLayer = layer$60.pipe(Layer.provideMerge(storageLayer));
12221
12221
  //#endregion
12222
12222
  //#region src/cliAuthFormat.ts
12223
12223
  const newline = "\n";
@@ -12763,8 +12763,8 @@ const noBrowserFlag = Flag.boolean("no-browser").pipe(Flag.withDescription("Disa
12763
12763
  const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe(Flag.withSchema(Schema$1.Int), Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), Flag.optional);
12764
12764
  const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe(Flag.withDescription("Create a project for the current working directory on startup when missing."), Flag.optional);
12765
12765
  const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe(Flag.withDescription("Emit server-side logs for outbound WebSocket push traffic (equivalent to P4CODE_LOG_WS_EVENTS)."), Flag.withAlias("log-ws-events"), Flag.optional);
12766
- const tailscaleServeFlag = Flag.boolean("tailscale-serve").pipe(Flag.withDescription("Configure Tailscale Serve to expose this backend over HTTPS on the Tailnet."), Flag.optional);
12767
- const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe(Flag.withSchema(PortSchema), Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), Flag.optional);
12766
+ const tailscaleServeFlag$1 = Flag.boolean("tailscale-serve").pipe(Flag.withDescription("Configure Tailscale Serve to expose this backend over HTTPS on the Tailnet."), Flag.optional);
12767
+ const tailscaleServePortFlag$1 = Flag.integer("tailscale-serve-port").pipe(Flag.withSchema(PortSchema), Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), Flag.optional);
12768
12768
  const EnvServerConfig = Config.all({
12769
12769
  logLevel: Config.logLevel("P4CODE_LOG_LEVEL").pipe(Config.withDefault("Info")),
12770
12770
  traceMinLevel: Config.logLevel("P4CODE_TRACE_MIN_LEVEL").pipe(Config.withDefault("Info")),
@@ -12806,8 +12806,8 @@ const sharedServerCommandFlags = {
12806
12806
  bootstrapFd: bootstrapFdFlag,
12807
12807
  autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
12808
12808
  logWebSocketEvents: logWebSocketEventsFlag,
12809
- tailscaleServeEnabled: tailscaleServeFlag,
12810
- tailscaleServePort: tailscaleServePortFlag
12809
+ tailscaleServeEnabled: tailscaleServeFlag$1,
12810
+ tailscaleServePort: tailscaleServePortFlag$1
12811
12811
  };
12812
12812
  const authLocationFlags = sharedServerLocationFlags;
12813
12813
  const resolveOptionPrecedence = (...values) => Option.firstSomeOf(values);
@@ -12972,7 +12972,7 @@ const runWithEnvironmentAuth = (flags, run, options) => Effect.gen(function* ()
12972
12972
  }
12973
12973
  return yield* Effect.gen(function* () {
12974
12974
  return yield* run(yield* EnvironmentAuth);
12975
- }).pipe(Effect.provide(Layer.mergeAll(runtimeLayer).pipe(Layer.provide(layer$66(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)))));
12975
+ }).pipe(Effect.provide(Layer.mergeAll(runtimeLayer).pipe(Layer.provide(layer$67(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)))));
12976
12976
  });
12977
12977
  const ttlFlag = Flag.string("ttl").pipe(Flag.withSchema(DurationFromString), Flag.withDescription("TTL, for example `5m`, `1h`, `30d`, or `15 minutes`."), Flag.optional);
12978
12978
  const jsonFlag = Flag.boolean("json").pipe(Flag.withDescription("Emit JSON instead of human-readable output."), Flag.withDefault(false));
@@ -13069,6 +13069,64 @@ const authCommand = Command.make("auth").pipe(Command.withDescription("Manage th
13069
13069
  * @module HubConfig
13070
13070
  */
13071
13071
  var HubConfig = class extends Context.Service()("@p4code/cli/hub/HubConfig") {};
13072
+ const FAILURE_WINDOW_MS = 6e4;
13073
+ const BLOCK_DURATION_MS = 6e4;
13074
+ const initialThrottleState = {
13075
+ windowStartMillis: 0,
13076
+ failures: 0,
13077
+ blockedUntilMillis: 0
13078
+ };
13079
+ /** Whether a *failing* request should be refused outright rather than checked. */
13080
+ const isBlocked = (state, nowMillis) => nowMillis < state.blockedUntilMillis;
13081
+ /** Seconds a caller should wait, for `retry-after`. Always at least one. */
13082
+ const retryAfterSeconds = (state, nowMillis) => Math.max(1, Math.ceil((state.blockedUntilMillis - nowMillis) / 1e3));
13083
+ /**
13084
+ * Count a failure, and start blocking once the window fills.
13085
+ *
13086
+ * The window is fixed rather than sliding: a sliding window needs the timestamp
13087
+ * of every attempt, which is unbounded memory for an unbounded attacker. A
13088
+ * fixed window lets a burst straddle a boundary and land up to twice the limit,
13089
+ * which for this purpose is a rounding error against the size of the search
13090
+ * space.
13091
+ */
13092
+ const recordFailure = (state, nowMillis, limits = {
13093
+ maxFailures: 10,
13094
+ windowMillis: FAILURE_WINDOW_MS,
13095
+ blockMillis: BLOCK_DURATION_MS
13096
+ }) => {
13097
+ if (nowMillis - state.windowStartMillis >= limits.windowMillis) return {
13098
+ windowStartMillis: nowMillis,
13099
+ failures: 1,
13100
+ blockedUntilMillis: 0
13101
+ };
13102
+ const failures = state.failures + 1;
13103
+ return {
13104
+ windowStartMillis: state.windowStartMillis,
13105
+ failures,
13106
+ blockedUntilMillis: failures >= limits.maxFailures ? nowMillis + limits.blockMillis : state.blockedUntilMillis
13107
+ };
13108
+ };
13109
+ var HubAuthThrottle = class extends Context.Service()("@p4code/cli/hub/HubAuthThrottle") {};
13110
+ const DEFAULT_LIMITS = {
13111
+ maxFailures: 10,
13112
+ windowMillis: FAILURE_WINDOW_MS,
13113
+ blockMillis: BLOCK_DURATION_MS
13114
+ };
13115
+ const make$63 = Effect.fn("HubAuthThrottle.make")(function* (limits = DEFAULT_LIMITS) {
13116
+ const state = yield* Ref.make(initialThrottleState);
13117
+ return HubAuthThrottle.of({
13118
+ shouldRefuse: Effect.gen(function* () {
13119
+ const now = yield* Clock.currentTimeMillis;
13120
+ return isBlocked(yield* Ref.get(state), now);
13121
+ }),
13122
+ recordFailure: Effect.gen(function* () {
13123
+ const now = yield* Clock.currentTimeMillis;
13124
+ const next = yield* Ref.updateAndGet(state, (current) => recordFailure(current, now, limits));
13125
+ return retryAfterSeconds(next, now);
13126
+ })
13127
+ });
13128
+ });
13129
+ const layer$59 = Layer.effect(HubAuthThrottle, make$63());
13072
13130
  //#endregion
13073
13131
  //#region src/hub/HubAuth.ts
13074
13132
  /**
@@ -13127,6 +13185,31 @@ var HubUnauthorizedError = class {
13127
13185
  _tag = "HubUnauthorizedError";
13128
13186
  };
13129
13187
  /**
13188
+ * Too many failed attempts recently; this one was not even checked.
13189
+ *
13190
+ * Distinct from `HubUnauthorizedError` so the response can differ. A 429 is the
13191
+ * honest answer — the credential was not evaluated, so calling it invalid would
13192
+ * be a claim the hub did not make — and it tells a legitimate server retrying a
13193
+ * stale token to back off rather than hammer.
13194
+ */
13195
+ var HubThrottledError = class {
13196
+ _tag = "HubThrottledError";
13197
+ retryAfterSeconds;
13198
+ constructor(retryAfterSeconds) {
13199
+ this.retryAfterSeconds = retryAfterSeconds;
13200
+ }
13201
+ };
13202
+ const hubThrottledResponse = (retryAfterSeconds) => HttpServerResponse.jsonUnsafe({
13203
+ error: "too_many_hub_credential_failures",
13204
+ message: "Too many failed credential attempts. Try again shortly."
13205
+ }, {
13206
+ status: 429,
13207
+ headers: {
13208
+ "cache-control": "no-store",
13209
+ "retry-after": String(retryAfterSeconds)
13210
+ }
13211
+ });
13212
+ /**
13130
13213
  * Fails with {@link HubUnauthorizedError} unless the request carries a
13131
13214
  * configured bearer token. Route handlers run this first and let
13132
13215
  * `respondToHubAuthFailure` turn the failure into a 401.
@@ -13134,11 +13217,17 @@ var HubUnauthorizedError = class {
13134
13217
  const authenticateHubRequest = Effect.fn("HubAuth.authenticateHubRequest")(function* () {
13135
13218
  const request = yield* HttpServerRequest.HttpServerRequest;
13136
13219
  const { tokenHashes } = yield* HubConfig;
13220
+ const throttle = yield* HubAuthThrottle;
13137
13221
  const token = readBearerToken(request.headers.authorization);
13138
- if (!isAuthorizedHubToken(token, tokenHashes)) {
13139
- yield* Effect.logWarning("rejected hub request with an unusable credential", { reason: token.length === 0 ? "missing_bearer_token" : "unknown_token" });
13140
- return yield* Effect.fail(new HubUnauthorizedError());
13222
+ if (isAuthorizedHubToken(token, tokenHashes)) return;
13223
+ if (yield* throttle.shouldRefuse) {
13224
+ const retryAfter = yield* throttle.recordFailure;
13225
+ yield* Effect.logWarning("refused hub request without checking; too many recent failures", { reason: token.length === 0 ? "missing_bearer_token" : "unknown_token" });
13226
+ return yield* Effect.fail(new HubThrottledError(retryAfter));
13141
13227
  }
13228
+ yield* throttle.recordFailure;
13229
+ yield* Effect.logWarning("rejected hub request with an unusable credential", { reason: token.length === 0 ? "missing_bearer_token" : "unknown_token" });
13230
+ return yield* Effect.fail(new HubUnauthorizedError());
13142
13231
  });
13143
13232
  const SHA256_HEX = /^[0-9a-f]{64}$/;
13144
13233
  /**
@@ -13547,6 +13636,7 @@ const storeUnavailable = HttpServerResponse.jsonUnsafe({
13547
13636
  */
13548
13637
  const respondToHubFailures = (handler) => handler.pipe(Effect.catch((cause) => {
13549
13638
  if (cause._tag === "HubUnauthorizedError") return Effect.succeed(hubUnauthorizedResponse);
13639
+ if (cause._tag === "HubThrottledError") return Effect.succeed(hubThrottledResponse(cause.retryAfterSeconds));
13550
13640
  if (cause._tag === "InvalidHubRequest") return Effect.succeed(badRequest(cause.detail));
13551
13641
  return Effect.logWarning("hub request failed", { cause }).pipe(Effect.as(storeUnavailable));
13552
13642
  }));
@@ -13942,7 +14032,7 @@ const announce = Layer.effectDiscard(Effect.gen(function* () {
13942
14032
  });
13943
14033
  if (isRemoteReachableHost(config.host)) yield* Effect.logWarning("Hub is reachable beyond loopback. The bearer token is the only control — repeated auth failures are not rate limited.", { host: config.host });
13944
14034
  }));
13945
- const makeHubLayer = HttpRouter.serve(layer$58).pipe(Layer.provideMerge(announce), Layer.provideMerge(HubHttpServerLive), Layer.provideMerge(TaskRepositoryLive), Layer.provideMerge(AgentAssetRepositoryLive), Layer.provideMerge(HubPersistenceLive));
14035
+ const makeHubLayer = HttpRouter.serve(layer$58).pipe(Layer.provideMerge(announce), Layer.provideMerge(HubHttpServerLive), Layer.provideMerge(layer$59), Layer.provideMerge(TaskRepositoryLive), Layer.provideMerge(AgentAssetRepositoryLive), Layer.provideMerge(HubPersistenceLive));
13946
14036
  const runHub = Layer.launch(makeHubLayer);
13947
14037
  //#endregion
13948
14038
  //#region src/atomicWrite.ts
@@ -16126,7 +16216,7 @@ const make$58 = Effect.gen(function* () {
16126
16216
  const layer$53 = Layer.effect(AssetSync, make$58);
16127
16217
  //#endregion
16128
16218
  //#region src/cli/assets.ts
16129
- const SyncServicesLive = layer$53.pipe(Layer.provideMerge(layer$54), Layer.provideMerge(layer$55), Layer.provideMerge(layer$57.pipe(Layer.provide(layer$63))), Layer.provideMerge(layer$63), Layer.provideMerge(FetchHttpClient.layer));
16219
+ const SyncServicesLive = layer$53.pipe(Layer.provideMerge(layer$54), Layer.provideMerge(layer$55), Layer.provideMerge(layer$57.pipe(Layer.provide(layer$64))), Layer.provideMerge(layer$64), Layer.provideMerge(FetchHttpClient.layer));
16130
16220
  const runWithSync = (flags, run) => Effect.gen(function* () {
16131
16221
  const config = yield* resolveCliAuthConfig(flags, yield* GlobalFlag.LogLevel);
16132
16222
  return yield* Effect.gen(function* () {
@@ -16134,7 +16224,7 @@ const runWithSync = (flags, run) => Effect.gen(function* () {
16134
16224
  sync: yield* AssetSync,
16135
16225
  link: yield* HubLink
16136
16226
  });
16137
- }).pipe(Effect.provide(SyncServicesLive.pipe(Layer.provide(layer$66(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, "Error")))));
16227
+ }).pipe(Effect.provide(SyncServicesLive.pipe(Layer.provide(layer$67(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, "Error")))));
16138
16228
  });
16139
16229
  const STATE_LABEL = {
16140
16230
  synced: "synced",
@@ -24098,6 +24188,92 @@ const removePinnedRuntimeInstallation = Effect.fn("cloud.pinned_runtime.remove")
24098
24188
  cause
24099
24189
  }))));
24100
24190
  });
24191
+ //#endregion
24192
+ //#region src/service/serviceOptions.ts
24193
+ /**
24194
+ * The options a supervised server keeps between installs.
24195
+ *
24196
+ * The unit file is generated, not edited: `p4c service update` rewrites it on
24197
+ * every upgrade. So anything configured by hand in it — a bind host, Tailscale
24198
+ * Serve — disappears at the next update, silently, weeks after the person who
24199
+ * added it has forgotten it was there. That is a worse failure than not
24200
+ * supporting the option at all, because the machine works until it does not.
24201
+ *
24202
+ * These options are therefore stored beside the server's other state and read
24203
+ * back when the unit is rendered. `install`, `update` and `status` all go
24204
+ * through the same path, so an option set once survives every later upgrade,
24205
+ * and `status` notices when the file and the unit disagree — which is what
24206
+ * makes `update` rewrite the unit after an option changes.
24207
+ *
24208
+ * @module service/serviceOptions
24209
+ */
24210
+ /**
24211
+ * Tailscale Serve puts the server on HTTPS at the machine's MagicDNS name.
24212
+ *
24213
+ * Worth being an option rather than a default: it publishes the server to
24214
+ * everything on the tailnet, which is a decision about exposure and not a
24215
+ * detail of how the service is launched.
24216
+ */
24217
+ const TailscaleServeOptions = Schema$1.Struct({
24218
+ enabled: Schema$1.Boolean,
24219
+ port: Schema$1.Int.check(Schema$1.isGreaterThan(0), Schema$1.isLessThanOrEqualTo(65535))
24220
+ });
24221
+ const ServiceOptions = Schema$1.Struct({ tailscaleServe: Schema$1.optional(TailscaleServeOptions) });
24222
+ const EMPTY_SERVICE_OPTIONS = {};
24223
+ const OptionsFromJson = Schema$1.fromJsonString(ServiceOptions);
24224
+ const decodeOptions = Schema$1.decodeUnknownExit(OptionsFromJson);
24225
+ const encodeOptions = Schema$1.encodeSync(OptionsFromJson);
24226
+ const serviceOptionsPath = (input) => input.path.join(input.baseDir, "userdata", "service-options.json");
24227
+ /**
24228
+ * Read the stored options, or none.
24229
+ *
24230
+ * A missing file is the ordinary case — most machines never set an option — and
24231
+ * an unreadable or malformed one is treated the same way rather than failing
24232
+ * the install. The cost of ignoring it is a service that starts without an
24233
+ * option; the cost of failing is a machine with no service at all, and the
24234
+ * second is worse for a file nobody edits by hand.
24235
+ */
24236
+ const readServiceOptions = Effect.fn("service.readServiceOptions")(function* (input) {
24237
+ const fileSystem = yield* FileSystem.FileSystem;
24238
+ const path = yield* Path.Path;
24239
+ const contents = yield* fileSystem.readFileString(serviceOptionsPath({
24240
+ baseDir: input.baseDir,
24241
+ path
24242
+ })).pipe(Effect.orElseSucceed(() => void 0));
24243
+ if (contents === void 0) return EMPTY_SERVICE_OPTIONS;
24244
+ const decoded = decodeOptions(contents);
24245
+ return decoded._tag === "Success" ? decoded.value : EMPTY_SERVICE_OPTIONS;
24246
+ });
24247
+ const writeServiceOptions = Effect.fn("service.writeServiceOptions")(function* (input) {
24248
+ const fileSystem = yield* FileSystem.FileSystem;
24249
+ const path = yield* Path.Path;
24250
+ const filePath = serviceOptionsPath({
24251
+ baseDir: input.baseDir,
24252
+ path
24253
+ });
24254
+ yield* fileSystem.makeDirectory(path.dirname(filePath), { recursive: true }).pipe(Effect.ignore);
24255
+ yield* writeFileStringAtomically({
24256
+ filePath,
24257
+ contents: `${encodeOptions(input.options)}\n`
24258
+ });
24259
+ });
24260
+ /**
24261
+ * The environment the unit sets, derived from the options.
24262
+ *
24263
+ * Pairs rather than a record so the rendered unit is byte-stable: a record's
24264
+ * iteration order is stable in practice but nothing says so, and `status`
24265
+ * compares the rendered unit against the installed one to decide whether an
24266
+ * update is needed. A reordering would read as a change forever.
24267
+ */
24268
+ const serviceEnvironmentFor = (options) => {
24269
+ const pairs = [];
24270
+ const tailscale = options.tailscaleServe;
24271
+ if (tailscale?.enabled === true) {
24272
+ pairs.push(["P4CODE_TAILSCALE_SERVE", "1"]);
24273
+ if (tailscale.port !== 443) pairs.push(["P4CODE_TAILSCALE_SERVE_PORT", String(tailscale.port)]);
24274
+ }
24275
+ return pairs;
24276
+ };
24101
24277
  const BOOT_SERVICE_UNIT_FILE = `p4code.service`;
24102
24278
  const BOOT_SERVICE_UNIT_ENV = "P4_BOOT_SERVICE_UNIT";
24103
24279
  /**
@@ -24158,6 +24334,7 @@ function renderBootServiceUnit(plan) {
24158
24334
  "WorkingDirectory=%h",
24159
24335
  `Environment=P4CODE_HOME=${quoteSystemdValue(plan.baseDir)}`,
24160
24336
  `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`,
24337
+ ...plan.serviceEnvironment.map(([name, value]) => `Environment=${name}=${quoteSystemdValue(value)}`),
24161
24338
  `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.p4EntryPath)} serve`,
24162
24339
  "Restart=always",
24163
24340
  "RestartSec=5",
@@ -24204,6 +24381,7 @@ function renderLaunchAgentPlist(plan) {
24204
24381
  ` <string>${escapeXmlText(plan.baseDir)}</string>`,
24205
24382
  ` <key>${escapeXmlText(BOOT_SERVICE_UNIT_ENV)}</key>`,
24206
24383
  ` <string>${escapeXmlText(LAUNCH_AGENT_PLIST_FILE)}</string>`,
24384
+ ...plan.serviceEnvironment.flatMap(([name, value]) => [` <key>${escapeXmlText(name)}</key>`, ` <string>${escapeXmlText(value)}</string>`]),
24207
24385
  " </dict>",
24208
24386
  " <key>RunAtLoad</key>",
24209
24387
  " <true/>",
@@ -24332,13 +24510,15 @@ const make$51 = Effect.fn("cloud.boot_service.make")(function* (input) {
24332
24510
  }) : new BootServiceInstallError({ cause: error })), Effect.tapError((error) => DateTime.now.pipe(Effect.flatMap((now) => fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, { flag: "a" })), Effect.ignore)));
24333
24511
  });
24334
24512
  const plannedEntryPath = isEphemeralCacheEntry(host.cliEntryPath) ? runtimePaths.entryPath : host.cliEntryPath;
24513
+ const serviceOptions = yield* readServiceOptions({ baseDir: input.baseDir });
24335
24514
  const plan = {
24336
24515
  nodePath: host.execPath,
24337
24516
  p4EntryPath: plannedEntryPath,
24338
24517
  baseDir: input.baseDir,
24339
24518
  logPath,
24340
24519
  unitPath,
24341
- workingDirectory: homeDir
24520
+ workingDirectory: homeDir,
24521
+ serviceEnvironment: serviceEnvironmentFor(serviceOptions)
24342
24522
  };
24343
24523
  const install = Effect.gen(function* () {
24344
24524
  yield* requireSupportedPlatform;
@@ -24492,7 +24672,7 @@ const RESTART_DELAY = Duration.seconds(2);
24492
24672
  /** Exact npm versions only — never dist-tags — so the acknowledgement names
24493
24673
  the version that was actually installed. Also keeps the value safe to
24494
24674
  pass to npm and embed in filesystem paths. */
24495
- const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
24675
+ const EXACT_VERSION_PATTERN$1 = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
24496
24676
  function normalizeEntryPath(entryPath) {
24497
24677
  return entryPath.replaceAll("\\", "/");
24498
24678
  }
@@ -24608,7 +24788,7 @@ const make$50 = Effect.fn("cloud.server_self_update.make")(function* (options) {
24608
24788
  if (capability === null) return yield* failWith("This server cannot update itself; relaunch it manually with the new version.");
24609
24789
  const activeMethod = capability;
24610
24790
  const targetVersion = input.targetVersion.trim();
24611
- if (!EXACT_VERSION_PATTERN.test(targetVersion)) return yield* failWith(`'${targetVersion}' is not an exact p4 version.`);
24791
+ if (!EXACT_VERSION_PATTERN$1.test(targetVersion)) return yield* failWith(`'${targetVersion}' is not an exact p4 version.`);
24612
24792
  if (yield* Ref.getAndSet(inFlight, true)) return yield* failWith("A server update is already in progress.");
24613
24793
  return yield* Effect.gen(function* () {
24614
24794
  const runtimePaths = yield* ensurePinnedRuntimeInstalled({
@@ -24637,13 +24817,15 @@ const make$50 = Effect.fn("cloud.server_self_update.make")(function* (options) {
24637
24817
  const homeDir = env.HOME ?? "";
24638
24818
  const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE);
24639
24819
  const previousUnit = yield* fs.readFileString(unitPath).pipe(Effect.mapError((cause) => failWith("Could not read the current systemd unit.", cause)));
24820
+ const serviceOptions = yield* readServiceOptions({ baseDir: serverConfig.baseDir }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path));
24640
24821
  const unit = renderBootServiceUnit({
24641
24822
  nodePath: host.execPath,
24642
24823
  p4EntryPath: runtimePaths.entryPath,
24643
24824
  baseDir: serverConfig.baseDir,
24644
24825
  logPath: path.join(serverConfig.logsDir, "boot-service.log"),
24645
24826
  unitPath,
24646
- workingDirectory: homeDir
24827
+ workingDirectory: homeDir,
24828
+ serviceEnvironment: serviceEnvironmentFor(serviceOptions)
24647
24829
  });
24648
24830
  yield* writeUnitAtomically(unitPath, unit).pipe(Effect.mapError((cause) => failWith("Could not update the systemd unit.", cause)));
24649
24831
  const reloadSystemd = Effect.fn("cloud.server_self_update.reload_systemd")(function* () {
@@ -26512,7 +26694,7 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* (flags, run
26512
26694
  });
26513
26695
  yield* Console.log(output);
26514
26696
  }));
26515
- const offlineRuntimeLayer = ProjectCliRuntimeLive.pipe(Layer.provide(layer$66(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)));
26697
+ const offlineRuntimeLayer = ProjectCliRuntimeLive.pipe(Layer.provide(layer$67(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)));
26516
26698
  return yield* Effect.gen(function* () {
26517
26699
  const snapshot = yield* getOfflineSnapshot();
26518
26700
  const orchestrationEngine = yield* OrchestrationEngineService;
@@ -26523,7 +26705,7 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* (flags, run
26523
26705
  });
26524
26706
  yield* Console.log(output);
26525
26707
  }).pipe(Effect.provide(offlineRuntimeLayer));
26526
- }).pipe(Effect.provide(Layer.mergeAll(runtimeLayer, layer$42).pipe(Layer.provideMerge(FetchHttpClient.layer), Layer.provide(layer$66(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)))));
26708
+ }).pipe(Effect.provide(Layer.mergeAll(runtimeLayer, layer$42).pipe(Layer.provideMerge(FetchHttpClient.layer), Layer.provide(layer$67(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)))));
26527
26709
  });
26528
26710
  const projectAddCommand = Command.make("add", {
26529
26711
  ...projectLocationFlags,
@@ -43519,7 +43701,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
43519
43701
  };
43520
43702
  });
43521
43703
  var OpenCodeRuntime = class extends Context.Service()("@p4code/cli/provider/opencodeRuntime") {};
43522
- const OpenCodeRuntimeLive = Layer.effect(OpenCodeRuntime, makeOpenCodeRuntime).pipe(Layer.provide(layer$67));
43704
+ const OpenCodeRuntimeLive = Layer.effect(OpenCodeRuntime, makeOpenCodeRuntime).pipe(Layer.provide(layer$68));
43523
43705
  //#endregion
43524
43706
  //#region src/textGeneration/TextGenerationUtils.ts
43525
43707
  const isTextGenerationError$2 = Schema$1.is(TextGenerationError);
@@ -82612,9 +82794,9 @@ const WorkspaceEntriesLayerLive = layer$26.pipe(Layer.provide(layer$42));
82612
82794
  const WorkspaceFileSystemLayerLive = layer$25.pipe(Layer.provide(layer$42), Layer.provide(WorkspaceEntriesLayerLive));
82613
82795
  const WorkspaceLayerLive = Layer.mergeAll(layer$42, WorkspaceEntriesLayerLive, WorkspaceFileSystemLayerLive);
82614
82796
  const ProjectFaviconResolverLayerLive = layer$40.pipe(Layer.provide(layer$42), Layer.provide(layer$41));
82615
- const AuthLayerLive = layer$59.pipe(Layer.provideMerge(PersistenceLayerLive), Layer.provide(layer$63));
82797
+ const AuthLayerLive = layer$60.pipe(Layer.provideMerge(PersistenceLayerLive), Layer.provide(layer$64));
82616
82798
  const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(Layer.provideMerge(ProviderLayerLive), Layer.provideMerge(OrchestrationLayerLive));
82617
- const RuntimeDependenciesLive = ReactorLayerLive.pipe(Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(layer$50), Layer.provideMerge(ProviderRegistryLive), Layer.provideMerge(ProviderInstanceRegistryHydrationLive), Layer.provideMerge(ProviderEventLoggersLive), Layer.provideMerge(OpenCodeRuntimeLive), Layer.provideMerge(layer$57.pipe(Layer.provide(layer$63))), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(layer$51), Layer.provideMerge(layer$44), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(layer$56), Layer.provideMerge(layer$63)).pipe(Layer.provideMerge(layer$8), Layer.provideMerge(layer$7), Layer.provideMerge(layer$6), Layer.provideMerge(layer$47), Layer.provideMerge(layer$49), Layer.provideMerge(layer$48), Layer.provide(layer$67));
82799
+ const RuntimeDependenciesLive = ReactorLayerLive.pipe(Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(layer$50), Layer.provideMerge(ProviderRegistryLive), Layer.provideMerge(ProviderInstanceRegistryHydrationLive), Layer.provideMerge(ProviderEventLoggersLive), Layer.provideMerge(OpenCodeRuntimeLive), Layer.provideMerge(layer$57.pipe(Layer.provide(layer$64))), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(layer$51), Layer.provideMerge(layer$44), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(layer$56), Layer.provideMerge(layer$64)).pipe(Layer.provideMerge(layer$8), Layer.provideMerge(layer$7), Layer.provideMerge(layer$6), Layer.provideMerge(layer$47), Layer.provideMerge(layer$49), Layer.provideMerge(layer$48), Layer.provide(layer$68));
82618
82800
  /**
82619
82801
  * Hub asset sync.
82620
82802
  *
@@ -82682,6 +82864,151 @@ const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe
82682
82864
  forceAutoBootstrapProjectFromCwd: false
82683
82865
  })));
82684
82866
  //#endregion
82867
+ //#region src/service/cliSelfUpdate.ts
82868
+ /**
82869
+ * Upgrading the globally installed CLI, then the service that runs it.
82870
+ *
82871
+ * Two steps that have to happen in that order and from different binaries, and
82872
+ * getting the order wrong is silent: `service update` renders the unit from the
82873
+ * *running* CLI, and pins that version into the runtime directory. Run the
82874
+ * reconcile from the old process and you install a new CLI and then carefully
82875
+ * point the service back at the old one.
82876
+ *
82877
+ * So the second step re-executes the newly installed binary. That is also why
82878
+ * this is not `ServerSelfUpdate`: that path respawns the server in place, which
82879
+ * it explicitly refuses to do under launchd — KeepAlive would restart the
82880
+ * process being replaced and leave two servers on one port. Under a supervisor
82881
+ * the honest sequence is install, then let the supervisor restart, which is
82882
+ * exactly what `service update` does.
82883
+ *
82884
+ * @module service/cliSelfUpdate
82885
+ */
82886
+ /** npm can take minutes when a native dependency compiles from source. */
82887
+ const CLI_INSTALL_TIMEOUT_MS = 600 * 1e3;
82888
+ const PACKAGE_NAME = "@p4code/cli";
82889
+ /**
82890
+ * Exact versions or the two dist-tags, and nothing else.
82891
+ *
82892
+ * The value is handed to npm and printed back as "installed X". An arbitrary
82893
+ * string could be a git URL or a local path, which npm would happily install
82894
+ * and which would make the acknowledgement a lie.
82895
+ */
82896
+ const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?$/u;
82897
+ const isInstallableVersion = (value) => value === "latest" || value === "next" || EXACT_VERSION_PATTERN.test(value);
82898
+ var CliSelfUpdateError = class extends Schema$1.TaggedErrorClass()("CliSelfUpdateError", {
82899
+ step: Schema$1.String,
82900
+ detail: Schema$1.optional(Schema$1.String)
82901
+ }) {
82902
+ get message() {
82903
+ return this.detail === void 0 ? `Self-update failed while ${this.step}.` : `Self-update failed while ${this.step}: ${this.detail}`;
82904
+ }
82905
+ };
82906
+ /**
82907
+ * Where `npm install --global` put the executable.
82908
+ *
82909
+ * Asked of npm rather than assumed, because the answer differs between a
82910
+ * Homebrew node, an nvm one and a system one — and the whole point of this step
82911
+ * is to run the binary that was just written, not the one on this process's
82912
+ * PATH, which a shell may have cached.
82913
+ */
82914
+ const resolveGlobalBinary = Effect.fn("cli.selfUpdate.resolveGlobalBinary")(function* (binaryName) {
82915
+ const result = yield* (yield* ProcessRunner).run({
82916
+ command: "npm",
82917
+ args: ["prefix", "--global"]
82918
+ }).pipe(Effect.mapError((cause) => new CliSelfUpdateError({
82919
+ step: "locating the npm global prefix",
82920
+ detail: String(cause)
82921
+ })));
82922
+ if (result.code !== 0) return yield* new CliSelfUpdateError({
82923
+ step: "locating the npm global prefix",
82924
+ detail: `npm exited with code ${String(result.code)}`
82925
+ });
82926
+ const prefix = result.stdout.trim();
82927
+ if (prefix.length === 0) return yield* new CliSelfUpdateError({
82928
+ step: "locating the npm global prefix",
82929
+ detail: "npm printed no prefix"
82930
+ });
82931
+ return `${prefix}/bin/${binaryName}`;
82932
+ });
82933
+ const installGlobalCli = Effect.fn("cli.selfUpdate.installGlobalCli")(function* (input) {
82934
+ const runner = yield* ProcessRunner;
82935
+ const step = `installing ${PACKAGE_NAME}@${input.version}`;
82936
+ const result = yield* runner.run({
82937
+ command: "npm",
82938
+ args: [
82939
+ "install",
82940
+ "--global",
82941
+ "--no-fund",
82942
+ "--no-audit",
82943
+ `${PACKAGE_NAME}@${input.version}`
82944
+ ],
82945
+ timeout: CLI_INSTALL_TIMEOUT_MS
82946
+ }).pipe(Effect.mapError((cause) => new CliSelfUpdateError({
82947
+ step,
82948
+ detail: String(cause)
82949
+ })));
82950
+ if (result.code !== 0) return yield* new CliSelfUpdateError({
82951
+ step,
82952
+ detail: result.stderr.trim().length > 0 ? result.stderr.trim() : `exit code ${String(result.code)}`
82953
+ });
82954
+ });
82955
+ /**
82956
+ * Ask the freshly installed binary what version it is.
82957
+ *
82958
+ * A completed npm install can still be unusable — a native dependency that
82959
+ * failed to build, a Node too old — and it is better to find that out here,
82960
+ * while the service is still running the version that works, than after
82961
+ * pointing the unit at it.
82962
+ */
82963
+ const readInstalledVersion = Effect.fn("cli.selfUpdate.readInstalledVersion")(function* (binaryPath) {
82964
+ const runner = yield* ProcessRunner;
82965
+ const step = "verifying the installed CLI";
82966
+ const result = yield* runner.run({
82967
+ command: binaryPath,
82968
+ args: ["--version"]
82969
+ }).pipe(Effect.mapError((cause) => new CliSelfUpdateError({
82970
+ step,
82971
+ detail: String(cause)
82972
+ })));
82973
+ if (result.code !== 0) return yield* new CliSelfUpdateError({
82974
+ step,
82975
+ detail: `the installed CLI exited with code ${String(result.code)}`
82976
+ });
82977
+ const match = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?)/u.exec(result.stdout);
82978
+ if (match?.[1] === void 0) return yield* new CliSelfUpdateError({
82979
+ step,
82980
+ detail: "the installed CLI did not report a version"
82981
+ });
82982
+ return match[1];
82983
+ });
82984
+ /**
82985
+ * Hand the rest of the work to the new binary.
82986
+ *
82987
+ * `service update` from *this* process would pin this process's version. The
82988
+ * new binary reconciles to itself, which is the only way the unit ends up
82989
+ * pointing at what was just installed.
82990
+ */
82991
+ const reconcileWithNewBinary = Effect.fn("cli.selfUpdate.reconcileWithNewBinary")(function* (input) {
82992
+ const runner = yield* ProcessRunner;
82993
+ const step = "updating the service to the new version";
82994
+ const result = yield* runner.run({
82995
+ command: input.binaryPath,
82996
+ args: [
82997
+ "service",
82998
+ "update",
82999
+ ...input.baseDir === void 0 ? [] : ["--base-dir", input.baseDir]
83000
+ ],
83001
+ timeout: CLI_INSTALL_TIMEOUT_MS
83002
+ }).pipe(Effect.mapError((cause) => new CliSelfUpdateError({
83003
+ step,
83004
+ detail: String(cause)
83005
+ })));
83006
+ if (result.code !== 0) return yield* new CliSelfUpdateError({
83007
+ step,
83008
+ detail: result.stderr.trim().length > 0 ? result.stderr.trim() : `exit code ${String(result.code)}`
83009
+ });
83010
+ });
83011
+ //#endregion
82685
83012
  //#region src/cli/service.ts
82686
83013
  const isBootServiceCommandError = Schema$1.is(BootServiceCommandError);
82687
83014
  const bootServiceLayer = (config) => layer$46({
@@ -82730,13 +83057,51 @@ function formatServiceStatus(status, cliVersion) {
82730
83057
  ...status.current ? [] : [" Next: Run `p4c service update`."]
82731
83058
  ].join("\n");
82732
83059
  }
82733
- const runServiceCommand = Effect.fn("cli.service.run")(function* (flags, run) {
83060
+ const runServiceCommand = Effect.fn("cli.service.run")(function* (flags, run, prepare) {
82734
83061
  const config = yield* resolveCliAuthConfig(flags, yield* GlobalFlag.LogLevel);
82735
83062
  const baseDirNotice = yield* describeNonDefaultBaseDir(config.baseDir);
82736
83063
  if (baseDirNotice !== null) yield* Console.error(baseDirNotice);
83064
+ if (prepare !== void 0) yield* prepare(config);
82737
83065
  return yield* run.pipe(Effect.tapError((error) => isBootServiceCommandError(error) ? BootService.pipe(Effect.flatMap((service) => Console.error(`Details: ${service.logPath}`))) : Effect.void), Effect.provide(Layer.mergeAll(bootServiceLayer(config), layer$52)));
82738
83066
  });
82739
- const serviceInstallCommand = Command.make("install", projectLocationFlags).pipe(Command.withDescription("Install P4Code as a background service for this user."), Command.withHandler((flags) => runServiceCommand(flags, Effect.gen(function* () {
83067
+ /**
83068
+ * Options the installed unit carries, rather than ones this command consumes.
83069
+ *
83070
+ * Optional on purpose. Absent means "leave whatever is stored alone", so
83071
+ * re-running install to pick up a new CLI build does not quietly switch
83072
+ * Tailscale Serve off — which a plain boolean flag, false when unspecified,
83073
+ * would do every time.
83074
+ */
83075
+ const tailscaleServeFlag = Flag.boolean("tailscale-serve").pipe(Flag.withDescription("Expose this server over HTTPS on your tailnet at its MagicDNS name. Use --no-tailscale-serve to turn it off."), Flag.optional);
83076
+ const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe(Flag.withDescription(`HTTPS port for Tailscale Serve (default 443).`), Flag.optional);
83077
+ /**
83078
+ * Fold the flags into what is already stored, and persist.
83079
+ *
83080
+ * Written before the unit is rendered, because the renderer reads this file —
83081
+ * so `install` and `update` both produce a unit that matches, and `status`
83082
+ * compares against the same thing.
83083
+ */
83084
+ const applyServiceOptionFlags = Effect.fn("cli.service.applyOptionFlags")(function* (input) {
83085
+ if (Option.isNone(input.tailscaleServe) && Option.isNone(input.tailscaleServePort)) return;
83086
+ const stored = yield* readServiceOptions({ baseDir: input.baseDir });
83087
+ const enabled = Option.getOrElse(input.tailscaleServe, () => stored.tailscaleServe?.enabled ?? false);
83088
+ const port = Option.getOrElse(input.tailscaleServePort, () => stored.tailscaleServe?.port ?? 443);
83089
+ yield* writeServiceOptions({
83090
+ baseDir: input.baseDir,
83091
+ options: {
83092
+ ...stored,
83093
+ tailscaleServe: {
83094
+ enabled,
83095
+ port
83096
+ }
83097
+ }
83098
+ });
83099
+ });
83100
+ const serviceInstallCommand = Command.make("install", {
83101
+ ...projectLocationFlags,
83102
+ tailscaleServe: tailscaleServeFlag,
83103
+ tailscaleServePort: tailscaleServePortFlag
83104
+ }).pipe(Command.withDescription("Install P4Code as a background service for this user."), Command.withHandler((flags) => runServiceCommand(flags, Effect.gen(function* () {
82740
83105
  const result = yield* reconcileService();
82741
83106
  if (!result.changed) {
82742
83107
  yield* Console.log(`P4Code service is already installed with @p4code/cli@${version}.`);
@@ -82748,6 +83113,10 @@ const serviceInstallCommand = Command.make("install", projectLocationFlags).pipe
82748
83113
  cliVersion: version,
82749
83114
  platform: yield* HostProcessPlatform
82750
83115
  }));
83116
+ }), (config) => applyServiceOptionFlags({
83117
+ baseDir: config.baseDir,
83118
+ tailscaleServe: flags.tailscaleServe,
83119
+ tailscaleServePort: flags.tailscaleServePort
82751
83120
  }))));
82752
83121
  const serviceUpdateCommand = Command.make("update", projectLocationFlags).pipe(Command.withDescription("Update or repair the background service so it runs this CLI build, then restart it."), Command.withHandler((flags) => runServiceCommand(flags, Effect.gen(function* () {
82753
83122
  const result = yield* reconcileService();
@@ -82767,6 +83136,57 @@ const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).
82767
83136
  const removed = yield* (yield* BootService).uninstall;
82768
83137
  yield* Console.log(removed ? "Removed the P4Code service." : "P4Code service is not installed.");
82769
83138
  }))));
83139
+ /**
83140
+ * Restart without rewriting the unit.
83141
+ *
83142
+ * `update` restarts too, but only after deciding the unit is out of date — and
83143
+ * a global `npm i -g` upgrades in place, so the rendered unit is byte-identical
83144
+ * and `update` correctly reports there is nothing to do while the running
83145
+ * process still holds the old code. The service already knew how to do this;
83146
+ * only the terminal had no way to ask.
83147
+ */
83148
+ const serviceRestartCommand = Command.make("restart", projectLocationFlags).pipe(Command.withDescription("Restart the background service without changing its configuration."), Command.withHandler((flags) => runServiceCommand(flags, Effect.gen(function* () {
83149
+ const service = yield* BootService;
83150
+ if (!(yield* service.status).installed) {
83151
+ yield* Console.error("P4Code service is not installed. Install it with: p4c service install");
83152
+ return;
83153
+ }
83154
+ yield* service.restart;
83155
+ yield* Console.log("Restarted the P4Code service.");
83156
+ }))));
83157
+ /**
83158
+ * Install a newer CLI, then point the service at it.
83159
+ *
83160
+ * Deliberately not `ServerSelfUpdate`, which the server uses to replace itself
83161
+ * in place: that path refuses to run under launchd, because KeepAlive would
83162
+ * restart the process being replaced and leave two servers on one port. Under a
83163
+ * supervisor the honest sequence is install, then reconcile and let the
83164
+ * supervisor restart — which is what this does, from the new binary.
83165
+ */
83166
+ const serviceSelfUpdateCommand = Command.make("self-update", {
83167
+ ...projectLocationFlags,
83168
+ version: Flag.string("version").pipe(Flag.withDescription("Version or dist-tag to install (default latest)."), Flag.optional)
83169
+ }).pipe(Command.withDescription("Install the newest @p4code/cli and update the service to run it."), Command.withHandler((flags) => runServiceCommand(flags, Effect.gen(function* () {
83170
+ const requested = Option.getOrElse(flags.version, () => "latest");
83171
+ if (!isInstallableVersion(requested)) {
83172
+ yield* Console.error(`'${requested}' is not a version or dist-tag. Use an exact version, or latest.`);
83173
+ return;
83174
+ }
83175
+ yield* Console.log(`Installing ${PACKAGE_NAME}@${requested}…`);
83176
+ yield* installGlobalCli({ version: requested });
83177
+ const binaryPath = yield* resolveGlobalBinary("p4c");
83178
+ const installed = yield* readInstalledVersion(binaryPath);
83179
+ if (installed === version) {
83180
+ yield* Console.log(`Already running @p4code/cli@${installed}; nothing to update.`);
83181
+ return;
83182
+ }
83183
+ yield* Console.log(`Installed @p4code/cli@${installed}; updating the service…`);
83184
+ yield* reconcileWithNewBinary({
83185
+ binaryPath,
83186
+ baseDir: void 0
83187
+ });
83188
+ yield* Console.log(`Service now runs @p4code/cli@${installed}.`);
83189
+ }))));
82770
83190
  const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe(Command.withDescription("Show whether the P4Code background service is installed."), Command.withHandler((flags) => runServiceCommand(flags, Effect.gen(function* () {
82771
83191
  const service = yield* BootService;
82772
83192
  yield* Console.log(formatServiceStatus(yield* service.status, version));
@@ -82790,11 +83210,13 @@ const serviceCommand = Command.make("service").pipe(Command.withDescription("Man
82790
83210
  serviceInstallCommand,
82791
83211
  serviceUninstallCommand,
82792
83212
  serviceUpdateCommand,
83213
+ serviceRestartCommand,
83214
+ serviceSelfUpdateCommand,
82793
83215
  serviceStatusCommand
82794
83216
  ]));
82795
83217
  //#endregion
82796
83218
  //#region src/bin.ts
82797
- const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, layer$67, FetchHttpClient.layer);
83219
+ const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, layer$68, FetchHttpClient.layer);
82798
83220
  /**
82799
83221
  * Prints an update reminder before dispatching, at most once a day.
82800
83222
  *