@c4roa0/agentpay-user 0.3.6 → 0.3.8
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/README.md +3 -1
- package/doc/README.md +12 -0
- package/doc/TODO.md +48 -1
- package/doc/plans/local-instances-and-install-recovery.md +250 -0
- package/doc/records/client-isolated-acceptance-2026-11-11.md +37 -0
- package/doc/records/dual-install-systemd-2026-11-12.md +40 -0
- package/doc/records/historical-request-routing-2026-11-11.md +47 -0
- package/doc/records/installation-maintenance-2026-11-11.md +46 -0
- package/doc/records/npm-update-participant-2026-11-11.md +25 -0
- package/doc/reference/local-update.md +2 -1
- package/doc/runbooks/commands.md +28 -0
- package/doc/runbooks/local-update.md +11 -5
- package/package.json +1 -1
- package/progress.md +136 -6
- package/scripts/autostart.mjs +51 -25
- package/scripts/install-user.mjs +10 -3
- package/scripts/local-service.mjs +1 -1
- package/scripts/user-setup.mjs +4 -0
- package/src/cache-management-cli.mjs +32 -2
- package/src/default-request-owner.mjs +163 -0
- package/src/index.mjs +9 -0
- package/src/install-and-setup.mjs +6 -1
- package/src/install-selection.mjs +41 -0
- package/src/install-user.mjs +22 -22
- package/src/installation-binding.mjs +62 -0
- package/src/installation-operation.mjs +114 -0
- package/src/installation-paths.mjs +50 -1
- package/src/invocation-store.mjs +1 -1
- package/src/local-data-directory.mjs +17 -17
- package/src/local-update.mjs +41 -10
- package/src/local-wallet-ipc.mjs +2 -2
- package/src/npm-update.mjs +68 -20
- package/src/platform-endpoints.mjs +5 -8
- package/src/runtime-client.mjs +21 -4
- package/src/runtime-connection-settings.mjs +2 -1
- package/src/runtime-owner.mjs +22 -0
- package/src/runtime-selection-ready.mjs +29 -0
- package/src/service-readiness.mjs +29 -0
- package/src/service-selection.mjs +189 -0
- package/src/setup-autostart.mjs +12 -2
- package/src/setup-service-selection.mjs +25 -0
- package/src/skill-client.mjs +8 -3
- package/src/uninstall-local.mjs +69 -15
- package/src/user-cli.mjs +1 -2
- package/update-pair.json +1 -1
package/src/npm-update.mjs
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
|
+
import {atomicWritePrivate, assertPrivateFile} from './private-journal-files.mjs';
|
|
2
|
+
import {withInstallationOperation, runInstallationCommand} from './installation-operation.mjs';
|
|
3
|
+
import {maintenanceServiceScope} from './service-selection.mjs';
|
|
1
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
2
5
|
// Update real global or project npm installations. Source checkouts/npm links are never
|
|
3
6
|
// overwritten. npm selects User; User's exact dependencies select Wallet/Protocol.
|
|
4
|
-
import {
|
|
5
|
-
import { promisify } from 'node:util';
|
|
6
|
-
import { access, appendFile, copyFile, chmod, lstat, mkdir, mkdtemp, open, readFile, realpath, rm, unlink } from 'node:fs/promises';
|
|
7
|
+
import { access, appendFile, copyFile, chmod, lstat, mkdir, mkdtemp, readFile, realpath, rm, unlink } from 'node:fs/promises';
|
|
7
8
|
import { constants } from 'node:fs';
|
|
8
9
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
9
|
-
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
10
12
|
import { createInterface } from 'node:readline/promises';
|
|
11
13
|
import { installedUserDir, resolveUserDataDir, rememberUserDataDir } from './local-data-directory.mjs';
|
|
12
14
|
import { installationDirectory } from './installation-paths.mjs';
|
|
13
15
|
import { prepareOfficialTestUpdate } from './official-test-update.mjs';
|
|
14
|
-
const execute =
|
|
16
|
+
const execute = runInstallationCommand;
|
|
15
17
|
const name = '@c4roa0/agentpay-user';
|
|
16
18
|
const stable = value => typeof value === 'string' && /^\d+\.\d+\.\d+$/.test(value);
|
|
17
19
|
function newer(next, current) {
|
|
@@ -64,15 +66,20 @@ async function installationTarget(userDir, npm) {
|
|
|
64
66
|
flags:['--global=false','--workspaces=false','--save-exact',save,'--include=dev','--include=optional']};
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
export async function updateFromNpm({
|
|
69
|
+
export async function updateFromNpm(options = {}) {
|
|
70
|
+
try { return await withInstallationOperation(() => updateUnderLock(options)); }
|
|
71
|
+
catch(error) { (options.write || console.log)("Update stopped. " + error.message); return 1; }
|
|
72
|
+
}
|
|
73
|
+
async function updateUnderLock({
|
|
68
74
|
userDir = installedUserDir, dataDir, force = false, write = console.log,
|
|
69
75
|
ask = askConfirmation,
|
|
70
76
|
npm = (args, options) => command('npm', args, options),
|
|
71
77
|
service = (action, options) => command(process.execPath, [join(userDir,'scripts/autostart.mjs'), action], options),
|
|
72
78
|
repair = options => command(process.execPath, [join(userDir,'scripts/repair.mjs'), '-f'], options),
|
|
73
79
|
officialTest = prepareOfficialTestUpdate,
|
|
80
|
+
serviceScope = maintenanceServiceScope,
|
|
74
81
|
} = {}) {
|
|
75
|
-
let
|
|
82
|
+
let logPath, installing = false;
|
|
76
83
|
try {
|
|
77
84
|
userDir = resolve(userDir);
|
|
78
85
|
const current = JSON.parse(await readFile(join(userDir, 'package.json'), 'utf8'));
|
|
@@ -80,20 +87,62 @@ export async function updateFromNpm({
|
|
|
80
87
|
write('Checking for a User update…');
|
|
81
88
|
const target = await installationTarget(userDir, npm);
|
|
82
89
|
await access(dirname(target.directory), constants.W_OK);
|
|
83
|
-
const
|
|
90
|
+
const progressPath = join(installationDirectory(), 'update-' + createHash('sha256').update(userDir).digest('hex') + '.json');
|
|
91
|
+
let pending;
|
|
92
|
+
try {
|
|
93
|
+
await assertPrivateFile(progressPath);
|
|
94
|
+
if ((await lstat(progressPath)).size > 16384) throw Error('Invalid update recovery record.');
|
|
95
|
+
pending = JSON.parse(await readFile(progressPath,'utf8'));
|
|
96
|
+
if (pending.version !== 1 || pending.installation !== userDir || !stable(pending.targetVersion) ||
|
|
97
|
+
!stable(pending.walletVersion) || !stable(pending.protocolVersion) || !isAbsolute(pending.dataDir))
|
|
98
|
+
throw Error('Invalid update recovery record. Keep it for review.');
|
|
99
|
+
} catch(error) { if(error.code !== 'ENOENT') throw error; }
|
|
100
|
+
dataDir ??= await resolveUserDataDir({userDir});
|
|
101
|
+
if (pending && pending.dataDir !== dataDir) throw Error('The interrupted update belongs to another data folder. Keep its recovery record for review.');
|
|
102
|
+
const scope = await serviceScope({userDir,dataDir});
|
|
103
|
+
if (pending && current.version === pending.targetVersion) {
|
|
104
|
+
installing = true;
|
|
105
|
+
if (current.dependencies?.['@c4roa0/agentpay-wallet'] !== pending.walletVersion ||
|
|
106
|
+
current.dependencies?.['@c4roa0/agentpay-protocol'] !== pending.protocolVersion)
|
|
107
|
+
throw Error('The interrupted update has mismatched dependencies. Reinstall the confirmed User version, then retry.');
|
|
108
|
+
write('Continuing the interrupted update. Checking installed packages and service settings.');
|
|
109
|
+
const configured = await lstat(join(dataDir,'runtime/local-services.json')).then(s=>s.isFile(),e=>{if(e.code==='ENOENT')return false;throw e;});
|
|
110
|
+
if (configured) {
|
|
111
|
+
const output = await repair({env:{...process.env,AGENTPAY_USER_DATA_DIR:dataDir},cwd:target.prefix,timeout:600000});
|
|
112
|
+
if(output)write(output);
|
|
113
|
+
} else await officialTest({userDir,dataDir,write});
|
|
114
|
+
await unlink(progressPath);
|
|
115
|
+
write(`User ${current.version} update completed. No paid test was started.`);
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
if(pending && newer(current.version,pending.targetVersion))
|
|
119
|
+
throw Error('Installed User is newer than the interrupted update. Keep the recovery record for review; no downgrade was attempted.');
|
|
120
|
+
const metadata = JSON.parse(await npm(['view',name + '@' + (pending?.targetVersion || 'latest'),'--json'], {cwd:target.prefix}));
|
|
84
121
|
if (metadata.name !== name || !stable(metadata.version) ||
|
|
85
122
|
!stable(metadata.dependencies?.['@c4roa0/agentpay-wallet']) ||
|
|
86
123
|
!stable(metadata.dependencies?.['@c4roa0/agentpay-protocol']))
|
|
87
124
|
throw Error('The published User release does not declare reviewed package versions.');
|
|
88
|
-
|
|
125
|
+
if (pending && (metadata.version !== pending.targetVersion || metadata.dependencies["@c4roa0/agentpay-wallet"] !== pending.walletVersion || metadata.dependencies["@c4roa0/agentpay-protocol"] !== pending.protocolVersion))
|
|
126
|
+
throw Error("The published package differs from the interrupted update. No program files were changed.");
|
|
89
127
|
if (!newer(metadata.version,current.version)) {
|
|
90
128
|
write(`User ${current.version} is current. Checking the official test Skill.`);
|
|
91
129
|
await officialTest({userDir,dataDir,write});
|
|
92
130
|
return 0;
|
|
93
131
|
}
|
|
94
|
-
|
|
132
|
+
// Read the installed dependency, not the version requested by User's manifest.
|
|
133
|
+
// Missing/broken packages must not prevent npm from repairing the installation.
|
|
134
|
+
let walletVersion;
|
|
135
|
+
try {
|
|
136
|
+
const file = createRequire(join(userDir,'package.json')).resolve('@c4roa0/agentpay-wallet/package.json');
|
|
137
|
+
const wallet = JSON.parse(await readFile(file,'utf8'));
|
|
138
|
+
if(wallet.name === '@c4roa0/agentpay-wallet' && stable(wallet.version)) walletVersion = wallet.version;
|
|
139
|
+
} catch {}
|
|
140
|
+
const targetWallet = metadata.dependencies['@c4roa0/agentpay-wallet'];
|
|
141
|
+
write('Installed packages → update target');
|
|
142
|
+
write(`User ${current.version} → ${metadata.version}`);
|
|
143
|
+
write(`Wallet ${walletVersion === targetWallet ? `${walletVersion} (unchanged)` : `${walletVersion || 'not confirmed'} → ${targetWallet}`}`);
|
|
95
144
|
write(`Installation folder: ${target.prefix}`);
|
|
96
|
-
write('Background services will pause. Keys, permissions, Orders and cache are kept.');
|
|
145
|
+
write(scope.active ? 'Background services will pause. Keys, permissions, Orders and cache are kept.' : 'This installation is inactive. The running Wallet and Runtime will not change.');
|
|
97
146
|
if (!force && (await ask("Type 'confirm' to update, or 'q' to cancel")).trim().toLowerCase() !== 'confirm') {
|
|
98
147
|
write('Update cancelled. Nothing was changed.'); return 0;
|
|
99
148
|
}
|
|
@@ -102,8 +151,6 @@ export async function updateFromNpm({
|
|
|
102
151
|
const stateDir = installationDirectory();
|
|
103
152
|
await mkdir(stateDir,{recursive:true,mode:0o700});
|
|
104
153
|
logPath = join(stateDir, `update-${Date.now()}-${randomUUID()}.log`);
|
|
105
|
-
lockPath = join(stateDir,'install.lock');
|
|
106
|
-
lock = await open(lockPath,'wx',0o600);
|
|
107
154
|
const rechecked = JSON.parse(await readFile(join(userDir,'package.json'),'utf8'));
|
|
108
155
|
if (rechecked.version !== current.version || rechecked.name !== name)
|
|
109
156
|
throw Error('Program files changed while reviewing the update. Run agentpay update again.');
|
|
@@ -132,8 +179,13 @@ export async function updateFromNpm({
|
|
|
132
179
|
let configured = false;
|
|
133
180
|
try { configured = (await lstat(join(dataDir,'runtime/local-services.json'))).isFile(); }
|
|
134
181
|
catch(e) { if(e.code !== 'ENOENT') throw e; }
|
|
182
|
+
// Record the confirmed version before stopping or replacing anything. A retry
|
|
183
|
+
// must finish repair even when npm already installed the target version.
|
|
184
|
+
await atomicWritePrivate(progressPath, JSON.stringify({version:1,installation:userDir,dataDir,
|
|
185
|
+
targetVersion:metadata.version,walletVersion:metadata.dependencies['@c4roa0/agentpay-wallet'],
|
|
186
|
+
protocolVersion:metadata.dependencies['@c4roa0/agentpay-protocol']}) + '\n');
|
|
135
187
|
// All imports used by this updater are loaded before npm replaces its files.
|
|
136
|
-
if (configured) await service('stop',options);
|
|
188
|
+
if (configured && scope.active) await service('stop',options);
|
|
137
189
|
installing = true;
|
|
138
190
|
write('2/3 Install User and its matching Wallet. This may take a few minutes.');
|
|
139
191
|
await npm(args,options);
|
|
@@ -142,11 +194,8 @@ export async function updateFromNpm({
|
|
|
142
194
|
installed.dependencies?.['@c4roa0/agentpay-wallet'] !== metadata.dependencies['@c4roa0/agentpay-wallet'] ||
|
|
143
195
|
installed.dependencies?.['@c4roa0/agentpay-protocol'] !== metadata.dependencies['@c4roa0/agentpay-protocol'])
|
|
144
196
|
throw Error('The installed version does not match the confirmed update.');
|
|
145
|
-
// Release before the new version's repair acquires the same lock. Another
|
|
146
|
-
// operation may win that lock; repair then refuses safely and can be retried.
|
|
147
|
-
await lock.close(); lock = null; await unlink(lockPath); lockPath = null;
|
|
148
197
|
if (configured) {
|
|
149
|
-
write('3/3 Restore services and verify the official test Skill.');
|
|
198
|
+
write(scope.active ? '3/3 Restore services and verify the official test Skill.' : '3/3 Verify the inactive installation and official test Skill.');
|
|
150
199
|
// A fresh Node process loads the NEW repair/client/official digest, not old imports.
|
|
151
200
|
const output = await repair(options);
|
|
152
201
|
if(output) write(output);
|
|
@@ -154,6 +203,7 @@ export async function updateFromNpm({
|
|
|
154
203
|
write('3/3 Program files are ready. Local setup has not been configured.');
|
|
155
204
|
write('Next: agentpay install --market YOUR_MARKET_URL');
|
|
156
205
|
}
|
|
206
|
+
await unlink(progressPath);
|
|
157
207
|
write(`User ${metadata.version} installed. No paid test was started.`);
|
|
158
208
|
return 0;
|
|
159
209
|
} catch(error) {
|
|
@@ -162,7 +212,5 @@ export async function updateFromNpm({
|
|
|
162
212
|
: 'Update stopped before replacing program files. ' + (error.code === 'EEXIST' ? 'Another installation is running.' : error.message));
|
|
163
213
|
if(logPath) write('Log: ' + logPath);
|
|
164
214
|
return 1;
|
|
165
|
-
} finally {
|
|
166
|
-
if(lock) { await lock.close(); await unlink(lockPath); }
|
|
167
215
|
}
|
|
168
216
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {withInstallationOperation,runInstallationCommand} from './installation-operation.mjs';
|
|
1
2
|
// SPDX-License-Identifier: MPL-2.0
|
|
2
3
|
// Deliberate transport changes only. Never replace a Policy, request, or result.
|
|
3
4
|
import {open,mkdir,lstat,rename,unlink,readFile} from 'node:fs/promises';
|
|
@@ -5,8 +6,6 @@ import {constants} from 'node:fs';
|
|
|
5
6
|
import {join,dirname} from 'node:path';
|
|
6
7
|
import {homedir} from 'node:os';
|
|
7
8
|
import {randomUUID} from 'node:crypto';
|
|
8
|
-
import {execFile} from 'node:child_process';
|
|
9
|
-
import {promisify} from 'node:util';
|
|
10
9
|
import {installedUserDir} from './local-data-directory.mjs';
|
|
11
10
|
import {deploymentFilePath,installationDirectory} from './installation-paths.mjs';
|
|
12
11
|
import {readPrivateJson,deploymentChanges} from './deployment-status.mjs';
|
|
@@ -18,7 +17,7 @@ import {settlementProfileBlocker} from './readiness-profile.mjs';
|
|
|
18
17
|
import {readRuntimeStatus} from './runtime-status.mjs';
|
|
19
18
|
import {readSettlementAddresses} from './settlement-address-history.mjs';
|
|
20
19
|
|
|
21
|
-
const execute=
|
|
20
|
+
const execute=runInstallationCommand;
|
|
22
21
|
const normalize=value=>new URL(value).href.replace(/\/+$/,'');
|
|
23
22
|
const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
|
|
24
23
|
const pendingPath=dir=>join(dir,'runtime/endpoint-update.pending.json');
|
|
@@ -102,12 +101,11 @@ export async function preparePlatformEndpoints({dataDir,origin,scope,userDir=ins
|
|
|
102
101
|
if(!metadata.response.ok||!metadata.result?.ok||!/^[a-f0-9]{64}$/.test(metadata.result.result?.sha256||''))
|
|
103
102
|
throw Error('The new package address could not be checked. Current addresses are kept.');
|
|
104
103
|
if(!changed)return {changed:false,apply:async()=>{}};
|
|
105
|
-
return {changed:true,urls,async apply({
|
|
104
|
+
return {changed:true,urls,async apply(options={}){return withInstallationOperation(()=>applyAddresses(options));}};
|
|
105
|
+
async function applyAddresses({write=console.log,manage=runtimeControl,stateDir=installationDirectory(),saveConnection=async()=>{}}={}){
|
|
106
106
|
await mkdir(stateDir,{recursive:true,mode:0o700});
|
|
107
107
|
const stat=await lstat(stateDir);
|
|
108
108
|
if(!stat.isDirectory()||stat.isSymbolicLink()||stat.uid!==process.getuid()||(stat.mode&0o077))throw Error('Unsafe installation state directory.');
|
|
109
|
-
const lockPath=join(stateDir,'install.lock');let lock;
|
|
110
|
-
try{lock=await open(lockPath,'wx',0o600);}catch(e){if(e.code==='EEXIST')throw Error('Another setup or update is running. Retry market connect when it finishes.');throw e;}
|
|
111
109
|
let updating=false;
|
|
112
110
|
try{
|
|
113
111
|
if(!same(await readPrivateJson(join(dataDir,'market-connection.json')),connectionBefore)||!same(await readPrivateJson(join(dataDir,'wallet/policy.json')),policy)||!same(await readServiceSettings(dataDir),settings)||!same(await readPrivateJson(publicPath),previousPublic)||!same(await readPrivateJson(await deploymentFilePath(dataDir,userDir,'anvil-onboarding.json')),setup))
|
|
@@ -133,6 +131,5 @@ export async function preparePlatformEndpoints({dataDir,origin,scope,userDir=ins
|
|
|
133
131
|
await unlink(pendingPath(dataDir));
|
|
134
132
|
write('Platform addresses updated. Your Wallet, permission and Order records were kept.');
|
|
135
133
|
}catch(e){throw Error('Address update did not finish.\n'+e.message+'\n'+(updating?'Some connection settings may already be saved. Wallet keys, Policies and Orders were kept.':'No connection addresses were changed.')+'\nNext: agentpay market connect --market '+origin);}
|
|
136
|
-
|
|
137
|
-
}};
|
|
134
|
+
}
|
|
138
135
|
}
|
package/src/runtime-client.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync, lstatSync } from "node:fs";
|
|
2
|
+
import { installationDirectory } from "./installation-paths.mjs";
|
|
1
3
|
// Shared Skill transport and recovery. Resolve local credentials privately; endpoint
|
|
2
4
|
// changes must not replace the original purchase identity or bypass retained checkpoints.
|
|
3
5
|
// SPDX-License-Identifier: MPL-2.0
|
|
@@ -38,6 +40,17 @@ export class RuntimeClientError extends Error {
|
|
|
38
40
|
}
|
|
39
41
|
const fail = (code, message) => new RuntimeClientError(code, message);
|
|
40
42
|
export function userDataDirectory(env = process.env) {
|
|
43
|
+
if (!env.AGENTPAY_USER_DATA_DIR) {
|
|
44
|
+
const path=join(installationDirectory(),'active-services.json');
|
|
45
|
+
try {
|
|
46
|
+
const st=lstatSync(path);
|
|
47
|
+
if (!st.isFile() || st.isSymbolicLink() || st.uid!==process.getuid() || st.mode & 0o077 || st.size>32768) throw Error('Unsafe default Runtime selection.');
|
|
48
|
+
const selection=JSON.parse(readFileSync(path,'utf8'));
|
|
49
|
+
if (selection.version!==1 || selection.phase!=='selected' || typeof selection.target?.dataDir!=='string' || !isAbsolute(selection.target.dataDir))
|
|
50
|
+
throw Error('The default Runtime is unavailable. Start the selected installation before using a Skill.');
|
|
51
|
+
return selection.target.dataDir;
|
|
52
|
+
} catch(e) {if(e.code!=='ENOENT')throw e;}
|
|
53
|
+
}
|
|
41
54
|
return resolve(
|
|
42
55
|
env.AGENTPAY_USER_DATA_DIR || join(homedir(), ".local/share/agentpay/user"),
|
|
43
56
|
);
|
|
@@ -852,7 +865,7 @@ export async function selectInvocation(
|
|
|
852
865
|
connection,
|
|
853
866
|
service,
|
|
854
867
|
input,
|
|
855
|
-
{ force = false, requestId } = {},
|
|
868
|
+
{ force = false, requestId, knownRequestIds } = {},
|
|
856
869
|
) {
|
|
857
870
|
if (force && requestId !== undefined)
|
|
858
871
|
throw fail(
|
|
@@ -871,9 +884,13 @@ export async function selectInvocation(
|
|
|
871
884
|
);
|
|
872
885
|
const id =
|
|
873
886
|
requestId || (!force ? state.currentId || state.pending[0] : undefined);
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
887
|
+
if (!force && knownRequestIds?.length && (!id || !knownRequestIds.includes(id)))
|
|
888
|
+
throw fail("REQUEST_HISTORY_REVIEW_REQUIRED",
|
|
889
|
+
"The original request selection is missing or conflicting. Use --resume with the saved request ID in the original installation; do not start a replacement purchase.");
|
|
890
|
+
const retained = id && (state.currentId === id || state.pending.includes(id) || knownRequestIds?.includes(id));
|
|
891
|
+
const checkpoint = retained
|
|
892
|
+
? await resumeInvocation(connection, service, id, input)
|
|
893
|
+
: await prepareInvocation(connection, service, input, id);
|
|
877
894
|
if (!state.pending.includes(checkpoint.requestId)) {
|
|
878
895
|
const cached = await cachedResult(connection, checkpoint);
|
|
879
896
|
if (!cached) state.pending.push(checkpoint.requestId);
|
|
@@ -12,7 +12,7 @@ import { fileURLToPath } from "node:url";
|
|
|
12
12
|
import { randomUUID } from "node:crypto";
|
|
13
13
|
import { readServiceSettings } from "./local-services.mjs";
|
|
14
14
|
|
|
15
|
-
export async function ensureRuntimeConnection({ dataDir, write = () => {}, refreshClientModule = false, checkOnly = false }) {
|
|
15
|
+
export async function ensureRuntimeConnection({ dataDir, write = () => {}, refreshClientModule = false, previousClientModule, checkOnly = false }) {
|
|
16
16
|
const settings = await readServiceSettings(dataDir);
|
|
17
17
|
const directory = join(dataDir, "runtime"),
|
|
18
18
|
path = join(directory, "connection.json");
|
|
@@ -45,6 +45,7 @@ export async function ensureRuntimeConnection({ dataDir, write = () => {}, refre
|
|
|
45
45
|
throw Error("conflict");
|
|
46
46
|
if (saved.clientModule !== expected.clientModule) {
|
|
47
47
|
if (!refreshClientModule || typeof saved.clientModule !== 'string' || !isAbsolute(saved.clientModule) || !saved.clientModule.endsWith('/src/runtime-client.mjs')) throw Error('conflict');
|
|
48
|
+
if (previousClientModule !== undefined && saved.clientModule !== previousClientModule) throw Error('conflict');
|
|
48
49
|
// Explicit repair after stopping services can relocate trusted client code.
|
|
49
50
|
// URL, credential, request identities and authorization remain unchanged.
|
|
50
51
|
if (!checkOnly) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
// A descriptor lock prevents different product entry points from running two
|
|
3
|
+
// Runtimes for the same OS user, even when their ports and data folders differ.
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import { open, mkdir, lstat } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { ensureInstallationDirectory } from './installation-paths.mjs';
|
|
8
|
+
export async function acquireRuntimeOwner() {
|
|
9
|
+
const directory=await ensureInstallationDirectory();
|
|
10
|
+
await mkdir(directory,{recursive:true,mode:0o700});
|
|
11
|
+
const info=await lstat(directory);
|
|
12
|
+
if(!info.isDirectory() || info.isSymbolicLink() || info.uid!==process.getuid() || info.mode & 0o077)
|
|
13
|
+
throw Error('Runtime ownership directory must be private and owned by you.');
|
|
14
|
+
const handle=await open(join(directory,'runtime-owner.lock'),constants.O_CREAT|constants.O_RDWR|constants.O_NOFOLLOW,0o600);
|
|
15
|
+
try {
|
|
16
|
+
const info=await handle.stat();
|
|
17
|
+
if(!info.isFile() || info.nlink!==1 || info.uid!==process.getuid() || info.mode & 0o077) throw Error('Unsafe Runtime ownership lock.');
|
|
18
|
+
const {tryLock}=await import('fs-native-extensions');
|
|
19
|
+
if(!tryLock(handle.fd)) throw Error('Another AgentPay Runtime is running. Switch installations through agentpay service start; do not start a second Runtime.');
|
|
20
|
+
return handle;
|
|
21
|
+
} catch(e) { await handle.close(); throw e; }
|
|
22
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
import {readFile,realpath} from 'node:fs/promises';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
import {installationDirectory} from './installation-paths.mjs';
|
|
5
|
+
import {assertPrivateFile,atomicWritePrivate} from './private-journal-files.mjs';
|
|
6
|
+
import {fileURLToPath} from 'node:url';
|
|
7
|
+
import {waitForServiceReadiness} from './service-readiness.mjs';
|
|
8
|
+
import {withClientRequestLock} from './client-request-lock.mjs';
|
|
9
|
+
// Called after bind. Login startup may revive a stopped selection, but must never
|
|
10
|
+
// overwrite an unfinished switch or select a different data directory.
|
|
11
|
+
export async function publishRuntimeReady(dataDir, {verifyReady = waitForServiceReadiness} = {}) {
|
|
12
|
+
const state=installationDirectory(),path=join(state,'active-services.json');
|
|
13
|
+
const userDir=await realpath(fileURLToPath(new URL('..',import.meta.url)));
|
|
14
|
+
for (let attempt=0;attempt<60;attempt++) {
|
|
15
|
+
try {return await withClientRequestLock(join(state,'service-selection.lock'),async()=>{
|
|
16
|
+
let selected;
|
|
17
|
+
try {await assertPrivateFile(path);selected=JSON.parse(await readFile(path,'utf8'));}
|
|
18
|
+
catch(e){if(e.code==='ENOENT')return;throw e;}
|
|
19
|
+
if(selected.version!==1 || selected.target?.dataDir!==dataDir || selected.target?.userDir!==userDir || !['stopped','selected'].includes(selected.phase))return;
|
|
20
|
+
// Recheck after acquiring the transition lock; an earlier probe could become
|
|
21
|
+
// stale while a concurrent stop/switch runs. Login also verifies both services.
|
|
22
|
+
await verifyReady(dataDir);
|
|
23
|
+
await atomicWritePrivate(path,JSON.stringify({...selected,phase:'selected'})+'\n');
|
|
24
|
+
});} catch(error) {
|
|
25
|
+
if(error.code!=='REQUEST_BUSY' || attempt===59)throw error;
|
|
26
|
+
await new Promise(resolve=>setTimeout(resolve,300));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
// Authenticated local status, not an open port or systemctl's start result,
|
|
3
|
+
// establishes readiness. A locked Wallet and unfinished Order checks are normal.
|
|
4
|
+
import {localWalletAdmin} from './local-wallet-ipc.mjs';
|
|
5
|
+
import {readRuntimeStatus} from './runtime-status.mjs';
|
|
6
|
+
import {requireRunningSoftware} from './software-versions.mjs';
|
|
7
|
+
|
|
8
|
+
export async function waitForServiceReadiness(dataDir, {
|
|
9
|
+
wallet = dir => localWalletAdmin(dir, 'status', {}, {timeoutMs:2500}),
|
|
10
|
+
runtime = readRuntimeStatus,
|
|
11
|
+
timeoutMs = 15000,
|
|
12
|
+
pause = ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
13
|
+
} = {}) {
|
|
14
|
+
const deadline = Date.now() + timeoutMs;
|
|
15
|
+
do {
|
|
16
|
+
try {
|
|
17
|
+
const [w,r] = await Promise.all([wallet(dataDir), runtime(dataDir)]);
|
|
18
|
+
if (typeof w?.locked !== 'boolean' || r?.role !== 'user-runtime')
|
|
19
|
+
throw Error('Invalid local service response.');
|
|
20
|
+
requireRunningSoftware(w,r);
|
|
21
|
+
return;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error.code === 'RUNNING_VERSION_UNVERIFIED') throw error;
|
|
24
|
+
if (Date.now() >= deadline) break;
|
|
25
|
+
await pause(300);
|
|
26
|
+
}
|
|
27
|
+
} while (Date.now() < deadline);
|
|
28
|
+
throw Error('Wallet and Runtime readiness could not be confirmed. Default Skill access stays paused. Run agentpay status, then retry agentpay service start.');
|
|
29
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import {importHistoricalRequests} from './default-request-owner.mjs';
|
|
2
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
// One managed service pair per OS user. A switch never changes Wallet identity.
|
|
4
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { autostartFiles, units } from './autostart-files.mjs';
|
|
8
|
+
import { installationDirectory } from './installation-paths.mjs';
|
|
9
|
+
import { withInstallationOperation } from './installation-operation.mjs';
|
|
10
|
+
import { readServiceSettings, listenerPresent } from './local-services.mjs';
|
|
11
|
+
import { atomicWritePrivate } from './private-journal-files.mjs';
|
|
12
|
+
import { ensureRuntimeConnection } from './runtime-connection-settings.mjs';
|
|
13
|
+
import { waitForServiceReadiness } from './service-readiness.mjs';
|
|
14
|
+
|
|
15
|
+
export async function installedServiceSelection(unitDir = join(homedir(), '.config/systemd/user'), recovery, {allowPartial = false} = {}) {
|
|
16
|
+
let identity;
|
|
17
|
+
let count = 0;
|
|
18
|
+
for (const name of units) {
|
|
19
|
+
const path = join(unitDir,name);
|
|
20
|
+
let info;
|
|
21
|
+
try { info = await lstat(path); } catch (e) { if (e.code === 'ENOENT') continue; throw e; }
|
|
22
|
+
if (!info.isFile() || info.isSymbolicLink() || info.uid !== process.getuid() || info.mode & 0o022)
|
|
23
|
+
throw Error('Existing service files need ownership review before switching.');
|
|
24
|
+
const body = await readFile(path,'utf8');
|
|
25
|
+
const line = body.split('\n').find(s => s.startsWith('ExecStart='));
|
|
26
|
+
const args = [...(line || '').matchAll(/"(?:\\.|[^"\\])*"/g)].map(([s]) => JSON.parse(s).replaceAll('%%','%').replaceAll('$$','$'));
|
|
27
|
+
const suffix = name.includes('reminder') ? '/scripts/login-notify.mjs' : '/scripts/local-service.mjs';
|
|
28
|
+
if (!args[1]?.endsWith(suffix) || !args[2]) throw Error('Unrecognized startup file. No service was changed.');
|
|
29
|
+
const found = {node:args[0], userDir:args[1].slice(0,-suffix.length), dataDir:args[2]};
|
|
30
|
+
const recoverable = recovery && [recovery.previous,recovery.target].filter(Boolean).some(value=>autostartFiles(value)[name]===body);
|
|
31
|
+
if ((recovery && !recoverable) || autostartFiles(found)[name] !== body || (!recoverable && identity && JSON.stringify(found) !== JSON.stringify(identity)))
|
|
32
|
+
throw Error('Startup files belong to conflicting configurations. No service was changed.');
|
|
33
|
+
identity = found; count++;
|
|
34
|
+
}
|
|
35
|
+
if (!recovery && !allowPartial && count && count !== units.length) throw Error('Startup files are incomplete. Repair the original installation before switching.');
|
|
36
|
+
return recovery ? (recovery.previous || recovery.target) : identity;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Full, exact target units distinguish a failed service command from interrupted
|
|
40
|
+
// file replacement. Never infer completion from only one surviving unit.
|
|
41
|
+
async function targetUnitsPrepared(unitDir, target) {
|
|
42
|
+
try {
|
|
43
|
+
const found = await installedServiceSelection(unitDir);
|
|
44
|
+
return Boolean(found && ['node','userDir','dataDir'].every(key => found[key] === target?.[key]));
|
|
45
|
+
} catch { return false; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The caller holds this lock through unit replacement and start/stop. No prompts
|
|
49
|
+
// can grant approval to a different target while the current transition writes.
|
|
50
|
+
export async function withServiceSelection({target, action, ask, write = console.log, ctl, unitDir, operation, verifyReady = waitForServiceReadiness}) {
|
|
51
|
+
return withServiceLifecycleLock(async () => {
|
|
52
|
+
const canonical = path => realpath(path).catch(error=>{if(error.code==='ENOENT')return resolve(path);throw error;});
|
|
53
|
+
target = {...target,userDir:await canonical(target.userDir),dataDir:await canonical(target.dataDir)};
|
|
54
|
+
const state = installationDirectory();
|
|
55
|
+
const journalPath = join(state,'active-services.json');
|
|
56
|
+
let recovery, savedSelection;
|
|
57
|
+
try {
|
|
58
|
+
const st=await lstat(journalPath);
|
|
59
|
+
if (!st.isFile() || st.isSymbolicLink() || st.uid!==process.getuid() || st.mode & 0o077 || st.size>32768) throw Error('Unsafe service transition record.');
|
|
60
|
+
const saved=JSON.parse(await readFile(journalPath,'utf8'));
|
|
61
|
+
savedSelection=saved;
|
|
62
|
+
if (saved.version!==1) throw Error('Invalid service transition record.');
|
|
63
|
+
if (['stopping','preparing'].includes(saved.phase)) {
|
|
64
|
+
if (!saved.target || await canonical(saved.target.dataDir)!==target.dataDir || await canonical(saved.target.userDir)!==target.userDir) throw Error('A switch was interrupted. Retry from the originally selected installation before choosing another one.');
|
|
65
|
+
recovery=saved;
|
|
66
|
+
}
|
|
67
|
+
} catch(e) { if(e.code!=='ENOENT')throw e; }
|
|
68
|
+
if (recovery && await targetUnitsPrepared(unitDir,recovery.target)) {
|
|
69
|
+
// Also recover journals written before the explicit units-prepared checkpoint.
|
|
70
|
+
await atomicWritePrivate(journalPath,JSON.stringify({version:1,phase:'stopped',target:recovery.target,startupChoice:recovery.startupChoice})+'\n');
|
|
71
|
+
savedSelection={...recovery,phase:'stopped'};
|
|
72
|
+
recovery=undefined;
|
|
73
|
+
}
|
|
74
|
+
const previous = await installedServiceSelection(unitDir,recovery);
|
|
75
|
+
if (action === 'status') return operation({previous,switched:false});
|
|
76
|
+
if (recovery && !['start','restart','activate-setup'].includes(action))
|
|
77
|
+
throw Error('A service switch is unfinished. Retry agentpay service start before changing startup preferences.');
|
|
78
|
+
let startupChoice=recovery?.startupChoice || (savedSelection?.target?.dataDir===target.dataDir ? savedSelection.startupChoice : undefined);
|
|
79
|
+
const different = previous && (previous.dataDir !== target.dataDir || previous.userDir !== target.userDir);
|
|
80
|
+
if (different) {
|
|
81
|
+
if (!['start','restart','activate-setup'].includes(action))
|
|
82
|
+
throw Error('Another installation owns the background services. Run service start from this installation to switch explicitly.');
|
|
83
|
+
write('\nSwitch AgentPay installation');
|
|
84
|
+
write('Current data: ' + previous.dataDir);
|
|
85
|
+
write('Selected data: ' + target.dataDir);
|
|
86
|
+
write('Wallet and Runtime will stop. Keys, permissions and Order records stay in their own folders.');
|
|
87
|
+
if (!ask || String(await ask("Type 'confirm' to switch, or 'q' to cancel")).trim().toLowerCase() !== 'confirm') {
|
|
88
|
+
write('Switch cancelled. Existing services were kept.');
|
|
89
|
+
return {cancelled:true};
|
|
90
|
+
}
|
|
91
|
+
if (!startupChoice) {
|
|
92
|
+
const states=await Promise.all(units.slice(0,2).map(async name=>String((await ctl('show',name,'--property=UnitFileState','--value')).stdout || '').trim()));
|
|
93
|
+
if (states.every(s=>s==='enabled')) startupChoice='enabled';
|
|
94
|
+
else if (states.every(s=>s==='disabled')) startupChoice='disabled';
|
|
95
|
+
}
|
|
96
|
+
await atomicWritePrivate(join(state,'active-services.json'),JSON.stringify({version:1,phase:'stopping',previous,target,startupChoice})+'\n');
|
|
97
|
+
await ctl('stop',...units);
|
|
98
|
+
// is-active returning nonzero alone is not evidence of a successful stop:
|
|
99
|
+
// inspect explicit ActiveState values, including failed/inactive only.
|
|
100
|
+
for (const name of units) {
|
|
101
|
+
const result = await ctl('show',name,'--property=ActiveState','--value');
|
|
102
|
+
if (!['inactive','failed'].includes(String(result.stdout || '').trim()))
|
|
103
|
+
throw Error('Previous services have not stopped. The selected installation was not started.');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (different) {
|
|
107
|
+
const settings = await readServiceSettings(previous.dataDir);
|
|
108
|
+
if (await listenerPresent(join(previous.dataDir,'wallet/wallet.sock')) ||
|
|
109
|
+
await listenerPresent({host:'127.0.0.1',port:Number(new URL(settings.runtimeUrl).port)}))
|
|
110
|
+
throw Error('An original Wallet or Runtime listener is still running. Stop it before switching; no new service was started.');
|
|
111
|
+
}
|
|
112
|
+
if (different) {
|
|
113
|
+
for (const dataDir of new Set([previous.dataDir,target.dataDir])) {
|
|
114
|
+
const history = await importHistoricalRequests(dataDir);
|
|
115
|
+
if (history?.unclassified)
|
|
116
|
+
write('Some older requests need review. Their original files are kept; automatic replacement purchases are blocked.');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const journal = join(state,'active-services.json');
|
|
120
|
+
const starts = ['start','restart'].includes(action);
|
|
121
|
+
const preservesRunning = !different && !recovery && !['stop','remove'].includes(action) &&
|
|
122
|
+
savedSelection?.phase === 'selected' && savedSelection.target?.dataDir === target.dataDir &&
|
|
123
|
+
savedSelection.target?.userDir === target.userDir;
|
|
124
|
+
// Preference-only operations must not revive a stopped default connection.
|
|
125
|
+
if (starts || different || recovery || ['stop','remove'].includes(action))
|
|
126
|
+
await atomicWritePrivate(journal,JSON.stringify({version:1,phase:'preparing',previous,target,startupChoice})+'\n');
|
|
127
|
+
// Reusing data across program installations moves only the client locator.
|
|
128
|
+
// Consent and stopped-listener checks above must precede this write. On retry
|
|
129
|
+
// the locator may already point here; unrelated paths still require review.
|
|
130
|
+
if (different && previous.dataDir === target.dataDir) {
|
|
131
|
+
await ensureRuntimeConnection({
|
|
132
|
+
dataDir:target.dataDir, write, refreshClientModule:true,
|
|
133
|
+
previousClientModule:join(previous.userDir,'src/runtime-client.mjs'),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// Exact old unit bodies may be replaced only inside this confirmed transition.
|
|
137
|
+
const unitsPrepared = async () => {
|
|
138
|
+
if (!await targetUnitsPrepared(unitDir,target))
|
|
139
|
+
throw Error('Service files are incomplete. Retry agentpay service start from this installation.');
|
|
140
|
+
// "stopped" closes the default route; it is not a claim about process state.
|
|
141
|
+
// Persist before invoking systemctl so a crash/timeout still permits repair.
|
|
142
|
+
await atomicWritePrivate(journal,JSON.stringify({version:1,phase:'stopped',target,startupChoice})+'\n');
|
|
143
|
+
};
|
|
144
|
+
const result = await operation({previous, switched:Boolean(different), unitsPrepared});
|
|
145
|
+
if (starts) {
|
|
146
|
+
write('Checking Wallet and Runtime readiness…');
|
|
147
|
+
try { await verifyReady(target.dataDir); }
|
|
148
|
+
catch (error) {
|
|
149
|
+
// Unit installation completed. Keep the default closed, but allow repair
|
|
150
|
+
// or a later start instead of stranding it in a unit-write transition.
|
|
151
|
+
await atomicWritePrivate(journal,JSON.stringify({version:1,phase:'stopped',target,startupChoice})+'\n');
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if(action==='enable')startupChoice='enabled';
|
|
156
|
+
if(action==='disable' || action==='remove')startupChoice='disabled';
|
|
157
|
+
const phase = starts || preservesRunning ? 'selected' : 'stopped';
|
|
158
|
+
await atomicWritePrivate(journal,JSON.stringify({version:1,phase,target,startupChoice})+'\n');
|
|
159
|
+
return result;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Uninstall shares the service transition lock. Keep confirmation outside this
|
|
164
|
+
// section, then re-read the selected scope before making destructive changes.
|
|
165
|
+
export async function withServiceLifecycleLock(operation) {
|
|
166
|
+
return withInstallationOperation(operation);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Called while holding the installation-operation lock. Maintenance of an inactive
|
|
170
|
+
// program tree must never claim the fixed service pair or rewrite active data.
|
|
171
|
+
export async function maintenanceServiceScope({userDir,dataDir,unitDir}) {
|
|
172
|
+
const canonical = async path => realpath(path).catch(error=>{if(error.code==='ENOENT')return resolve(path);throw error;});
|
|
173
|
+
userDir = await canonical(userDir); dataDir = await canonical(dataDir);
|
|
174
|
+
let saved;
|
|
175
|
+
try {
|
|
176
|
+
const path=join(installationDirectory(),'active-services.json');
|
|
177
|
+
const {assertPrivateFile}=await import('./private-journal-files.mjs');
|
|
178
|
+
await assertPrivateFile(path); saved=JSON.parse(await readFile(path,'utf8'));
|
|
179
|
+
if(saved.version!==1)throw Error('Unrecognized service selection. Keep it for review.');
|
|
180
|
+
if(['stopping','preparing'].includes(saved.phase) && !await targetUnitsPrepared(unitDir,saved.target))
|
|
181
|
+
throw Error('A service switch is unfinished. Retry service start from its selected installation before maintenance.');
|
|
182
|
+
} catch(error) {if(error.code!=='ENOENT')throw error;}
|
|
183
|
+
const owner=await installedServiceSelection(unitDir) || saved?.target;
|
|
184
|
+
if(!owner)return {active:true}; // Existing single-install setup/repair behavior.
|
|
185
|
+
if(await canonical(owner.userDir)===userDir && await canonical(owner.dataDir)===dataDir)return {active:true};
|
|
186
|
+
if(await canonical(owner.userDir)===userDir || await canonical(owner.dataDir)===dataDir)
|
|
187
|
+
throw Error('Another service selection shares these program or data files. Switch explicitly before repairing or updating this installation.');
|
|
188
|
+
return {active:false};
|
|
189
|
+
}
|
package/src/setup-autostart.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { installationDirectory } from "./installation-paths.mjs";
|
|
2
|
+
import { assertPrivateFile } from "./private-journal-files.mjs";
|
|
1
3
|
import { walletCliPath } from "./wallet-package.mjs";
|
|
2
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
3
5
|
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
@@ -14,7 +16,6 @@ import {
|
|
|
14
16
|
} from "./local-services.mjs";
|
|
15
17
|
const execute = promisify(execFile);
|
|
16
18
|
const userDir = fileURLToPath(new URL("..", import.meta.url));
|
|
17
|
-
const walletCli = walletCliPath();
|
|
18
19
|
async function ctl(...args) {
|
|
19
20
|
return execute("systemctl", ["--user", ...args], {
|
|
20
21
|
timeout: 10000,
|
|
@@ -83,7 +84,7 @@ async function ownership(dataDir, settings) {
|
|
|
83
84
|
} catch {}
|
|
84
85
|
const expected =
|
|
85
86
|
role === "wallet"
|
|
86
|
-
? [process.execPath,
|
|
87
|
+
? [process.execPath, walletCliPath(), "serve", join(dataDir, "wallet")]
|
|
87
88
|
: [process.execPath, join(userDir, "src/index.mjs")];
|
|
88
89
|
if (
|
|
89
90
|
!(await guideOwnsProcess({
|
|
@@ -143,6 +144,15 @@ export async function finishLocalSetup({
|
|
|
143
144
|
settlementUrl: config.settlementUrl,
|
|
144
145
|
});
|
|
145
146
|
const settings = await readSettings(dataDir);
|
|
147
|
+
const selectionPath=join(installationDirectory(),'active-services.json');
|
|
148
|
+
try {
|
|
149
|
+
await assertPrivateFile(selectionPath);
|
|
150
|
+
const selection=JSON.parse(await readFile(selectionPath,'utf8'));
|
|
151
|
+
if (selection.target?.dataDir===dataDir && ['enabled','disabled'].includes(selection.startupChoice)) {
|
|
152
|
+
settings.startupChoice=selection.startupChoice;
|
|
153
|
+
await save({dataDir,...settings});
|
|
154
|
+
}
|
|
155
|
+
} catch(e) { if(e.code!=='ENOENT')throw e; }
|
|
146
156
|
await available();
|
|
147
157
|
if (!(await owns(dataDir, settings))) {
|
|
148
158
|
write(
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
// Manual setup remains available when the OS has no user service manager.
|
|
3
|
+
import { execFile, spawn } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { installedServiceSelection } from './service-selection.mjs';
|
|
7
|
+
import {installationChildOptions} from './installation-operation.mjs';
|
|
8
|
+
export async function activateSetup({dataDir, platform=process.platform,
|
|
9
|
+
available=()=>promisify(execFile)('systemctl',['--user','show-environment'],{timeout:10000}),
|
|
10
|
+
inspect=installedServiceSelection, write=console.log,
|
|
11
|
+
launch=()=>new Promise((resolve,reject)=>{
|
|
12
|
+
const child=spawn(process.execPath,[fileURLToPath(new URL('../scripts/autostart.mjs',import.meta.url)),'activate-setup'],{
|
|
13
|
+
...installationChildOptions({stdio:['inherit','inherit','inherit'],env:{...process.env,AGENTPAY_USER_DATA_DIR:dataDir}})
|
|
14
|
+
});
|
|
15
|
+
child.once('error',reject);child.once('close',code=>resolve(code ?? 1));
|
|
16
|
+
})}={}) {
|
|
17
|
+
let managed=platform==='linux';
|
|
18
|
+
if (managed) { try {await available();} catch {managed=false;} }
|
|
19
|
+
if (!managed) {
|
|
20
|
+
if (await inspect()) throw Error('Existing background service files were found, but the user service manager is unavailable. Restore it before switching installations.');
|
|
21
|
+
write('User service manager unavailable. Continuing with manual setup; login startup is not enabled.');
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
return launch();
|
|
25
|
+
}
|