Ns/simplify 1 (#2764)

* Rid devconsole

* lots of removals

* more commandservice
This commit is contained in:
ProgrammerIn-wonderland
2026-04-06 14:05:48 -04:00
committed by GitHub
parent b0bd8a12a7
commit 649db33763
33 changed files with 92 additions and 1351 deletions
-4
View File
@@ -87,7 +87,3 @@ See [events.md](./events.md)
## Definitions
See [definitions.md](./definitions.md)
## Bundled extensions
- [dev-console](./dev-console.md) Dev socket for running backend commands locally (opt-in via `DEVCONSOLE=1`).
@@ -1,21 +0,0 @@
# dev-console extension
The **dev-console** extension provides a **dev socket** so you can run backend commands on a local Puter instance (e.g. commands registered in [CommandService](../../../src/backend/src/services/CommandService.js)).
## Enabling
The extension is **opt-in**. Set the environment variable `DEVCONSOLE=1` when starting Puter. The `npm run dev` script already does this:
```bash
npm run dev
```
With `DEVCONSOLE=1`, the extension registers a `dev-socket` service that creates a UNIX socket and runs command lines through CommandService.
## Usage
See [Backend dev socket](../../../src/backend/doc/dev_socket.md) for how to connect (e.g. `rlwrap nc -U ./dev.sock`) and run commands like `help`, `logs:indent`, etc.
## Location
The extension lives in `extensions/dev-console/`. It only registers the dev-socket service when `DEVCONSOLE=1`; otherwise the extension loads but does nothing, so it does not affect default runs.
-4
View File
@@ -5,10 +5,6 @@
### Here
Documentation for extensions is [here](src/backend/doc/extensions/README.md).
### Bundled extensions
- **dev-console** (`extensions/dev-console/`) Dev socket for running backend commands locally. Opt-in via `DEVCONSOLE=1` (e.g. `npm run dev`). See [Backend dev socket](src/backend/doc/dev_socket.md).
### Not Here
Outdated documentation for extensions is [here](../doc/contributors/extensions/README.md).
-104
View File
@@ -1,104 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
const SOCKET_NAME = 'dev.sock';
const WELCOME = [
'Puter dev socket enter a command (e.g. help) and press Enter.',
'Close the connection with Ctrl+C or by typing exit.',
'',
].join('\n');
function getSocketDir () {
if ( process.env.PUTER_DEV_SOCKET_DIR ) {
return process.env.PUTER_DEV_SOCKET_DIR;
}
const volatileRuntime = path.join(process.cwd(), 'volatile', 'runtime');
if ( fs.existsSync(volatileRuntime) ) {
return volatileRuntime;
}
return process.cwd();
}
extension.on('init', async () => {
if ( process.env.DEVCONSOLE !== '1' ) {
return;
}
const commands = extension.import('service:commands');
const socketDir = getSocketDir();
const socketPath = path.join(socketDir, SOCKET_NAME);
try {
if ( fs.existsSync(socketPath) ) {
fs.unlinkSync(socketPath);
}
fs.mkdirSync(socketDir, { recursive: true });
} catch ( err ) {
console.warn('dev-socket: could not prepare socket path', socketPath, err.message);
return;
}
const server = net.createServer((socket) => {
socket.setEncoding('utf8');
socket.write(`${WELCOME }\n> `);
let buffer = '';
socket.on('data', (chunk) => {
buffer += chunk;
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? '';
for ( const line of lines ) {
const trimmed = line.trim();
if ( trimmed === '' ) continue;
if ( trimmed.toLowerCase() === 'exit' ) {
socket.end();
return;
}
const log = {
log: (msg) => {
socket.write(`${String(msg) }\n`);
},
error: (msg) => {
socket.write(`${String(msg) }\n`);
},
};
commands.executeRawCommand(trimmed, log).then(() => {
socket.write('> ');
}).catch((err) => {
log.error(err?.message ?? err);
socket.write('> ');
});
}
});
socket.on('end', () => {
});
socket.on('error', () => {
});
});
server.listen(socketPath, () => {
console.log('dev-socket: socket listening at', socketPath);
});
server.on('error', (err) => {
console.warn('dev-socket: socket error', err.message);
});
});
@@ -1,8 +0,0 @@
{
"name": "@heyputer/extension-dev-console",
"version": "1.0.0",
"description": "Dev socket for running backend commands locally (opt-in via DEVCONSOLE=1)",
"main": "main.js",
"type": "module",
"private": true
}
@@ -26,52 +26,27 @@ class CustomPuterService extends use.Service {
const svc_puterHomepage = this.services.get('puter-homepage');
svc_puterHomepage.register_script('/custom-gui/main.js');
}
['__on_install.routes'] (_, { app }) {
'__on_install.routes' (_, { app }) {
const require = this.require;
const express = require('express');
const path_ = require('path');
app.use('/custom-gui',
express.static(path.join(__dirname, 'gui')));
app.use(
'/custom-gui',
express.static(path.join(__dirname, 'gui')),
);
}
async ['__on_boot.consolidation'] () {
const then = Date.now();
this.tod_widget = () => {
const s = 5 - Math.floor((Date.now() - then) / 1000);
const lines = [
'\x1B[36;1mKDMOD ENABLED\x1B[0m' +
` (👁️ ${s}s)`,
];
// It would be super cool to be able to use this here
// surrounding_box('33;1', lines);
return lines;
};
const svc_devConsole = this.services.get('dev-console', { optional: true });
if ( ! svc_devConsole ) return;
svc_devConsole.add_widget(this.tod_widget);
setTimeout(() => {
svc_devConsole.remove_widget(this.tod_widget);
}, 5000);
}
_register_commands (commands) {
commands.registerCommands('o', [
{
id: 'k',
description: '',
handler: async (_, log) => {
const svc_devConsole = this.services.get('dev-console', { optional: true });
if ( ! svc_devConsole ) return;
svc_devConsole.remove_widget(this.tod_widget);
const lines = this.tod_widget();
for ( const line of lines ) log.log(line);
this.tod_widget = null;
log.log('kdmod is enabled');
},
},
]);
}
}
module.exports = { CustomPuterService };
module.exports = { CustomPuterService };
+1 -1
View File
@@ -50,7 +50,7 @@
"start=gui": "nodemon --exec \"node dev-server.js\" ",
"start": "node ./tools/run-selfhosted.js",
"prestart": "npm run build:ts",
"dev": "npm run build:ts && DEVCONSOLE=1 node ./tools/run-selfhosted.js",
"dev": "npm run build:ts && node ./tools/run-selfhosted.js",
"build": "npx eslint --quiet -c eslint/mandatory.eslint.config.js src/backend/src extensions && npm run build:ts && cd src/gui && node ./build.js",
"check-translations": "node tools/check-translations.js",
"prepare": "husky",
-32
View File
@@ -1,32 +0,0 @@
## Backend - dev socket
The "dev socket" allows you to interact with Puter's backend by running commands.
It's a UNIX socket that lets you run commands registered with
[CommandService](../../src/services/CommandService.js) (e.g. `help`, `logs:indent`, `params:get`, etc.).
### Enabling the dev socket
The dev socket is provided by the **dev-console extension** and is **opt-in**. To enable it:
1. Set the environment variable `DEVCONSOLE=1` when starting Puter (e.g. `npm run dev` already does this).
2. The extension lives in `extensions/dev-console/` and registers a `dev-socket` service when `DEVCONSOLE=1`.
### Socket location
The socket is created in a directory chosen as follows (in order):
- `PUTER_DEV_SOCKET_DIR` if set
- `./volatile/runtime` if it exists (typical local dev)
- otherwise the process current working directory
The socket file is named `dev.sock`.
### Connecting
When in that directory, connect with your tool of choice. For example, using `nc` and `rlwrap` for readline history:
```
rlwrap nc -U ./dev.sock
```
If it is successful you will see a message with instructions. Enter a command (e.g. `help`) and press Enter.
-4
View File
@@ -72,10 +72,6 @@ key-value store, and in-memory cache.
### Adding Features to Puter
- [Implementing Drivers](./pages/drivers.md)
### Bundled extensions
- **dev-console** When `DEVCONSOLE=1` is set (e.g. `npm run dev`), the dev-console extension registers a UNIX socket (`dev.sock`) so you can run backend commands (see [CommandService](../../src/services/CommandService.js)) from a terminal. See [Backend dev socket](../dev_socket.md).
## Extensions - Planned Features
Extensions are under refactor currently. This is the checklist:
+1 -5
View File
@@ -33,6 +33,7 @@ const { redisClient } = require('./clients/redis/redisSingleton');
const { kv } = require('./util/kvSingleton');
const { s3ClientProvider } = require('./clients/s3/s3ClientProvider');
const { PuterS3Service } = require('./deprecated/filesystem/PuterS3Service');
const BaseService = require('./services/BaseService');
/**
* @footgun - real install method is defined above
@@ -121,7 +122,6 @@ const install = async ({ context, services, app, useapi, modapi }) => {
// TODO: move these to top level imports or await imports and esm this file
const { CommandService } = require('./services/CommandService');
const { RateLimitService } = require('./services/sla/RateLimitService');
const { AuthService } = require('./services/auth/AuthService');
const { SLAService } = require('./services/sla/SLAService');
@@ -164,7 +164,6 @@ const install = async ({ context, services, app, useapi, modapi }) => {
services.registerService('dynamo', DDBClientWrapper);
services.registerService('system-validation', SystemValidationService);
services.registerService('commands', CommandService);
services.registerService('__api-filesystem', FilesystemAPIService);
services.registerService('__api', PuterAPIService);
services.registerService('__gui', ServeGUIService);
@@ -395,14 +394,11 @@ const install = async ({ context, services, app, useapi, modapi }) => {
const install_legacy = async ({ services }) => {
const { OperationTraceService } = require('./services/OperationTraceService');
const { ClientOperationService } = require('./services/ClientOperationService');
const { EngPortalService } = require('./services/EngPortalService');
// === Services which do not yet extend BaseService ===
// services.registerService('filesystem', FilesystemService);
services.registerService('operationTrace', OperationTraceService);
services.registerService('client-operation', ClientOperationService);
services.registerService('engineering-portal', EngPortalService);
};
/**
+7 -4
View File
@@ -16,14 +16,17 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const { AdvancedBase } = require('@heyputer/putility');
import { AdvancedBase } from '@heyputer/putility';
import { StrategizedService } from './services/StrategizedService.js';
import { SqliteDatabaseAccessService } from './services/database/SqliteDatabaseAccessService.js';
// import {BaseService} from './services/BaseService.js';
class DatabaseModule extends AdvancedBase {
async install (context) {
const services = context.get('services');
const { StrategizedService } = require('./services/StrategizedService');
const { SqliteDatabaseAccessService } = require('./services/database/SqliteDatabaseAccessService');
services.registerService('database', StrategizedService, {
strategy_key: 'engine',
strategies: {
@@ -33,4 +36,4 @@ class DatabaseModule extends AdvancedBase {
}
}
module.exports = DatabaseModule;
export default DatabaseModule;
-1
View File
@@ -213,7 +213,6 @@ class Kernel extends AdvancedBase {
deployment_type: globalThis.deployment_type,
});
})();
await services.emit('boot.activation');
await services.emit('boot.ready');
@@ -64,7 +64,6 @@ class AlarmService extends BaseService {
* the '_init' method of CommandService may not have been called yet.
*/
'__on_boot.consolidation' () {
this._register_commands(this.services.get('commands'));
}
adapt_id_ (id) {
@@ -361,137 +360,6 @@ class AlarmService extends BaseService {
get_alarm (id) {
return this.alarms[id] ?? this.alarm_aliases[id];
}
_register_commands (commands) {
// Function to handle a specific alarm event.
// This comment can be added above line 320.
// This function is responsible for processing specific events related to alarms.
// It can be used for tasks such as updating alarm status, sending notifications, or triggering actions.
// This function is called internally by the AlarmService class.
// /*
// * handleAlarmEvent - Handles a specific alarm event.
// *
// * @param {Object} alarm - The alarm object containing relevant information.
// * @param {Function} callback - Optional callback function to be called when the event is handled.
// */
// function handleAlarmEvent(alarm, callback) {
// // Implementation goes here.
// }
const completeAlarmID = (args) => {
// The alarm ID is the first argument, so return no results if we're on the second or later.
if ( args.length > 1 )
{
return;
}
const lastArg = args[args.length - 1];
const results = [];
for ( const alarm of Object.values(this.alarms) ) {
if ( alarm.id.startsWith(lastArg) ) {
results.push(alarm.id);
}
if ( alarm.short_id?.startsWith(lastArg) ) {
results.push(alarm.short_id);
}
}
return results;
};
commands.registerCommands('alarm', [
{
id: 'list',
description: 'list alarms',
handler: async (args, log) => {
for ( const alarm of Object.values(this.alarms) ) {
log.log(`${alarm.id_string}: ${alarm.message} (${alarm.count})`);
}
},
},
{
id: 'info',
description: 'show info about an alarm',
handler: async (args, log) => {
const [id] = args;
const alarm = this.get_alarm(id);
if ( ! alarm ) {
log.log(`no alarm with id ${id}`);
return;
}
log.log(`\x1B[33;1m${alarm.id_string}\x1B[0m :: ${alarm.message} (${alarm.count})`);
log.log(`started: ${new Date(alarm.started).toISOString()}`);
log.log(`short id: ${alarm.short_id}`);
log.log(`original id: ${alarm.id}`);
// print stack trace of alarm error
if ( alarm.error ) {
log.log(alarm.error.stack);
}
// print other fields
for ( const [key, value] of Object.entries(alarm.fields) ) {
log.log(`- ${key}: ${util.inspect(value)}`);
}
},
completer: completeAlarmID,
},
{
id: 'clear',
description: 'clear an alarm',
handler: async (args, log) => {
const [id] = args;
const alarm = this.get_alarm(id);
if ( ! alarm ) {
log.log(`no alarm with id ${id}; ` +
`but calling clear(${JSON.stringify(id)}) anyway.`);
}
this.clear(id);
},
completer: completeAlarmID,
},
{
id: 'clear-all',
description: 'clear all alarms',
handler: async (_args, _log) => {
const alarms = Object.values(this.alarms);
this.alarms = {};
for ( const alarm of alarms ) {
this.handle_alarm_off_(alarm);
}
},
},
{
id: 'sound',
description: 'sound an alarm',
handler: async (args, _log) => {
const [id, message] = args;
this.create(id ?? 'test', message, {});
},
},
{
id: 'inspect',
description: 'show logs that happened an alarm',
handler: async (args, log) => {
const [id, occurance_idx] = args;
const alarm = this.get_alarm(id);
if ( ! alarm ) {
log.log(`no alarm with id ${id}`);
return;
}
const occurance = alarm.occurrences[occurance_idx];
if ( ! occurance ) {
log.log(`no occurance with index ${occurance_idx}`);
return;
}
log.log(`┏━━ Logs before: ${alarm.id_string} ━━━━`);
for ( const lg of occurance.logs ) {
log.log(`${ this.logutil.stringify_log_entry(lg)}`);
}
log.log(`┗━━ Logs before: ${alarm.id_string} ━━━━`);
},
completer: completeAlarmID,
},
]);
}
}
module.exports = {
@@ -410,61 +410,6 @@ class LogService extends BaseService {
* Registers logging commands with the command service.
*/
'__on_boot.consolidation' () {
const commands = this.services.get('commands');
commands.registerCommands('logs', [
{
id: 'show',
description: 'toggle log output',
handler: async () => {
this.devlogger && (this.devlogger.off = !this.devlogger.off);
},
},
{
id: 'rec',
description: 'start recording to a file via dev logger',
handler: async (args, ctx) => {
const [name] = args;
const { log } = ctx;
if ( ! this.devlogger ) {
log('no dev logger; what are you doing?');
}
this.devlogger.recto = name;
},
},
{
id: 'stop',
description: 'stop recording to a file via dev logger',
handler: async ([_name], log) => {
if ( ! this.devlogger ) {
log('no dev logger; what are you doing?');
}
this.devlogger.recto = null;
},
},
{
id: 'indent',
description: 'toggle log indentation',
handler: async () => {
globalThis.dev_console_indent_on =
!globalThis.dev_console_indent_on;
},
},
{
id: 'get-level',
description: 'get the current log level for displayed logs',
handler: async (args, log) => {
log.log(`${display_log_level} (${display_log_level_label[display_log_level] ?? '?'})`);
},
},
{
id: 'set-level',
description: 'set the new log level for displayed logs',
handler: async (args, log) => {
display_log_level = Number(args[0]);
log.log(`${display_log_level} (${display_log_level_label[display_log_level] ?? '?'})`);
},
},
]);
}
/**
* Registers logging commands with the command service.
@@ -45,7 +45,6 @@ class PagerService extends BaseService {
* the '_init' method of CommandService may not have been called yet.
*/
'__on_boot.consolidation' () {
this._register_commands(this.services.get('commands'));
}
/**
@@ -139,24 +138,6 @@ class PagerService extends BaseService {
}
}
_register_commands (commands) {
commands.registerCommands('pager', [
{
id: 'test-alert',
description: 'create a test alert',
handler: async (args, log) => {
const [severity] = args;
await this.alert({
id: 'test-alert',
message: 'test alert',
source: 'test',
severity,
});
},
},
]);
}
}
module.exports = {
@@ -98,7 +98,6 @@ class ParameterService extends BaseService {
* @private
*/
'__on_boot.consolidation' () {
this._registerCommands(this.services.get('commands'));
}
createParameters (serviceName, parameters, opt_instance) {
@@ -109,9 +108,11 @@ class ParameterService extends BaseService {
id: `${serviceName}:${parameter.id}`,
}));
if ( opt_instance ) {
this.bindToInstance(`${serviceName}:${parameter.id}`,
opt_instance,
parameter.id);
this.bindToInstance(
`${serviceName}:${parameter.id}`,
opt_instance,
parameter.id,
);
}
}
}
@@ -144,71 +145,6 @@ class ParameterService extends BaseService {
}
return parameter;
}
/**
* Registers parameter-related commands with the command service
* @param {Object} commands - The command service instance to register with
*/
_registerCommands (commands) {
const completeParameterName = (args) => {
// The parameter name is the first argument, so return no results if we're on the second or later.
if ( args.length > 1 )
{
return;
}
const lastArg = args[args.length - 1];
return this.parameters_
.map(parameter => parameter.spec_.id)
.filter(parameterName => parameterName.startsWith(lastArg));
};
commands.registerCommands('params', [
{
id: 'get',
description: 'get a parameter',
handler: async (args, log) => {
const [name] = args;
const value = await this.get(name);
log.log(value);
},
completer: completeParameterName,
},
{
id: 'set',
description: 'set a parameter',
handler: async (args, log) => {
const [name, value] = args;
const parameter = this._get_param(name);
parameter.set(value);
log.log(value);
},
completer: completeParameterName,
},
{
id: 'list',
description: 'list parameters',
handler: async (args, log) => {
const [prefix] = args;
let parameters = this.parameters_;
if ( prefix ) {
parameters = parameters
.filter(p => p.spec_.id.startsWith(prefix));
}
log.log(`available parameters${
prefix ? ` (starting with: ${prefix})` : ''
}:`);
for ( const parameter of parameters ) {
// log.log(`- ${parameter.spec_.id}: ${parameter.spec_.description}`);
// Log parameter description and value
const value = await parameter.get();
log.log(`- ${parameter.spec_.id} = ${value}`);
log.log(` ${parameter.spec_.description}`);
}
},
},
]);
}
}
module.exports = {
@@ -23,19 +23,6 @@ class DomainVerificationService extends BaseService {
return await get_user({ username: 'admin' });
}
_register_commands (commands) {
const svc_commands = this.services.get('commands');
svc_commands.registerCommands('domain', [
{
id: 'user',
description: '',
handler: async (args, log) => {
const res = await this.get_controlling_user({ domain: args[0] });
log.log(res);
},
},
]);
}
}
module.exports = {
@@ -42,35 +42,6 @@ class SizeService extends BaseService {
}
'__on_boot.consolidate' () {
const svc_commands = this.services.get('commands');
svc_commands.registerCommands('size', [
{
id: 'get-usage',
description: 'get usage for a user',
handler: async (args, log) => {
const user = await UserParameter.adapt(args[0]);
const usage = await this.get_usage(user.id);
log.log(`usage: ${usage} bytes`);
},
},
{
id: 'get-capacity',
description: 'get storage capacity for a user',
handler: async (args, log) => {
const user = await UserParameter.adapt(args[0]);
const capacity = await this.get_storage_capacity(user);
log.log(`capacity: ${capacity} bytes`);
},
},
{
id: 'get-cache-size',
description: 'get the number of cached users',
handler: async (args, log) => {
const size = Object.keys(this.usages).length;
log.log(`cache size: ${size}`);
},
},
]);
}
async get_usage (user_id) {
@@ -36,7 +36,6 @@ const DEFAULT_FILES = {};
class DefaultUserService extends BaseService {
async _init () {
this._register_commands(this.services.get('commands'));
}
async '__on_ready.webserver' () {
// check if a user named `admin` exists
@@ -221,19 +220,6 @@ class DefaultUserService extends BaseService {
return tmp_password;
});
}
_register_commands (commands) {
commands.registerCommands('default-user', [
{
id: 'reset-password',
handler: async (args, ctx) => {
const [username] = args;
const user = await get_user({ username });
const tmp_pwd = await this.force_tmp_password_(user);
ctx.log(`New password for ${quot(username)} is: ${tmp_pwd}`);
},
},
]);
}
}
module.exports = DefaultUserService;
@@ -25,57 +25,6 @@ class SelfhostedService extends BaseService {
`;
async _init () {
this._register_commands(this.services.get('commands'));
}
_register_commands (commands) {
const db = this.services.get('database').get(DB_WRITE, 'selfhosted');
commands.registerCommands('app', [
{
id: 'godmode-on',
description: 'Toggle godmode for an app',
handler: async (args, _log) => {
const svc_su = this.services.get('su');
await await svc_su.sudo(async () => {
const [app_uid] = args;
const es_app = await this.services.get('es:app');
const app = await es_app.read(app_uid);
if ( ! app ) {
throw new Error(`App ${app_uid} not found`);
}
await db.write('UPDATE apps SET godmode = 1 WHERE uid = ?', [app_uid]);
const svc_event = this.services.get('event');
await svc_event.emit('app.changed', {
app_uid,
action: 'updated',
});
});
},
},
]);
commands.registerCommands('app', [
{
id: 'godmode-off',
description: 'Toggle godmode for an app',
handler: async (args, _log) => {
const svc_su = this.services.get('su');
await await svc_su.sudo(async () => {
const [app_uid] = args;
const es_app = await this.services.get('es:app');
const app = await es_app.read(app_uid);
if ( ! app ) {
throw new Error(`App ${app_uid} not found`);
}
await db.write('UPDATE apps SET godmode = 0 WHERE uid = ?', [app_uid]);
const svc_event = this.services.get('event');
await svc_event.emit('app.changed', {
app_uid,
action: 'updated',
});
});
},
},
]);
}
}
@@ -33,7 +33,6 @@ export class TestCoreModule {
services.registerService('su', SUService);
services.registerService('alarm', AlarmService);
services.registerService('event', EventService);
services.registerService('commands', CommandService);
services.registerService('meteringService', MeteringServiceWrapper);
services.registerService('puter-kvstore', DynamoKVStoreWrapper);
services.registerService('permission', PermissionService);
@@ -136,6 +136,7 @@ class WebServerService extends BaseService {
* @returns {Promise<void>} A promise that resolves once the server is started.
*/
async '__on_boot.activation' () {
console.log('starting webser');
const services = this.services;
await services.emit('start.webserver');
await services.emit('ready.webserver');
-204
View File
@@ -1,204 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const { Context } = require('../util/context');
const BaseService = require('./BaseService');
/**
* Represents a Command class that encapsulates command execution functionality.
* Each Command instance contains a specification (spec) that defines its ID,
* name, description, handler function, and optional argument completer.
* The class provides methods for executing commands and handling command
* argument completion.
*/
class Command {
constructor (spec) {
this.spec_ = spec;
}
/**
* Gets the unique identifier for this command
* @returns {string} The command's ID as specified in the constructor
*/
get id () {
return this.spec_.id;
}
/**
* Executes the command with given arguments and logging
* @param {Array} args - Command arguments to pass to the handler
* @param {Object} [log=console] - Logger object for output, defaults to console
* @returns {Promise<void>}
* @throws {Error} Logs any errors that occur during command execution
*/
async execute (args, log) {
log = log ?? console;
const { id, name, description, handler } = this.spec_;
try {
await handler(args, log);
} catch ( err ) {
log.error(`command ${name ?? id} failed: ${err.message}`);
log.error(err.stack);
}
}
completeArgument (args) {
const completer = this.spec_.completer;
if ( completer )
{
return completer(args);
}
return [];
}
}
/**
* CommandService class manages the registration, execution, and handling of commands in the Puter system.
* Extends BaseService to provide command-line interface functionality. Maintains a collection of Command
* objects, supports command registration with namespaces, command execution with arguments, and provides
* command lookup capabilities. Includes built-in help command functionality.
* @extends BaseService
*/
class CommandService extends BaseService {
/**
* Initializes the command service's internal state
* Called during service construction to set up the empty commands array
*/
async _construct () {
this.commands_ = [];
}
/**
* Add the help command to the list of commands on init
*/
async _init () {
this.commands_.push(new Command({
id: 'help',
description: 'show this help',
handler: (args, log) => {
log.log('available commands:');
for ( const command of this.commands_ ) {
log.log(`- ${command.spec_.id}: ${command.spec_.description}`);
}
},
}));
}
async '__on_boot.consolidation' () {
const svc_event = this.services.get('event');
const svc_command = this;
const event = {
createCommand (name, command) {
const serviceName = Context.get('extension_name') ?? '%missing%';
const commandSpec = typeof command === 'function'
? { handler: command }
: command;
if ( typeof commandSpec !== 'object' ) {
throw new Error('command must be either a function or an object');
}
if ( ! (typeof command.handler === 'function') ) {
throw new Error('command should have a handler function');
}
svc_command.registerCommands(serviceName, [{
id: name,
...commandSpec,
}]);
},
};
svc_event.emit('create.commands', event);
}
registerCommands (serviceName, commands) {
if ( ! this.log ) {
/* eslint-disable */
console.error(
'CommandService.registerCommands was called before a logger ' +
'was initialied. This happens when calling registerCommands ' +
'in the "construct" phase instead of the "init" phase. If ' +
'you are migrating a legacy service that does not extend ' +
'BaseService, maybe the _construct hook is calling init()'
);
/* eslint-enable */
process.exit(1);
}
for ( const command of commands ) {
this.log.debug(`registering command ${serviceName}:${command.id}`);
this.commands_.push(new Command({
...command,
id: `${serviceName}:${command.id}`,
}));
}
}
/**
* Executes a command with the given arguments and logging context
* @param {string[]} args - Array of command arguments where first element is command name
* @param {Object} log - Logger object for output (defaults to console if not provided)
* @returns {Promise<void>}
* @throws {Error} If command execution fails
*/
async executeCommand (args, log) {
const [commandName, ...commandArgs] = args;
const command = this.commands_.find(c => c.spec_.id === commandName);
if ( ! command ) {
log.error(`unknown command: ${commandName}`);
return;
}
/**
* Executes a command with the given arguments in a global context
* @param {string[]} args - Array of command arguments where first element is command name
* @param {Object} log - Logger object for output
* @returns {Promise<void>}
* @throws {Error} If command execution fails
*/
await globalThis.root_context.sub({
injected_logger: log,
}).arun(async () => {
await command.execute(commandArgs, log);
});
}
/**
* Executes a raw command string by splitting it into arguments and executing the command
* @param {string} text - Raw command string to execute
* @param {object} log - Logger object for output (defaults to console if not provided)
* @returns {Promise<void>}
* @todo Replace basic whitespace splitting with proper tokenizer (obvious-json)
*/
async executeRawCommand (text, log) {
// TODO: add obvious-json as a tokenizer
const args = text.split(/\s+/);
await this.executeCommand(args, log);
}
/**
* Gets a list of all registered command names/IDs
* @returns {string[]} Array of command identifier strings
*/
get commandNames () {
return this.commands_.map(command => command.id);
}
getCommand (id) {
return this.commands_.find(command => command.id === id);
}
}
module.exports = {
CommandService,
};
@@ -1,120 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { createTestKernel } from '../../tools/test.mjs';
import { CommandService } from './CommandService';
describe('CommandService', async () => {
const testKernel = await createTestKernel({
serviceMap: {
commands: CommandService,
},
initLevelString: 'init',
});
const commandService = testKernel.services!.get('commands') as CommandService;
it('should be instantiated', () => {
expect(commandService).toBeInstanceOf(CommandService);
});
it('should have help command registered by default', () => {
expect(commandService.commandNames).toContain('help');
});
it('should register commands', () => {
commandService.registerCommands('test-service', [
{
id: 'test-cmd',
description: 'A test command',
handler: async () => {},
},
]);
expect(commandService.commandNames).toContain('test-service:test-cmd');
});
it('should execute registered commands', async () => {
let executed = false;
commandService.registerCommands('exec-test', [
{
id: 'exec-cmd',
description: 'Execute test',
handler: async () => { executed = true; },
},
]);
const mockLog = { error: vi.fn(), log: vi.fn() };
await commandService.executeCommand(['exec-test:exec-cmd'], mockLog);
expect(executed).toBe(true);
});
it('should pass arguments to command handler', async () => {
let receivedArgs: string[] = [];
commandService.registerCommands('args-test', [
{
id: 'args-cmd',
description: 'Args test',
handler: async (args) => { receivedArgs = args; },
},
]);
const mockLog = { error: vi.fn(), log: vi.fn() };
await commandService.executeCommand(['args-test:args-cmd', 'arg1', 'arg2'], mockLog);
expect(receivedArgs).toEqual(['arg1', 'arg2']);
});
it('should handle unknown commands', async () => {
const mockLog = { error: vi.fn(), log: vi.fn() };
await commandService.executeCommand(['unknown-command'], mockLog);
expect(mockLog.error).toHaveBeenCalledWith('unknown command: unknown-command');
});
it('should execute raw commands', async () => {
let executed = false;
commandService.registerCommands('raw-test', [
{
id: 'raw-cmd',
description: 'Raw test',
handler: async () => { executed = true; },
},
]);
const mockLog = { error: vi.fn(), log: vi.fn() };
await commandService.executeRawCommand('raw-test:raw-cmd', mockLog);
expect(executed).toBe(true);
});
it('should get command by id', () => {
commandService.registerCommands('get-test', [
{
id: 'get-cmd',
description: 'Get test',
handler: async () => {},
},
]);
const cmd = commandService.getCommand('get-test:get-cmd');
expect(cmd).toBeDefined();
expect(cmd?.id).toBe('get-test:get-cmd');
});
it('should execute help command', async () => {
const mockLog = { error: vi.fn(), log: vi.fn() };
await commandService.executeCommand(['help'], mockLog);
expect(mockLog.log).toHaveBeenCalledWith('available commands:');
});
it('should support command completers', () => {
commandService.registerCommands('complete-test', [
{
id: 'complete-cmd',
description: 'Complete test',
handler: async () => {},
completer: (args) => ['option1', 'option2'],
},
]);
const cmd = commandService.getCommand('complete-test:complete-cmd');
const completions = cmd?.completeArgument([]);
expect(completions).toEqual(['option1', 'option2']);
});
});
+11 -1
View File
@@ -211,7 +211,17 @@ class Container {
for ( const k in this.instances_ ) {
try {
if ( PARALLEL ) promises.push(this.instances_[k].init());
else await this.instances_[k].init();
else {
// Logic to get name of a service, unused but
// if you ever need to log the name
// this will be accurate
let name = this.instances_[k].constructor.name;
if ( name === 'ExtensionService' ) {
name = this.instances_[k].args.state.extension.runtime.name;
}
await this.instances_[k].init();
}
} catch (e) {
init_failures.push({ k, e });
}
@@ -1,162 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const { AdvancedBase } = require('@heyputer/putility');
/**
* @class EngPortalService
* @extends {AdvancedBase}
*
* EngPortalService is a class that provides services for managing and accessing various operations, alarms, and statistics
* within a system. It inherits from the AdvancedBase class and utilizes multiple dependencies such as socket.io for communication
* and uuidv4 for generating unique identifiers. The class includes methods for listing operations, serializing frames, listing alarms,
* fetching server statistics, and registering command handlers. This class is integral to maintaining and monitoring system health
* and operations efficiently.
*/
class EngPortalService extends AdvancedBase {
static MODULES = {
uuidv4: require('uuid').v4,
};
constructor ({ services }) {
super();
this.services = services;
this.commands = services.get('commands');
this._registerCommands(this.commands);
}
/**
* Lists all ongoing operations.
* This method retrieves all ongoing operations from the 'operationTrace' service,
* serializes them, and returns the serialized list.
*
* @async
* @returns {Promise<Array>} A list of serialized operation frames.
*/
async list_operations () {
const svc_operationTrace = this.services.get('operationTrace');
const ls = [];
for ( const id in svc_operationTrace.ongoing ) {
const op = svc_operationTrace.ongoing[id];
ls.push(this._serialize_frame(op));
}
return ls;
}
_serialize_frame (frame) {
const out = {
id: frame.id,
label: frame.label,
status: frame.status,
async: frame.async,
checkpoint: frame.checkpoint,
// tags: frame.tags,
// attributes: frame.attributes,
// messages: frame.messages,
// error: frame.error_ ? frame.error_.message || true : null,
children: [],
attributes: {},
};
for ( const k in frame.attributes ) {
out.attributes[k] = frame.attributes[k];
}
for ( const child of frame.children ) {
out.children.push(this._serialize_frame(child));
}
return out;
}
/**
* Retrieves a list of alarms.
*
* This method fetches all active alarms from the 'alarm' service and returns a serialized array of alarm objects.
*
* @returns {Promise<Array>} A promise that resolves to an array of serialized alarm objects.
*/
async list_alarms () {
const svc_alarm = this.services.get('alarm');
const ls = [];
for ( const id in svc_alarm.alarms ) {
const alarm = svc_alarm.alarms[id];
ls.push(this._serialize_alarm(alarm));
}
return ls;
}
/**
* Gets the system statistics.
*
* This method retrieves the system statistics from the server-health service and returns them.
*
* @async
* @returns {Promise<Object>} A promise that resolves to the system statistics.
*/
async get_stats () {
const svc_health = this.services.get('server-health');
return await svc_health.get_stats();
}
_serialize_alarm (alarm) {
const out = {
id: alarm.id,
short_id: alarm.short_id,
started: alarm.started,
occurrances: alarm.occurrences.map(this._serialize_occurance.bind(this)),
...(alarm.error ? {
error: {
message: alarm.error.message,
stack: alarm.error.stack,
},
} : {}),
};
return out;
}
_serialize_occurance (occurance) {
const out = {
message: occurance.message,
timestamp: occurance.timestamp,
fields: occurance.fields,
};
return out;
}
_registerCommands (commands) {
this.commands.registerCommands('eng', [
{
id: 'list-operations',
description: 'testing',
handler: async (args, log) => {
const ops = await this.list_operations();
log.log(JSON.stringify(ops, null, 2));
},
},
]);
}
}
module.exports = {
EngPortalService,
};
-33
View File
@@ -45,39 +45,6 @@ class LockService extends BaseService {
* initialization is complete.
*/
async _init () {
const svc_commands = this.services.get('commands');
svc_commands.registerCommands('lock', [
{
id: 'locks',
description: 'lists locks',
handler: async (args, log) => {
for ( const name in this.locks ) {
let line = `${name }: `;
if ( this.locks[name].effective_mode === RWLock.TYPE_READ ) {
line += `READING (${this.locks[name].readers_})`;
log.log(line);
}
else
if ( this.locks[name].effective_mode === RWLock.TYPE_WRITE ) {
line += 'WRITING';
log.log(line);
}
else {
line += 'UNKNOWN';
log.log(line);
// log the lock's internal state
const lines = JSON.stringify(this.locks[name],
null,
2).split('\n');
for ( const line of lines ) {
log.log(` -> ${ line}`);
}
}
}
},
},
]);
}
/**
-27
View File
@@ -70,34 +70,7 @@ class ScriptService extends BaseService {
* @returns {Promise<void>} A promise that resolves when the initialization is complete.
*/
async _init () {
const svc_commands = this.services.get('commands');
svc_commands.registerCommands('script', [
{
id: 'run',
description: 'run a script',
handler: async (args, ctx) => {
const script_name = args.shift();
const script = this.scripts.find(s => s.name === script_name);
if ( ! script ) {
ctx.error(`script not found: ${script_name}`);
return;
}
await script.run(ctx, args);
},
completer: (args) => {
// The script name is the first argument, so return no results if we're on the second or later.
if ( args.length > 1 )
{
return;
}
const scriptName = args[args.length - 1];
return this.scripts
.filter(script => scriptName.startsWith(scriptName))
.map(script => script.name);
},
},
]);
}
register (name, fn) {
@@ -158,47 +158,48 @@ export class OpenRouterProvider implements IChatProvider {
}
async models () {
let models = kv.get('openrouterChat:models');
if ( ! models ) {
try {
const resp = await axios.request({
method: 'GET',
url: `${this.#apiBaseUrl}/models`,
});
return [] as IChatModel[];
// let models = kv.get('openrouterChat:models');
// if ( ! models ) {
// try {
// const resp = await axios.request({
// method: 'GET',
// url: `${this.#apiBaseUrl}/models`,
// });
models = resp.data.data;
kv.set('openrouterChat:models', models);
} catch (e) {
console.log(e);
}
}
const coerced_models: IChatModel[] = [];
for ( const model of models ) {
if ( (model.id as string).includes('openrouter/auto') ) {
continue;
}
const overridenModel = OPEN_ROUTER_MODEL_OVERRIDES.find(m => m.id === `openrouter:${model.id}`);
const microcentCosts = Object.fromEntries(Object.entries(model.pricing).map(([k, v]) => [k, Math.round((v as number < 0 ? 1 : v as number) * 1_000_000 * 100)])) ;
if ( ! microcentCosts.request ) {
microcentCosts.request = 0;
}
coerced_models.push({
id: `openrouter:${model.id}`,
name: `${model.name} (OpenRouter)`,
aliases: [model.id, model.name, `openrouter/${model.id}`, model.id.split('/').slice(1).join('/')],
context: model.context_length,
max_tokens: model.top_provider.max_completion_tokens,
costs_currency: 'usd-cents',
input_cost_key: 'prompt',
output_cost_key: 'completion',
costs: {
tokens: 1_000_000,
...microcentCosts,
},
...overridenModel,
});
}
return coerced_models;
// models = resp.data.data;
// kv.set('openrouterChat:models', models);
// } catch (e) {
// console.log(e);
// }
// }
// const coerced_models: IChatModel[] = [];
// for ( const model of models ) {
// if ( (model.id as string).includes('openrouter/auto') ) {
// continue;
// }
// const overridenModel = OPEN_ROUTER_MODEL_OVERRIDES.find(m => m.id === `openrouter:${model.id}`);
// const microcentCosts = Object.fromEntries(Object.entries(model.pricing).map(([k, v]) => [k, Math.round((v as number < 0 ? 1 : v as number) * 1_000_000 * 100)])) ;
// if ( ! microcentCosts.request ) {
// microcentCosts.request = 0;
// }
// coerced_models.push({
// id: `openrouter:${model.id}`,
// name: `${model.name} (OpenRouter)`,
// aliases: [model.id, model.name, `openrouter/${model.id}`, model.id.split('/').slice(1).join('/')],
// context: model.context_length,
// max_tokens: model.top_provider.max_completion_tokens,
// costs_currency: 'usd-cents',
// input_cost_key: 'prompt',
// output_cost_key: 'completion',
// costs: {
// tokens: 1_000_000,
// ...microcentCosts,
// },
// ...overridenModel,
// });
// }
// return coerced_models;
}
checkModeration (_text: string): ReturnType<IChatProvider['checkModeration']> {
throw new Error('Method not implemented.');
@@ -71,7 +71,6 @@ class PermissionService extends BaseService {
*/
this.kvService = this.services.get('puter-kvstore').as('puter-kvstore');
this.db = this.services.get('database').get(DB_WRITE, 'permissions');
this._register_commands(this.services.get('commands'));
this.kvAvgTimes = { count: 0, avg: 0, max: 0 };
this.dbAvgTimes = { count: 0, avg: 0, max: 0 };
}
@@ -1272,62 +1271,6 @@ class PermissionService extends BaseService {
this._permission_exploders.push(exploder);
}
_register_commands (commands) {
commands.registerCommands('perms', [
{
id: 'grant-user-app',
handler: async (args, _log) => {
const [username, app_uid, permission, extra] = args;
// actor from username
const actor = new Actor({
type: new UserActorType({
user: await get_user({ username }),
}),
});
await this.grant_user_app_permission(actor, app_uid, permission, extra);
},
},
{
id: 'scan',
handler: async (args, ctx) => {
const [username, permission] = args;
// actor from username
const actor = new Actor({
type: new UserActorType({
user: await get_user({ username }),
}),
});
let reading = await this.scan(actor, permission);
// reading = PermissionUtil.reading_to_options(reading);
ctx.log(JSON.stringify(reading, undefined, ' '));
},
},
{
id: 'scan-app',
handler: async (args, ctx) => {
const [username, app_name, permission] = args;
const app = await get_app({ name: app_name });
// actor from username
const actor = new Actor({
type: new AppUnderUserActorType({
app,
user: await get_user({ username }),
}),
});
const reading = await this.scan(actor, permission);
// reading = PermissionUtil.reading_to_options(reading);
ctx.log(JSON.stringify(reading, undefined, ' '));
},
},
]);
}
}
module.exports = {
@@ -270,7 +270,6 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService {
}
async '__on_boot.consolidation' () {
this._register_commands(this.services.get('commands'));
}
/**
@@ -372,37 +371,6 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService {
await vm.runInContext(contents, context);
}
_register_commands (commands) {
commands.registerCommands('sqlite', [
{
id: 'execfile',
description: 'execute a file',
handler: async (args, log) => {
try {
const [filename] = args;
const fs = require('fs');
const contents = fs.readFileSync(filename, 'utf8');
this.db.exec(contents);
} catch ( err ) {
log.error(err.message);
}
},
},
{
id: 'read',
description: 'read a query',
handler: async (args, log) => {
try {
const [query] = args;
const rows = this._read(query, []);
log.log(rows);
} catch ( err ) {
log.error(err.message);
}
},
},
]);
}
}
module.exports = {
@@ -44,39 +44,6 @@ class FSLockService extends BaseService {
* @returns {Promise<void>} A promise that resolves when the initialization is complete.
*/
async _init () {
const svc_commands = this.services.get('commands');
svc_commands.registerCommands('fslock', [
{
id: 'locks',
description: 'lists locks',
handler: async (args, log) => {
for ( const path in this.locks ) {
let line = `${path }: `;
if ( this.locks[path].effective_mode === MODE_READ ) {
line += `READING (${this.locks[path].readers_})`;
log.log(line);
}
else
if ( this.locks[path].effective_mode === MODE_WRITE ) {
line += 'WRITING';
log.log(line);
}
else {
line += 'UNKNOWN';
log.log(line);
// log the lock's internal state
const lines = JSON.stringify(this.locks[path],
null,
2).split('\n');
for ( const line of lines ) {
log.log(` -> ${ line}`);
}
}
}
},
},
]);
}
/**
@@ -104,7 +104,8 @@ class Mig_StorePath extends Job {
for ( ;; ) {
const t_0 = performance.now();
const [fsentries] = await dbrr.promise().execute(
'SELECT id, uuid FROM fsentries WHERE path IS NULL ORDER BY accessed DESC LIMIT 50');
'SELECT id, uuid FROM fsentries WHERE path IS NULL ORDER BY accessed DESC LIMIT 50',
);
if ( fsentries.length === 0 ) {
log.info('No more fsentries to migrate');
@@ -127,8 +128,9 @@ class Mig_StorePath extends Job {
log.info(`id=${fsentry.id} uuid=${fsentry.uuid} path=${path}`);
}
await dbrw.promise().execute(
'UPDATE fsentries SET path=? WHERE id=?',
[path, fsentry.id]);
'UPDATE fsentries SET path=? WHERE id=?',
[path, fsentry.id],
);
}
const t_1 = performance.now();
@@ -173,7 +175,8 @@ class Mig_IndexAccessed extends Job {
log.info('Running update statement');
const t_0 = performance.now();
const [results] = await dbrr.promise().execute(
'UPDATE fsentries SET accessed = COALESCE(accessed, created) WHERE accessed IS NULL LIMIT 10000');
'UPDATE fsentries SET accessed = COALESCE(accessed, created) WHERE accessed IS NULL LIMIT 10000',
);
log.info(`Updated ${results.affectedRows} rows`);
if ( results.affectedRows === 0 ) {
@@ -369,43 +372,19 @@ class Mig_AuditInitialStorage extends Job {
* independently.
*/
class FSEntryMigrateService {
constructor ({ services }) {
const mysql = services.get('mysql');
const dbrr = mysql.get(DB_MODE_READ, 'fsentry-migrate');
const dbrw = mysql.get(DB_MODE_WRITE, 'fsentry-migrate');
const log = services.get('log-service').create('fsentry-migrate');
constructor (_) {
// const mysql = services.get('mysql');
// const dbrr = mysql.get(DB_MODE_READ, 'fsentry-migrate');
// const dbrw = mysql.get(DB_MODE_WRITE, 'fsentry-migrate');
// const log = services.get('log-service').create('fsentry-migrate');
const migrations = {
'store-path': new Mig_StorePath({ dbrr, dbrw, log }),
'index-accessed': new Mig_IndexAccessed({ dbrr, dbrw, log }),
'fix-trash': new Mig_FixTrash({ dbrr, dbrw, log }),
'gen-referral-codes': new Mig_AddReferralCodes({ dbrr, dbrw, log }),
};
// const migrations = {
// 'store-path': new Mig_StorePath({ dbrr, dbrw, log }),
// 'index-accessed': new Mig_IndexAccessed({ dbrr, dbrw, log }),
// 'fix-trash': new Mig_FixTrash({ dbrr, dbrw, log }),
// 'gen-referral-codes': new Mig_AddReferralCodes({ dbrr, dbrw, log }),
// };
services.get('commands').registerCommands('fsentry-migrate', [
{
id: 'start',
description: 'start a migration',
handler: async (args, log) => {
const [migration] = args;
if ( ! migrations[migration] ) {
throw new Error(`unknown migration: ${migration}`);
}
migrations[migration].start(args.slice(1));
},
},
{
id: 'stop',
description: 'stop a migration',
handler: async (args, log) => {
const [migration] = args;
if ( ! migrations[migration] ) {
throw new Error(`unknown migration: ${migration}`);
}
migrations[migration].stop();
},
},
]);
}
}