@hellcoder/companion 0.124.0 → 0.125.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/bin/cli.ts +9 -1
  2. package/dist/assets/{AgentsPage-Byq8wvdn.js → AgentsPage-0Fi6tbQN.js} +1 -1
  3. package/dist/assets/{CronManager-Bq2lOMbZ.js → CronManager-DNN709nr.js} +1 -1
  4. package/dist/assets/{DashboardPage-V77HIt8S.js → DashboardPage-6cNRrqGQ.js} +1 -1
  5. package/dist/assets/{IntegrationsPage-C3aW9cDI.js → IntegrationsPage-DrE-8KPE.js} +1 -1
  6. package/dist/assets/{LinearOAuthSettingsPage-_eAbciJN.js → LinearOAuthSettingsPage-Czi_NsX1.js} +1 -1
  7. package/dist/assets/{LinearSettingsPage-DDHFHt7j.js → LinearSettingsPage-C1RHlPwX.js} +1 -1
  8. package/dist/assets/{MagicUIDashboard-Cl18AKjV.js → MagicUIDashboard-BOZc4rKn.js} +1 -1
  9. package/dist/assets/{MagicUIView-C0aiMTet.js → MagicUIView-BVM8CqGg.js} +1 -1
  10. package/dist/assets/{Playground-CvoHRMBd.js → Playground-DWAal5bL.js} +1 -1
  11. package/dist/assets/{PromptsPage-CzTs1_Se.js → PromptsPage-D2YI_-95.js} +1 -1
  12. package/dist/assets/{RunsPage-B7xru-Xy.js → RunsPage-dn5rSkPu.js} +1 -1
  13. package/dist/assets/{SandboxManager-C3tFkiWz.js → SandboxManager-vWbQKeLT.js} +1 -1
  14. package/dist/assets/{SettingsPage-BZ2fBrHD.js → SettingsPage-CrV1Tb8F.js} +1 -1
  15. package/dist/assets/{TailscalePage-DFRiFKcb.js → TailscalePage-ba0g8QT1.js} +1 -1
  16. package/dist/assets/{index-C7nK12tB.js → index-e4RnFbra.js} +4 -4
  17. package/dist/assets/{sw-register-Dc72vh_Y.js → sw-register-BBun0drK.js} +1 -1
  18. package/dist/index.html +1 -1
  19. package/dist/sw.js +1 -1
  20. package/package.json +1 -1
  21. package/server/index.ts +2 -2
  22. package/server/routes/system-routes.test.ts +57 -0
  23. package/server/routes/system-routes.ts +20 -24
  24. package/server/service.test.ts +232 -0
  25. package/server/service.ts +286 -23
  26. package/server/settings-manager.ts +1 -0
@@ -1 +1 @@
1
- import{_ as u}from"./index-C7nK12tB.js";function w(r={}){const{immediate:i=!1,onNeedReload:d,onNeedRefresh:_,onOfflineReady:s,onRegistered:n,onRegisteredSW:o,onRegisterError:t}=r;let a,c;const l=async(e=!0)=>{await c};async function f(){if("serviceWorker"in navigator){if(a=await u(async()=>{const{Workbox:e}=await import("./workbox-window.prod.es5-BBnX5xw4.js");return{Workbox:e}},[]).then(({Workbox:e})=>new e("/sw.js",{scope:"/",type:"classic"})).catch(e=>{t==null||t(e)}),!a)return;a.addEventListener("activated",e=>{(e.isUpdate||e.isExternal)&&(d?d():window.location.reload())}),a.addEventListener("installed",e=>{e.isUpdate||s==null||s()}),a.register({immediate:i}).then(e=>{o?o("/sw.js",e):n==null||n(e)}).catch(e=>{t==null||t(e)})}}return c=f(),l}w({onRegisteredSW(r,i){i&&setInterval(()=>{i.update()},3600*1e3)},onOfflineReady(){console.log("[SW] Offline-ready: all assets precached")}});
1
+ import{_ as u}from"./index-e4RnFbra.js";function w(r={}){const{immediate:i=!1,onNeedReload:d,onNeedRefresh:_,onOfflineReady:s,onRegistered:n,onRegisteredSW:o,onRegisterError:t}=r;let a,c;const l=async(e=!0)=>{await c};async function f(){if("serviceWorker"in navigator){if(a=await u(async()=>{const{Workbox:e}=await import("./workbox-window.prod.es5-BBnX5xw4.js");return{Workbox:e}},[]).then(({Workbox:e})=>new e("/sw.js",{scope:"/",type:"classic"})).catch(e=>{t==null||t(e)}),!a)return;a.addEventListener("activated",e=>{(e.isUpdate||e.isExternal)&&(d?d():window.location.reload())}),a.addEventListener("installed",e=>{e.isUpdate||s==null||s()}),a.register({immediate:i}).then(e=>{o?o("/sw.js",e):n==null||n(e)}).catch(e=>{t==null||t(e)})}}return c=f(),l}w({onRegisteredSW(r,i){i&&setInterval(()=>{i.update()},3600*1e3)},onOfflineReady(){console.log("[SW] Offline-ready: all assets precached")}});
package/dist/index.html CHANGED
@@ -11,7 +11,7 @@
11
11
  <meta name="apple-mobile-web-app-title" content="Companion ME" />
12
12
  <meta name="theme-color" content="#d97757" />
13
13
  <title>The Companion — Moritz Edition</title>
14
- <script type="module" crossorigin src="/assets/index-C7nK12tB.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-e4RnFbra.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-OAVDFR0O.css">
16
16
  </head>
17
17
  <body class="bg-cc-bg text-cc-fg">
package/dist/sw.js CHANGED
@@ -1,2 +1,2 @@
1
1
  try{self["workbox:core:7.4.0"]&&_()}catch{}const x=(a,...e)=>{let t=a;return e.length>0&&(t+=` :: ${JSON.stringify(e)}`),t},N=x;class l extends Error{constructor(e,t){const s=N(e,t);super(s),this.name=e,this.details=t}}const E=new Set,f={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:typeof registration<"u"?registration.scope:""},b=a=>[f.prefix,a,f.suffix].filter(e=>e&&e.length>0).join("-"),O=a=>{for(const e of Object.keys(f))a(e)},C={updateDetails:a=>{O(e=>{typeof a[e]=="string"&&(f[e]=a[e])})},getGoogleAnalyticsName:a=>a||b(f.googleAnalytics),getPrecacheName:a=>a||b(f.precache),getPrefix:()=>f.prefix,getRuntimeName:a=>a||b(f.runtime),getSuffix:()=>f.suffix};function P(a,e){const t=new URL(a);for(const s of e)t.searchParams.delete(s);return t.href}async function I(a,e,t,s){const n=P(e.url,t);if(e.url===n)return a.match(e,s);const r=Object.assign(Object.assign({},s),{ignoreSearch:!0}),c=await a.keys(e,r);for(const i of c){const o=P(i.url,t);if(n===o)return a.match(i,s)}}let y;function M(){if(y===void 0){const a=new Response("");if("body"in a)try{new Response(a.body),y=!0}catch{y=!1}y=!1}return y}class D{constructor(){this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}}async function S(){for(const a of E)await a()}const W=a=>new URL(String(a),location.href).href.replace(new RegExp(`^${location.origin}`),"");function A(a){return new Promise(e=>setTimeout(e,a))}function K(a,e){const t=e();return a.waitUntil(t),t}async function q(a,e){let t=null;if(a.url&&(t=new URL(a.url).origin),t!==self.location.origin)throw new l("cross-origin-copy-response",{origin:t});const s=a.clone(),r={headers:new Headers(s.headers),status:s.status,statusText:s.statusText},c=M()?s.body:await s.blob();return new Response(c,r)}function j(){self.addEventListener("activate",()=>self.clients.claim())}try{self["workbox:precaching:7.4.0"]&&_()}catch{}const H="__WB_REVISION__";function F(a){if(!a)throw new l("add-to-cache-list-unexpected-type",{entry:a});if(typeof a=="string"){const r=new URL(a,location.href);return{cacheKey:r.href,url:r.href}}const{revision:e,url:t}=a;if(!t)throw new l("add-to-cache-list-unexpected-type",{entry:a});if(!e){const r=new URL(t,location.href);return{cacheKey:r.href,url:r.href}}const s=new URL(t,location.href),n=new URL(t,location.href);return s.searchParams.set(H,e),{cacheKey:s.href,url:n.href}}class B{constructor(){this.updatedURLs=[],this.notUpdatedURLs=[],this.handlerWillStart=async({request:e,state:t})=>{t&&(t.originalRequest=e)},this.cachedResponseWillBeUsed=async({event:e,state:t,cachedResponse:s})=>{if(e.type==="install"&&t&&t.originalRequest&&t.originalRequest instanceof Request){const n=t.originalRequest.url;s?this.notUpdatedURLs.push(n):this.updatedURLs.push(n)}return s}}}class ${constructor({precacheController:e}){this.cacheKeyWillBeUsed=async({request:t,params:s})=>{const n=(s==null?void 0:s.cacheKey)||this._precacheController.getCacheKeyForURL(t.url);return n?new Request(n,{headers:t.headers}):t},this._precacheController=e}}try{self["workbox:strategies:7.4.0"]&&_()}catch{}function m(a){return typeof a=="string"?new Request(a):a}class G{constructor(e,t){this._cacheKeys={},Object.assign(this,t),this.event=t.event,this._strategy=e,this._handlerDeferred=new D,this._extendLifetimePromises=[],this._plugins=[...e.plugins],this._pluginStateMap=new Map;for(const s of this._plugins)this._pluginStateMap.set(s,{});this.event.waitUntil(this._handlerDeferred.promise)}async fetch(e){const{event:t}=this;let s=m(e);if(s.mode==="navigate"&&t instanceof FetchEvent&&t.preloadResponse){const c=await t.preloadResponse;if(c)return c}const n=this.hasCallback("fetchDidFail")?s.clone():null;try{for(const c of this.iterateCallbacks("requestWillFetch"))s=await c({request:s.clone(),event:t})}catch(c){if(c instanceof Error)throw new l("plugin-error-request-will-fetch",{thrownErrorMessage:c.message})}const r=s.clone();try{let c;c=await fetch(s,s.mode==="navigate"?void 0:this._strategy.fetchOptions);for(const i of this.iterateCallbacks("fetchDidSucceed"))c=await i({event:t,request:r,response:c});return c}catch(c){throw n&&await this.runCallbacks("fetchDidFail",{error:c,event:t,originalRequest:n.clone(),request:r.clone()}),c}}async fetchAndCachePut(e){const t=await this.fetch(e),s=t.clone();return this.waitUntil(this.cachePut(e,s)),t}async cacheMatch(e){const t=m(e);let s;const{cacheName:n,matchOptions:r}=this._strategy,c=await this.getCacheKey(t,"read"),i=Object.assign(Object.assign({},r),{cacheName:n});s=await caches.match(c,i);for(const o of this.iterateCallbacks("cachedResponseWillBeUsed"))s=await o({cacheName:n,matchOptions:r,cachedResponse:s,request:c,event:this.event})||void 0;return s}async cachePut(e,t){const s=m(e);await A(0);const n=await this.getCacheKey(s,"write");if(!t)throw new l("cache-put-with-no-response",{url:W(n.url)});const r=await this._ensureResponseSafeToCache(t);if(!r)return!1;const{cacheName:c,matchOptions:i}=this._strategy,o=await self.caches.open(c),h=this.hasCallback("cacheDidUpdate"),p=h?await I(o,n.clone(),["__WB_REVISION__"],i):null;try{await o.put(n,h?r.clone():r)}catch(u){if(u instanceof Error)throw u.name==="QuotaExceededError"&&await S(),u}for(const u of this.iterateCallbacks("cacheDidUpdate"))await u({cacheName:c,oldResponse:p,newResponse:r.clone(),request:n,event:this.event});return!0}async getCacheKey(e,t){const s=`${e.url} | ${t}`;if(!this._cacheKeys[s]){let n=e;for(const r of this.iterateCallbacks("cacheKeyWillBeUsed"))n=m(await r({mode:t,request:n,event:this.event,params:this.params}));this._cacheKeys[s]=n}return this._cacheKeys[s]}hasCallback(e){for(const t of this._strategy.plugins)if(e in t)return!0;return!1}async runCallbacks(e,t){for(const s of this.iterateCallbacks(e))await s(t)}*iterateCallbacks(e){for(const t of this._strategy.plugins)if(typeof t[e]=="function"){const s=this._pluginStateMap.get(t);yield r=>{const c=Object.assign(Object.assign({},r),{state:s});return t[e](c)}}}waitUntil(e){return this._extendLifetimePromises.push(e),e}async doneWaiting(){for(;this._extendLifetimePromises.length;){const e=this._extendLifetimePromises.splice(0),s=(await Promise.allSettled(e)).find(n=>n.status==="rejected");if(s)throw s.reason}}destroy(){this._handlerDeferred.resolve(null)}async _ensureResponseSafeToCache(e){let t=e,s=!1;for(const n of this.iterateCallbacks("cacheWillUpdate"))if(t=await n({request:this.request,response:t,event:this.event})||void 0,s=!0,!t)break;return s||t&&t.status!==200&&(t=void 0),t}}class V{constructor(e={}){this.cacheName=C.getRuntimeName(e.cacheName),this.plugins=e.plugins||[],this.fetchOptions=e.fetchOptions,this.matchOptions=e.matchOptions}handle(e){const[t]=this.handleAll(e);return t}handleAll(e){e instanceof FetchEvent&&(e={event:e,request:e.request});const t=e.event,s=typeof e.request=="string"?new Request(e.request):e.request,n="params"in e?e.params:void 0,r=new G(this,{event:t,request:s,params:n}),c=this._getResponse(r,s,t),i=this._awaitComplete(c,r,s,t);return[c,i]}async _getResponse(e,t,s){await e.runCallbacks("handlerWillStart",{event:s,request:t});let n;try{if(n=await this._handle(t,e),!n||n.type==="error")throw new l("no-response",{url:t.url})}catch(r){if(r instanceof Error){for(const c of e.iterateCallbacks("handlerDidError"))if(n=await c({error:r,event:s,request:t}),n)break}if(!n)throw r}for(const r of e.iterateCallbacks("handlerWillRespond"))n=await r({event:s,request:t,response:n});return n}async _awaitComplete(e,t,s,n){let r,c;try{r=await e}catch{}try{await t.runCallbacks("handlerDidRespond",{event:n,request:s,response:r}),await t.doneWaiting()}catch(i){i instanceof Error&&(c=i)}if(await t.runCallbacks("handlerDidComplete",{event:n,request:s,response:r,error:c}),t.destroy(),c)throw c}}class d extends V{constructor(e={}){e.cacheName=C.getPrecacheName(e.cacheName),super(e),this._fallbackToNetwork=e.fallbackToNetwork!==!1,this.plugins.push(d.copyRedirectedCacheableResponsesPlugin)}async _handle(e,t){const s=await t.cacheMatch(e);return s||(t.event&&t.event.type==="install"?await this._handleInstall(e,t):await this._handleFetch(e,t))}async _handleFetch(e,t){let s;const n=t.params||{};if(this._fallbackToNetwork){const r=n.integrity,c=e.integrity,i=!c||c===r;s=await t.fetch(new Request(e,{integrity:e.mode!=="no-cors"?c||r:void 0})),r&&i&&e.mode!=="no-cors"&&(this._useDefaultCacheabilityPluginIfNeeded(),await t.cachePut(e,s.clone()))}else throw new l("missing-precache-entry",{cacheName:this.cacheName,url:e.url});return s}async _handleInstall(e,t){this._useDefaultCacheabilityPluginIfNeeded();const s=await t.fetch(e);if(!await t.cachePut(e,s.clone()))throw new l("bad-precaching-response",{url:e.url,status:s.status});return s}_useDefaultCacheabilityPluginIfNeeded(){let e=null,t=0;for(const[s,n]of this.plugins.entries())n!==d.copyRedirectedCacheableResponsesPlugin&&(n===d.defaultPrecacheCacheabilityPlugin&&(e=s),n.cacheWillUpdate&&t++);t===0?this.plugins.push(d.defaultPrecacheCacheabilityPlugin):t>1&&e!==null&&this.plugins.splice(e,1)}}d.defaultPrecacheCacheabilityPlugin={async cacheWillUpdate({response:a}){return!a||a.status>=400?null:a}};d.copyRedirectedCacheableResponsesPlugin={async cacheWillUpdate({response:a}){return a.redirected?await q(a):a}};class Q{constructor({cacheName:e,plugins:t=[],fallbackToNetwork:s=!0}={}){this._urlsToCacheKeys=new Map,this._urlsToCacheModes=new Map,this._cacheKeysToIntegrities=new Map,this._strategy=new d({cacheName:C.getPrecacheName(e),plugins:[...t,new $({precacheController:this})],fallbackToNetwork:s}),this.install=this.install.bind(this),this.activate=this.activate.bind(this)}get strategy(){return this._strategy}precache(e){this.addToCacheList(e),this._installAndActiveListenersAdded||(self.addEventListener("install",this.install),self.addEventListener("activate",this.activate),this._installAndActiveListenersAdded=!0)}addToCacheList(e){const t=[];for(const s of e){typeof s=="string"?t.push(s):s&&s.revision===void 0&&t.push(s.url);const{cacheKey:n,url:r}=F(s),c=typeof s!="string"&&s.revision?"reload":"default";if(this._urlsToCacheKeys.has(r)&&this._urlsToCacheKeys.get(r)!==n)throw new l("add-to-cache-list-conflicting-entries",{firstEntry:this._urlsToCacheKeys.get(r),secondEntry:n});if(typeof s!="string"&&s.integrity){if(this._cacheKeysToIntegrities.has(n)&&this._cacheKeysToIntegrities.get(n)!==s.integrity)throw new l("add-to-cache-list-conflicting-integrities",{url:r});this._cacheKeysToIntegrities.set(n,s.integrity)}if(this._urlsToCacheKeys.set(r,n),this._urlsToCacheModes.set(r,c),t.length>0){const i=`Workbox is precaching URLs without revision info: ${t.join(", ")}
2
- This is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(i)}}}install(e){return K(e,async()=>{const t=new B;this.strategy.plugins.push(t);for(const[r,c]of this._urlsToCacheKeys){const i=this._cacheKeysToIntegrities.get(c),o=this._urlsToCacheModes.get(r),h=new Request(r,{integrity:i,cache:o,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:c},request:h,event:e}))}const{updatedURLs:s,notUpdatedURLs:n}=t;return{updatedURLs:s,notUpdatedURLs:n}})}activate(e){return K(e,async()=>{const t=await self.caches.open(this.strategy.cacheName),s=await t.keys(),n=new Set(this._urlsToCacheKeys.values()),r=[];for(const c of s)n.has(c.url)||(await t.delete(c),r.push(c.url));return{deletedURLs:r}})}getURLsToCacheKeys(){return this._urlsToCacheKeys}getCachedURLs(){return[...this._urlsToCacheKeys.keys()]}getCacheKeyForURL(e){const t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForCacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){const t=e instanceof Request?e.url:e,s=this.getCacheKeyForURL(t);if(s)return(await self.caches.open(this.strategy.cacheName)).match(s)}createHandlerBoundToURL(e){const t=this.getCacheKeyForURL(e);if(!t)throw new l("non-precached-url",{url:e});return s=>(s.request=new Request(e),s.params=Object.assign({cacheKey:t},s.params),this.strategy.handle(s))}}let U;const L=()=>(U||(U=new Q),U);try{self["workbox:routing:7.4.0"]&&_()}catch{}const v="GET",R=a=>a&&typeof a=="object"?a:{handle:a};class g{constructor(e,t,s=v){this.handler=R(t),this.match=e,this.method=s}setCatchHandler(e){this.catchHandler=R(e)}}class z extends g{constructor(e,t,s){const n=({url:r})=>{const c=e.exec(r.href);if(c&&!(r.origin!==location.origin&&c.index!==0))return c.slice(1)};super(n,t,s)}}class J{constructor(){this._routes=new Map,this._defaultHandlerMap=new Map}get routes(){return this._routes}addFetchListener(){self.addEventListener("fetch",(e=>{const{request:t}=e,s=this.handleRequest({request:t,event:e});s&&e.respondWith(s)}))}addCacheListener(){self.addEventListener("message",(e=>{if(e.data&&e.data.type==="CACHE_URLS"){const{payload:t}=e.data,s=Promise.all(t.urlsToCache.map(n=>{typeof n=="string"&&(n=[n]);const r=new Request(...n);return this.handleRequest({request:r,event:e})}));e.waitUntil(s),e.ports&&e.ports[0]&&s.then(()=>e.ports[0].postMessage(!0))}}))}handleRequest({request:e,event:t}){const s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;const n=s.origin===location.origin,{params:r,route:c}=this.findMatchingRoute({event:t,request:e,sameOrigin:n,url:s});let i=c&&c.handler;const o=e.method;if(!i&&this._defaultHandlerMap.has(o)&&(i=this._defaultHandlerMap.get(o)),!i)return;let h;try{h=i.handle({url:s,request:e,event:t,params:r})}catch(u){h=Promise.reject(u)}const p=c&&c.catchHandler;return h instanceof Promise&&(this._catchHandler||p)&&(h=h.catch(async u=>{if(p)try{return await p.handle({url:s,request:e,event:t,params:r})}catch(k){k instanceof Error&&(u=k)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw u})),h}findMatchingRoute({url:e,sameOrigin:t,request:s,event:n}){const r=this._routes.get(s.method)||[];for(const c of r){let i;const o=c.match({url:e,sameOrigin:t,request:s,event:n});if(o)return i=o,(Array.isArray(i)&&i.length===0||o.constructor===Object&&Object.keys(o).length===0||typeof o=="boolean")&&(i=void 0),{route:c,params:i}}return{}}setDefaultHandler(e,t=v){this._defaultHandlerMap.set(t,R(e))}setCatchHandler(e){this._catchHandler=R(e)}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new l("unregister-route-but-not-found-with-method",{method:e.method});const t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new l("unregister-route-route-not-registered")}}let w;const X=()=>(w||(w=new J,w.addFetchListener(),w.addCacheListener()),w);function T(a,e,t){let s;if(typeof a=="string"){const r=new URL(a,location.href),c=({url:i})=>i.href===r.href;s=new g(c,e,t)}else if(a instanceof RegExp)s=new z(a,e,t);else if(typeof a=="function")s=new g(a,e,t);else if(a instanceof g)s=a;else throw new l("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});return X().registerRoute(s),s}function Y(a,e=[]){for(const t of[...a.searchParams.keys()])e.some(s=>s.test(t))&&a.searchParams.delete(t);return a}function*Z(a,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:t="index.html",cleanURLs:s=!0,urlManipulation:n}={}){const r=new URL(a,location.href);r.hash="",yield r.href;const c=Y(r,e);if(yield c.href,t&&c.pathname.endsWith("/")){const i=new URL(c.href);i.pathname+=t,yield i.href}if(s){const i=new URL(c.href);i.pathname+=".html",yield i.href}if(n){const i=n({url:r});for(const o of i)yield o.href}}class ee extends g{constructor(e,t){const s=({request:n})=>{const r=e.getURLsToCacheKeys();for(const c of Z(n.url,t)){const i=r.get(c);if(i){const o=e.getIntegrityForCacheKey(i);return{cacheKey:i,integrity:o}}}};super(s,e.strategy)}}function te(a){const e=L(),t=new ee(e,a);T(t)}const se="-precache-",ae=async(a,e=se)=>{const s=(await self.caches.keys()).filter(n=>n.includes(e)&&n.includes(self.registration.scope)&&n!==a);return await Promise.all(s.map(n=>self.caches.delete(n))),s};function ne(){self.addEventListener("activate",(a=>{const e=C.getPrecacheName();a.waitUntil(ae(e).then(t=>{}))}))}function re(a){return L().createHandlerBoundToURL(a)}function ce(a){L().precache(a)}function ie(a,e){ce(a),te(e)}class oe extends g{constructor(e,{allowlist:t=[/./],denylist:s=[]}={}){super(n=>this._match(n),e),this._allowlist=t,this._denylist=s}_match({url:e,request:t}){if(t&&t.mode!=="navigate")return!1;const s=e.pathname+e.search;for(const n of this._denylist)if(n.test(s))return!1;return!!this._allowlist.some(n=>n.test(s))}}self.skipWaiting();j();ie([{"revision":"fab346dacb0b9b9af12554e9a59f004d","url":"logo.svg"},{"revision":"623840baec64a3438663496bb1050d30","url":"logo-docker.svg"},{"revision":"c5c8611b432190ec28522f1a2dfcbb7f","url":"logo-codex.svg"},{"revision":"54558a709a65caff714f71ea6125911d","url":"index.html"},{"revision":"1421e9c6cad8218ef5689b85034d3a95","url":"icon-512.png"},{"revision":"b96da5a0b6636012517f54b0e69c304d","url":"icon-192.png"},{"revision":"c64ca8ae8596d4ffe13d0f591beb2274","url":"favicon.svg"},{"revision":"6481face04bd08e6f0bfc2a633840b57","url":"apple-touch-icon.png"},{"revision":"83304194a3c8be2b5b61242eeb1c1046","url":"fonts/MesloLGSNerdFontMono-Regular.woff2"},{"revision":"60fafe18cbcb717c51cdabf87f9490f0","url":"fonts/MesloLGSNerdFontMono-Bold.woff2"},{"revision":null,"url":"assets/workbox-window.prod.es5-BBnX5xw4.js"},{"revision":null,"url":"assets/sw-register-Dc72vh_Y.js"},{"revision":null,"url":"assets/index-OAVDFR0O.css"},{"revision":null,"url":"assets/index-C7nK12tB.js"},{"revision":null,"url":"assets/TailscalePage-DFRiFKcb.js"},{"revision":null,"url":"assets/SettingsPage-BZ2fBrHD.js"},{"revision":null,"url":"assets/SandboxManager-C3tFkiWz.js"},{"revision":null,"url":"assets/RunsPage-B7xru-Xy.js"},{"revision":null,"url":"assets/PromptsPage-CzTs1_Se.js"},{"revision":null,"url":"assets/Playground-CvoHRMBd.js"},{"revision":null,"url":"assets/MagicUIView-C0aiMTet.js"},{"revision":null,"url":"assets/MagicUIDashboard-Cl18AKjV.js"},{"revision":null,"url":"assets/LinearSettingsPage-DDHFHt7j.js"},{"revision":null,"url":"assets/LinearOAuthSettingsPage-_eAbciJN.js"},{"revision":null,"url":"assets/IntegrationsPage-C3aW9cDI.js"},{"revision":null,"url":"assets/DashboardPage-V77HIt8S.js"},{"revision":null,"url":"assets/CronManager-Bq2lOMbZ.js"},{"revision":null,"url":"assets/AgentsPage-Byq8wvdn.js"}]);ne();T(new oe(re("index.html"),{denylist:[/^\/api/,/^\/ws/]}));
2
+ This is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(i)}}}install(e){return K(e,async()=>{const t=new B;this.strategy.plugins.push(t);for(const[r,c]of this._urlsToCacheKeys){const i=this._cacheKeysToIntegrities.get(c),o=this._urlsToCacheModes.get(r),h=new Request(r,{integrity:i,cache:o,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:c},request:h,event:e}))}const{updatedURLs:s,notUpdatedURLs:n}=t;return{updatedURLs:s,notUpdatedURLs:n}})}activate(e){return K(e,async()=>{const t=await self.caches.open(this.strategy.cacheName),s=await t.keys(),n=new Set(this._urlsToCacheKeys.values()),r=[];for(const c of s)n.has(c.url)||(await t.delete(c),r.push(c.url));return{deletedURLs:r}})}getURLsToCacheKeys(){return this._urlsToCacheKeys}getCachedURLs(){return[...this._urlsToCacheKeys.keys()]}getCacheKeyForURL(e){const t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForCacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){const t=e instanceof Request?e.url:e,s=this.getCacheKeyForURL(t);if(s)return(await self.caches.open(this.strategy.cacheName)).match(s)}createHandlerBoundToURL(e){const t=this.getCacheKeyForURL(e);if(!t)throw new l("non-precached-url",{url:e});return s=>(s.request=new Request(e),s.params=Object.assign({cacheKey:t},s.params),this.strategy.handle(s))}}let U;const L=()=>(U||(U=new Q),U);try{self["workbox:routing:7.4.0"]&&_()}catch{}const v="GET",R=a=>a&&typeof a=="object"?a:{handle:a};class g{constructor(e,t,s=v){this.handler=R(t),this.match=e,this.method=s}setCatchHandler(e){this.catchHandler=R(e)}}class z extends g{constructor(e,t,s){const n=({url:r})=>{const c=e.exec(r.href);if(c&&!(r.origin!==location.origin&&c.index!==0))return c.slice(1)};super(n,t,s)}}class J{constructor(){this._routes=new Map,this._defaultHandlerMap=new Map}get routes(){return this._routes}addFetchListener(){self.addEventListener("fetch",(e=>{const{request:t}=e,s=this.handleRequest({request:t,event:e});s&&e.respondWith(s)}))}addCacheListener(){self.addEventListener("message",(e=>{if(e.data&&e.data.type==="CACHE_URLS"){const{payload:t}=e.data,s=Promise.all(t.urlsToCache.map(n=>{typeof n=="string"&&(n=[n]);const r=new Request(...n);return this.handleRequest({request:r,event:e})}));e.waitUntil(s),e.ports&&e.ports[0]&&s.then(()=>e.ports[0].postMessage(!0))}}))}handleRequest({request:e,event:t}){const s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;const n=s.origin===location.origin,{params:r,route:c}=this.findMatchingRoute({event:t,request:e,sameOrigin:n,url:s});let i=c&&c.handler;const o=e.method;if(!i&&this._defaultHandlerMap.has(o)&&(i=this._defaultHandlerMap.get(o)),!i)return;let h;try{h=i.handle({url:s,request:e,event:t,params:r})}catch(u){h=Promise.reject(u)}const p=c&&c.catchHandler;return h instanceof Promise&&(this._catchHandler||p)&&(h=h.catch(async u=>{if(p)try{return await p.handle({url:s,request:e,event:t,params:r})}catch(k){k instanceof Error&&(u=k)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw u})),h}findMatchingRoute({url:e,sameOrigin:t,request:s,event:n}){const r=this._routes.get(s.method)||[];for(const c of r){let i;const o=c.match({url:e,sameOrigin:t,request:s,event:n});if(o)return i=o,(Array.isArray(i)&&i.length===0||o.constructor===Object&&Object.keys(o).length===0||typeof o=="boolean")&&(i=void 0),{route:c,params:i}}return{}}setDefaultHandler(e,t=v){this._defaultHandlerMap.set(t,R(e))}setCatchHandler(e){this._catchHandler=R(e)}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new l("unregister-route-but-not-found-with-method",{method:e.method});const t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new l("unregister-route-route-not-registered")}}let w;const X=()=>(w||(w=new J,w.addFetchListener(),w.addCacheListener()),w);function T(a,e,t){let s;if(typeof a=="string"){const r=new URL(a,location.href),c=({url:i})=>i.href===r.href;s=new g(c,e,t)}else if(a instanceof RegExp)s=new z(a,e,t);else if(typeof a=="function")s=new g(a,e,t);else if(a instanceof g)s=a;else throw new l("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});return X().registerRoute(s),s}function Y(a,e=[]){for(const t of[...a.searchParams.keys()])e.some(s=>s.test(t))&&a.searchParams.delete(t);return a}function*Z(a,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:t="index.html",cleanURLs:s=!0,urlManipulation:n}={}){const r=new URL(a,location.href);r.hash="",yield r.href;const c=Y(r,e);if(yield c.href,t&&c.pathname.endsWith("/")){const i=new URL(c.href);i.pathname+=t,yield i.href}if(s){const i=new URL(c.href);i.pathname+=".html",yield i.href}if(n){const i=n({url:r});for(const o of i)yield o.href}}class ee extends g{constructor(e,t){const s=({request:n})=>{const r=e.getURLsToCacheKeys();for(const c of Z(n.url,t)){const i=r.get(c);if(i){const o=e.getIntegrityForCacheKey(i);return{cacheKey:i,integrity:o}}}};super(s,e.strategy)}}function te(a){const e=L(),t=new ee(e,a);T(t)}const se="-precache-",ae=async(a,e=se)=>{const s=(await self.caches.keys()).filter(n=>n.includes(e)&&n.includes(self.registration.scope)&&n!==a);return await Promise.all(s.map(n=>self.caches.delete(n))),s};function ne(){self.addEventListener("activate",(a=>{const e=C.getPrecacheName();a.waitUntil(ae(e).then(t=>{}))}))}function re(a){return L().createHandlerBoundToURL(a)}function ce(a){L().precache(a)}function ie(a,e){ce(a),te(e)}class oe extends g{constructor(e,{allowlist:t=[/./],denylist:s=[]}={}){super(n=>this._match(n),e),this._allowlist=t,this._denylist=s}_match({url:e,request:t}){if(t&&t.mode!=="navigate")return!1;const s=e.pathname+e.search;for(const n of this._denylist)if(n.test(s))return!1;return!!this._allowlist.some(n=>n.test(s))}}self.skipWaiting();j();ie([{"revision":"fab346dacb0b9b9af12554e9a59f004d","url":"logo.svg"},{"revision":"623840baec64a3438663496bb1050d30","url":"logo-docker.svg"},{"revision":"c5c8611b432190ec28522f1a2dfcbb7f","url":"logo-codex.svg"},{"revision":"38904c45f039fa501fcc7a9ad2ba6ac0","url":"index.html"},{"revision":"1421e9c6cad8218ef5689b85034d3a95","url":"icon-512.png"},{"revision":"b96da5a0b6636012517f54b0e69c304d","url":"icon-192.png"},{"revision":"c64ca8ae8596d4ffe13d0f591beb2274","url":"favicon.svg"},{"revision":"6481face04bd08e6f0bfc2a633840b57","url":"apple-touch-icon.png"},{"revision":"83304194a3c8be2b5b61242eeb1c1046","url":"fonts/MesloLGSNerdFontMono-Regular.woff2"},{"revision":"60fafe18cbcb717c51cdabf87f9490f0","url":"fonts/MesloLGSNerdFontMono-Bold.woff2"},{"revision":null,"url":"assets/workbox-window.prod.es5-BBnX5xw4.js"},{"revision":null,"url":"assets/sw-register-BBun0drK.js"},{"revision":null,"url":"assets/index-e4RnFbra.js"},{"revision":null,"url":"assets/index-OAVDFR0O.css"},{"revision":null,"url":"assets/TailscalePage-ba0g8QT1.js"},{"revision":null,"url":"assets/SettingsPage-CrV1Tb8F.js"},{"revision":null,"url":"assets/SandboxManager-vWbQKeLT.js"},{"revision":null,"url":"assets/RunsPage-dn5rSkPu.js"},{"revision":null,"url":"assets/PromptsPage-D2YI_-95.js"},{"revision":null,"url":"assets/Playground-DWAal5bL.js"},{"revision":null,"url":"assets/MagicUIView-BVM8CqGg.js"},{"revision":null,"url":"assets/MagicUIDashboard-BOZc4rKn.js"},{"revision":null,"url":"assets/LinearSettingsPage-C1RHlPwX.js"},{"revision":null,"url":"assets/LinearOAuthSettingsPage-Czi_NsX1.js"},{"revision":null,"url":"assets/IntegrationsPage-DrE-8KPE.js"},{"revision":null,"url":"assets/DashboardPage-6cNRrqGQ.js"},{"revision":null,"url":"assets/CronManager-DNN709nr.js"},{"revision":null,"url":"assets/AgentsPage-0Fi6tbQN.js"}]);ne();T(new oe(re("index.html"),{denylist:[/^\/api/,/^\/ws/]}));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellcoder/companion",
3
- "version": "0.124.0",
3
+ "version": "0.125.0",
4
4
  "type": "module",
5
5
  "description": "Web UI for launching and interacting with Claude Code agents — Moritz Edition (fork of the-companion)",
6
6
  "license": "MIT",
package/server/index.ts CHANGED
@@ -45,7 +45,7 @@ import { getSettings, updateSettings } from "./settings-manager.js";
45
45
  import { setCliIngressServer } from "./routes/system-routes.js";
46
46
  import { imagePullManager } from "./image-pull-manager.js";
47
47
  import { restoreIfNeeded as restoreTailscaleFunnel, cleanup as cleanupTailscaleFunnel } from "./tailscale-manager.js";
48
- import { isRunningAsService } from "./service.js";
48
+ import { getDetectedServiceKind, isRunningAsService } from "./service.js";
49
49
  import { getToken, verifyToken } from "./auth-manager.js";
50
50
  import { getCookie } from "hono/cookie";
51
51
  import type { SocketData } from "./ws-bridge.js";
@@ -421,7 +421,7 @@ if (getSettings().claudeBridgeMode === "patched") {
421
421
  }
422
422
  if (isRunningAsService()) {
423
423
  setServiceMode(true);
424
- console.log("[server] Running as background service (auto-update available)");
424
+ console.log(`[server] Running as background service (${getDetectedServiceKind()}, auto-update available)`);
425
425
  }
426
426
 
427
427
  // ── Runtime diagnostics ──────────────────────────────────────────────────────
@@ -28,6 +28,15 @@ vi.mock("../update-checker.js", () => ({
28
28
  // ─── Mock service ──────────────────────────────────────────────────────────
29
29
  vi.mock("../service.js", () => ({
30
30
  refreshServiceDefinition: vi.fn(),
31
+ getDetectedServiceKind: vi.fn(() => "systemd-user"),
32
+ getUpdateRestartPlan: vi.fn((kind: string | null) =>
33
+ kind === "systemd-system" || kind === "external"
34
+ ? { exitCode: 75 }
35
+ : {
36
+ command: ["systemctl", "--user", "restart", "the-companion.service"],
37
+ env: { XDG_RUNTIME_DIR: "/run/user/1000" },
38
+ exitCode: 0,
39
+ }),
31
40
  }));
32
41
 
33
42
  // ─── Mock claude-compat checker ────────────────────────────────────────────
@@ -88,6 +97,7 @@ import {
88
97
  setUpdateInProgress,
89
98
  } from "../update-checker.js";
90
99
  import { registerSystemRoutes } from "./system-routes.js";
100
+ import { getDetectedServiceKind } from "../service.js";
91
101
  import { checkCompat, getCompatState } from "../claude-compat-checker.js";
92
102
  import { pinToVersion, patchBinary, unpatch } from "../claude-patcher.js";
93
103
  import { getSettings, updateSettings } from "../settings-manager.js";
@@ -496,6 +506,53 @@ describe("POST /api/update", () => {
496
506
  // Advance past the 500ms exit timeout
497
507
  await vi.advanceTimersByTimeAsync(600);
498
508
 
509
+ // The user unit is restarted through systemctl --user, then we exit 0.
510
+ expect(mockSpawn).toHaveBeenCalledWith(
511
+ ["systemctl", "--user", "restart", "the-companion.service"],
512
+ expect.objectContaining({ env: expect.objectContaining({ XDG_RUNTIME_DIR: "/run/user/1000" }) }),
513
+ );
514
+ expect(exitSpy).toHaveBeenCalledWith(0);
515
+
516
+ vi.useRealTimers();
517
+ exitSpy.mockRestore();
518
+ // @ts-expect-error -- cleanup Bun global mock
519
+ delete globalThis.Bun;
520
+ });
521
+
522
+ // Under a system-wide unit (issue #139) the server can't restart itself via
523
+ // systemctl; it must exit non-zero so Restart=on-failure respawns it.
524
+ it("exits with the restart code and spawns nothing under a system unit", async () => {
525
+ vi.useFakeTimers();
526
+ vi.mocked(getDetectedServiceKind).mockReturnValueOnce("systemd-system");
527
+ vi.mocked(getUpdateState).mockReturnValue({
528
+ currentVersion: "1.0.0",
529
+ latestVersion: "2.0.0",
530
+ lastChecked: Date.now(),
531
+ isServiceMode: true,
532
+ checking: false,
533
+ updateInProgress: false,
534
+ channel: "stable",
535
+ });
536
+ vi.mocked(isUpdateAvailable).mockReturnValue(true);
537
+
538
+ const mockSpawn = vi.fn().mockReturnValueOnce({
539
+ exited: Promise.resolve(0),
540
+ stdout: new ReadableStream(),
541
+ stderr: new ReadableStream(),
542
+ });
543
+ // @ts-expect-error -- Bun global mock
544
+ globalThis.Bun = { spawn: mockSpawn };
545
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
546
+
547
+ const res = await app.request("/api/update", { method: "POST" });
548
+ expect(res.status).toBe(200);
549
+ await vi.advanceTimersByTimeAsync(150);
550
+ await vi.advanceTimersByTimeAsync(600);
551
+
552
+ // Only the install was spawned — no systemctl --user restart.
553
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
554
+ expect(exitSpy).toHaveBeenCalledWith(75);
555
+
499
556
  vi.useRealTimers();
500
557
  exitSpy.mockRestore();
501
558
  // @ts-expect-error -- cleanup Bun global mock
@@ -11,7 +11,11 @@ import {
11
11
  isUpdateAvailable,
12
12
  setUpdateInProgress,
13
13
  } from "../update-checker.js";
14
- import { refreshServiceDefinition } from "../service.js";
14
+ import {
15
+ getDetectedServiceKind,
16
+ getUpdateRestartPlan,
17
+ refreshServiceDefinition,
18
+ } from "../service.js";
15
19
  import { getSettings, updateSettings } from "../settings-manager.js";
16
20
  import { imagePullManager } from "../image-pull-manager.js";
17
21
  import { isSandboxEnabled } from "../feature-flags.js";
@@ -187,29 +191,21 @@ export function registerSystemRoutes(
187
191
 
188
192
  console.log("[update] Update successful, restarting service...");
189
193
 
190
- const isLinux = process.platform === "linux";
191
- const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
192
- const restartCmd = isLinux
193
- ? ["systemctl", "--user", "restart", "the-companion.service"]
194
- : uid !== undefined
195
- ? ["launchctl", "kickstart", "-k", `gui/${uid}/sh.thecompanion.app`]
196
- : ["launchctl", "kickstart", "-k", "sh.thecompanion.app"];
197
-
198
- Bun.spawn(restartCmd, {
199
- stdout: "ignore",
200
- stderr: "ignore",
201
- stdin: "ignore",
202
- env: isLinux
203
- ? {
204
- ...process.env,
205
- XDG_RUNTIME_DIR:
206
- process.env.XDG_RUNTIME_DIR ||
207
- `/run/user/${uid ?? 1000}`,
208
- }
209
- : undefined,
210
- });
211
-
212
- setTimeout(() => process.exit(0), 500);
194
+ const plan = getUpdateRestartPlan(getDetectedServiceKind());
195
+ if (plan.command) {
196
+ Bun.spawn(plan.command, {
197
+ stdout: "ignore",
198
+ stderr: "ignore",
199
+ stdin: "ignore",
200
+ env: plan.env ? { ...process.env, ...plan.env } : undefined,
201
+ });
202
+ } else {
203
+ console.log(
204
+ `[update] Exiting with code ${plan.exitCode} so the supervisor restarts the server.`,
205
+ );
206
+ }
207
+
208
+ setTimeout(() => process.exit(plan.exitCode), 500);
213
209
  } catch (err) {
214
210
  console.error("[update] Update failed:", err);
215
211
  setUpdateInProgress(false);
@@ -50,6 +50,38 @@ vi.mock("node:child_process", async (importOriginal) => {
50
50
  };
51
51
  });
52
52
 
53
+ // Mock node:net so isPortInUse() never binds a real port (the dev box may
54
+ // well be running a Companion on the default port).
55
+ const mockPortInUse = vi.hoisted(() => {
56
+ let inUse = false;
57
+ return { get: () => inUse, set: (v: boolean) => { inUse = v; } };
58
+ });
59
+
60
+ vi.mock("node:net", async (importOriginal) => {
61
+ const actual = await importOriginal<typeof import("node:net")>();
62
+ const { EventEmitter } = await import("node:events");
63
+ return {
64
+ ...actual,
65
+ createServer: () => {
66
+ const server = new EventEmitter() as InstanceType<typeof EventEmitter> & {
67
+ listen: (port: number) => void;
68
+ close: (cb?: () => void) => void;
69
+ };
70
+ server.listen = () => {
71
+ queueMicrotask(() => {
72
+ if (mockPortInUse.get()) {
73
+ server.emit("error", Object.assign(new Error("in use"), { code: "EADDRINUSE" }));
74
+ } else {
75
+ server.emit("listening");
76
+ }
77
+ });
78
+ };
79
+ server.close = (cb) => cb?.();
80
+ return server;
81
+ },
82
+ };
83
+ });
84
+
53
85
  // Mock path-resolver to return a deterministic enriched PATH
54
86
  const MOCK_SERVICE_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/mock/.bun/bin:/mock/.local/bin";
55
87
  vi.mock("./path-resolver.js", () => ({
@@ -74,6 +106,9 @@ beforeEach(async () => {
74
106
  mockHomedir.set(tempDir);
75
107
  mockPlatform.set("darwin");
76
108
  mockExecSync.mockReset();
109
+ mockPortInUse.set(false);
110
+ delete process.env.COMPANION_SERVICE_MODE;
111
+ delete process.env.INVOCATION_ID;
77
112
 
78
113
  // Set process.platform AFTER resetting mockPlatform to "darwin"
79
114
  Object.defineProperty(process, "platform", {
@@ -1417,3 +1452,200 @@ describe("platform check", () => {
1417
1452
  expect(existsSync(unitPath())).toBe(true);
1418
1453
  });
1419
1454
  });
1455
+
1456
+ // ===========================================================================
1457
+ // System-wide systemd unit support (issue #139)
1458
+ // ===========================================================================
1459
+
1460
+ const SYSTEM_UNIT_SHOW = [
1461
+ "LoadState=loaded",
1462
+ "ActiveState=active",
1463
+ "MainPID=4321",
1464
+ "InvocationID=0123456789abcdef0123456789abcdef",
1465
+ "FragmentPath=/etc/systemd/system/the-companion.service",
1466
+ "Environment=NODE_ENV=production PORT=3999 HOST=0.0.0.0",
1467
+ ].join("\n");
1468
+
1469
+ /** True for system-scope `systemctl show the-companion.service` queries. */
1470
+ function isSystemShow(cmd: string): boolean {
1471
+ return cmd.startsWith("systemctl show the-companion.service");
1472
+ }
1473
+
1474
+ describe("system-wide systemd unit (linux)", () => {
1475
+ beforeEach(async () => {
1476
+ mockPlatform.set("linux");
1477
+ Object.defineProperty(process, "platform", { value: "linux" });
1478
+ vi.resetModules();
1479
+ service = await import("./service.js");
1480
+ });
1481
+
1482
+ describe("getSystemUnitInfo", () => {
1483
+ it("parses state, pid, invocation id, path and port", () => {
1484
+ mockExecSync.mockImplementation((cmd: string) => (isSystemShow(cmd) ? SYSTEM_UNIT_SHOW : ""));
1485
+ expect(service.getSystemUnitInfo()).toEqual({
1486
+ activeState: "active",
1487
+ mainPid: 4321,
1488
+ invocationId: "0123456789abcdef0123456789abcdef",
1489
+ fragmentPath: "/etc/systemd/system/the-companion.service",
1490
+ port: 3999,
1491
+ });
1492
+ });
1493
+
1494
+ it("returns undefined for LoadState=not-found", () => {
1495
+ mockExecSync.mockImplementation(() => "LoadState=not-found\nActiveState=inactive\n");
1496
+ expect(service.getSystemUnitInfo()).toBeUndefined();
1497
+ });
1498
+
1499
+ it("returns undefined when systemctl fails", () => {
1500
+ mockExecSync.mockImplementation(() => { throw new Error("no systemd"); });
1501
+ expect(service.getSystemUnitInfo()).toBeUndefined();
1502
+ });
1503
+ });
1504
+
1505
+ describe("detectServiceKind / isRunningAsService", () => {
1506
+ it("detects the system unit when INVOCATION_ID matches", () => {
1507
+ process.env.INVOCATION_ID = "0123456789abcdef0123456789abcdef";
1508
+ mockExecSync.mockImplementation((cmd: string) => (isSystemShow(cmd) ? SYSTEM_UNIT_SHOW : ""));
1509
+ expect(service.isRunningAsService()).toBe(true);
1510
+ expect(service.getDetectedServiceKind()).toBe("systemd-system");
1511
+ });
1512
+
1513
+ it("ignores the system unit when this process is not its invocation", () => {
1514
+ process.env.INVOCATION_ID = "ffffffffffffffffffffffffffffffff";
1515
+ mockExecSync.mockImplementation((cmd: string) => (isSystemShow(cmd) ? SYSTEM_UNIT_SHOW : ""));
1516
+ expect(service.detectServiceKind()).toBeNull();
1517
+ });
1518
+
1519
+ it("ignores the system unit when not started by systemd (no INVOCATION_ID)", () => {
1520
+ mockExecSync.mockImplementation((cmd: string) => (isSystemShow(cmd) ? SYSTEM_UNIT_SHOW : ""));
1521
+ expect(service.detectServiceKind()).toBeNull();
1522
+ });
1523
+
1524
+ it("ignores an inactive system unit", () => {
1525
+ process.env.INVOCATION_ID = "0123456789abcdef0123456789abcdef";
1526
+ mockExecSync.mockImplementation((cmd: string) =>
1527
+ isSystemShow(cmd) ? SYSTEM_UNIT_SHOW.replace("ActiveState=active", "ActiveState=failed") : "");
1528
+ expect(service.detectServiceKind()).toBeNull();
1529
+ });
1530
+
1531
+ it("COMPANION_SERVICE_MODE=1 opts in without any unit", () => {
1532
+ process.env.COMPANION_SERVICE_MODE = "1";
1533
+ expect(service.isRunningAsService()).toBe(true);
1534
+ expect(service.getDetectedServiceKind()).toBe("external");
1535
+ });
1536
+
1537
+ it("COMPANION_SERVICE_MODE=0 forces service mode off", () => {
1538
+ process.env.COMPANION_SERVICE_MODE = "0";
1539
+ process.env.INVOCATION_ID = "0123456789abcdef0123456789abcdef";
1540
+ mockExecSync.mockImplementation((cmd: string) => (isSystemShow(cmd) ? SYSTEM_UNIT_SHOW : ""));
1541
+ expect(service.isRunningAsService()).toBe(false);
1542
+ expect(service.getDetectedServiceKind()).toBeNull();
1543
+ });
1544
+
1545
+ it("still reports systemd-user for an active per-user unit", async () => {
1546
+ mockExecSync.mockImplementation((cmd: string) =>
1547
+ cmd.startsWith("which") ? "/usr/local/bin/the-companion\n" : "");
1548
+ await service.install();
1549
+ mockExecSync.mockReset();
1550
+ mockExecSync.mockImplementation((cmd: string) => (cmd.includes("is-active") ? "active\n" : ""));
1551
+ expect(service.detectServiceKind()).toBe("systemd-user");
1552
+ });
1553
+ });
1554
+
1555
+ describe("install", () => {
1556
+ it("refuses when a system-wide unit exists", async () => {
1557
+ mockExecSync.mockImplementation((cmd: string) => {
1558
+ if (cmd.startsWith("which")) return "/usr/local/bin/the-companion\n";
1559
+ if (isSystemShow(cmd)) return SYSTEM_UNIT_SHOW;
1560
+ return "";
1561
+ });
1562
+ await expect(service.install()).rejects.toThrow("process.exit(1)");
1563
+ expect(existsSync(unitPath())).toBe(false);
1564
+ expect(mockExecSync).not.toHaveBeenCalledWith(
1565
+ expect.stringContaining("enable --now"), expect.anything());
1566
+ });
1567
+
1568
+ it("refuses when the port is already in use", async () => {
1569
+ mockPortInUse.set(true);
1570
+ mockExecSync.mockImplementation((cmd: string) =>
1571
+ cmd.startsWith("which") ? "/usr/local/bin/the-companion\n" : "");
1572
+ await expect(service.install()).rejects.toThrow("process.exit(1)");
1573
+ expect(existsSync(unitPath())).toBe(false);
1574
+ });
1575
+ });
1576
+
1577
+ describe("start/stop/restart/status/uninstall with only a system unit", () => {
1578
+ beforeEach(() => {
1579
+ mockExecSync.mockImplementation((cmd: string) => (isSystemShow(cmd) ? SYSTEM_UNIT_SHOW : ""));
1580
+ });
1581
+
1582
+ it("start does not install a user unit and prints the sudo hint when unprivileged", async () => {
1583
+ vi.spyOn(process, "getuid").mockReturnValue(1000);
1584
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
1585
+ await expect(service.start()).rejects.toThrow("process.exit(1)");
1586
+ expect(existsSync(unitPath())).toBe(false);
1587
+ expect(log.mock.calls.flat().join("\n")).toContain("sudo systemctl start the-companion.service");
1588
+ });
1589
+
1590
+ it("restart drives the system unit directly as root", async () => {
1591
+ vi.spyOn(process, "getuid").mockReturnValue(0);
1592
+ vi.spyOn(console, "log").mockImplementation(() => {});
1593
+ await service.restart();
1594
+ expect(mockExecSync).toHaveBeenCalledWith(
1595
+ "systemctl restart the-companion.service", expect.anything());
1596
+ expect(mockExecSync).not.toHaveBeenCalledWith(
1597
+ expect.stringContaining("--user restart"), expect.anything());
1598
+ });
1599
+
1600
+ it("stop prints the sudo hint when unprivileged", async () => {
1601
+ vi.spyOn(process, "getuid").mockReturnValue(1000);
1602
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
1603
+ await expect(service.stop()).rejects.toThrow("process.exit(1)");
1604
+ expect(log.mock.calls.flat().join("\n")).toContain("sudo systemctl stop the-companion.service");
1605
+ });
1606
+
1607
+ it("status reports the system unit", async () => {
1608
+ expect(await service.status()).toEqual({
1609
+ installed: true, running: true, pid: 4321, port: 3999, scope: "system",
1610
+ });
1611
+ });
1612
+
1613
+ it("uninstall leaves the system unit alone", async () => {
1614
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
1615
+ await service.uninstall();
1616
+ expect(mockExecSync).not.toHaveBeenCalledWith(
1617
+ expect.stringContaining("disable"), expect.anything());
1618
+ expect(log.mock.calls.flat().join("\n")).toContain("sudo systemctl disable --now");
1619
+ });
1620
+ });
1621
+ });
1622
+
1623
+ describe("getUpdateRestartPlan", () => {
1624
+ it("exits non-zero without spawning for a system unit", () => {
1625
+ expect(service.getUpdateRestartPlan("systemd-system")).toEqual({
1626
+ exitCode: service.UPDATE_RESTART_EXIT_CODE,
1627
+ });
1628
+ expect(service.UPDATE_RESTART_EXIT_CODE).not.toBe(0);
1629
+ // 42 is SuccessExitStatus in our own template; a "please restart" code must differ.
1630
+ expect(service.UPDATE_RESTART_EXIT_CODE).not.toBe(42);
1631
+ });
1632
+
1633
+ it("exits non-zero without spawning for COMPANION_SERVICE_MODE=1", () => {
1634
+ expect(service.getUpdateRestartPlan("external").command).toBeUndefined();
1635
+ expect(service.getUpdateRestartPlan("external").exitCode).toBe(service.UPDATE_RESTART_EXIT_CODE);
1636
+ });
1637
+
1638
+ it("uses systemctl --user restart for the user unit", () => {
1639
+ const plan = service.getUpdateRestartPlan("systemd-user");
1640
+ expect(plan.command).toEqual(["systemctl", "--user", "restart", "the-companion.service"]);
1641
+ expect(plan.env?.XDG_RUNTIME_DIR).toBeTruthy();
1642
+ expect(plan.exitCode).toBe(0);
1643
+ });
1644
+
1645
+ it("uses launchctl kickstart for launchd", () => {
1646
+ const plan = service.getUpdateRestartPlan("launchd");
1647
+ expect(plan.command?.slice(0, 3)).toEqual(["launchctl", "kickstart", "-k"]);
1648
+ expect(plan.command?.[3]).toMatch(/sh\.thecompanion\.app$/);
1649
+ expect(plan.exitCode).toBe(0);
1650
+ });
1651
+ });