shutro-erp 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +58 -58
  2. package/dist/account_portal/.kosh/bindings.js +1 -1
  3. package/dist/account_portal/.kosh/schema.d.ts +31 -1
  4. package/dist/account_portal/index.js +3 -2
  5. package/dist/accounting/.kosh/bindings.js +1 -1
  6. package/dist/accounting/.kosh/schema.d.ts +31 -1
  7. package/dist/accounting/index.js +4 -0
  8. package/dist/assistant/routes.js +1 -1
  9. package/dist/automation/engine.js +1 -1
  10. package/dist/automation/web/messages.js +2 -2
  11. package/dist/cli/apps.d.ts +1 -1
  12. package/dist/cli/apps.js +14 -14
  13. package/dist/cli/bin.js +3 -0
  14. package/dist/cli/index.js +11 -11
  15. package/dist/cli/init.d.ts +1 -1
  16. package/dist/cli/init.js +60 -36
  17. package/dist/cli/launch.js +10 -7
  18. package/dist/cli/project.d.ts +7 -7
  19. package/dist/cli/project.js +25 -25
  20. package/dist/cli/prompt.js +2 -2
  21. package/dist/cli/templates.d.ts +3 -3
  22. package/dist/cli/templates.js +33 -28
  23. package/dist/cli/ui.d.ts +1 -8
  24. package/dist/cli/ui.js +36 -12
  25. package/dist/contacts/.kosh/bindings.js +1 -1
  26. package/dist/contacts/.kosh/schema.d.ts +31 -1
  27. package/dist/integrations/inbound.d.ts +36 -0
  28. package/dist/integrations/inbound.js +171 -0
  29. package/dist/integrations/index.d.ts +18 -0
  30. package/dist/integrations/index.js +400 -0
  31. package/dist/integrations/models.d.ts +6 -0
  32. package/dist/integrations/models.js +119 -0
  33. package/dist/integrations/web/DeliveryTools.d.ts +4 -0
  34. package/dist/integrations/web/DeliveryTools.js +15 -0
  35. package/dist/integrations/web/EndpointTools.d.ts +7 -0
  36. package/dist/integrations/web/EndpointTools.js +25 -0
  37. package/dist/integrations/web/InboundTools.d.ts +4 -0
  38. package/dist/integrations/web/InboundTools.js +15 -0
  39. package/dist/integrations/web/WebhookTools.d.ts +7 -0
  40. package/dist/integrations/web/WebhookTools.js +25 -0
  41. package/dist/integrations/web/index.d.ts +16 -0
  42. package/dist/integrations/web/index.js +27 -0
  43. package/dist/integrations/web/messages.d.ts +2 -0
  44. package/dist/integrations/web/messages.js +53 -0
  45. package/dist/integrations/webhooks.d.ts +63 -0
  46. package/dist/integrations/webhooks.js +217 -0
  47. package/dist/l10n_bd/.kosh/bindings.js +1 -1
  48. package/dist/l10n_bd/.kosh/schema.d.ts +31 -1
  49. package/dist/l10n_in/.kosh/bindings.js +1 -1
  50. package/dist/l10n_in/.kosh/schema.d.ts +31 -1
  51. package/dist/l10n_in/einvoice.js +1 -1
  52. package/dist/l10n_in/web/messages.js +2 -2
  53. package/dist/mail/index.js +2 -2
  54. package/dist/modules-sdk/index.d.ts +1 -1
  55. package/dist/modules-sdk/loader.js +2 -2
  56. package/dist/modules-sdk/manifest.d.ts +48 -0
  57. package/dist/notify/messages.js +3 -3
  58. package/dist/notify/routes.js +1 -1
  59. package/dist/notify/web/messages.js +1 -1
  60. package/dist/payment/.kosh/bindings.d.ts +22 -0
  61. package/dist/payment/.kosh/bindings.js +15 -0
  62. package/dist/payment/.kosh/schema.d.ts +1553 -0
  63. package/dist/payment/index.d.ts +34 -0
  64. package/dist/payment/index.js +375 -0
  65. package/dist/payment/module.d.ts +6853 -0
  66. package/dist/payment/module.js +74 -0
  67. package/dist/payment/providers.d.ts +64 -0
  68. package/dist/payment/providers.js +77 -0
  69. package/dist/payment/settle.d.ts +11 -0
  70. package/dist/payment/settle.js +59 -0
  71. package/dist/payment/sslcommerz.d.ts +12 -0
  72. package/dist/payment/sslcommerz.js +185 -0
  73. package/dist/portal/.kosh/bindings.js +1 -1
  74. package/dist/portal/.kosh/schema.d.ts +31 -1
  75. package/dist/portal/routes-documents.js +5 -0
  76. package/dist/product/.kosh/bindings.js +1 -1
  77. package/dist/product/.kosh/schema.d.ts +31 -1
  78. package/dist/project/.kosh/bindings.js +1 -1
  79. package/dist/project/.kosh/schema.d.ts +31 -1
  80. package/dist/project_portal/.kosh/bindings.js +1 -1
  81. package/dist/project_portal/.kosh/schema.d.ts +31 -1
  82. package/dist/purchase/.kosh/bindings.js +1 -1
  83. package/dist/purchase/.kosh/schema.d.ts +31 -1
  84. package/dist/purchase_account/.kosh/bindings.js +1 -1
  85. package/dist/purchase_account/.kosh/schema.d.ts +31 -1
  86. package/dist/purchase_portal/.kosh/bindings.js +1 -1
  87. package/dist/purchase_portal/.kosh/schema.d.ts +31 -1
  88. package/dist/sale_account/.kosh/bindings.js +1 -1
  89. package/dist/sale_account/.kosh/schema.d.ts +31 -1
  90. package/dist/sale_portal/.kosh/bindings.js +1 -1
  91. package/dist/sale_portal/.kosh/schema.d.ts +31 -1
  92. package/dist/sale_project/.kosh/bindings.js +1 -1
  93. package/dist/sale_project/.kosh/schema.d.ts +31 -1
  94. package/dist/sales/.kosh/bindings.js +1 -1
  95. package/dist/sales/.kosh/schema.d.ts +31 -1
  96. package/dist/sales/index.js +2 -0
  97. package/dist/server/api/client.js +1 -1
  98. package/dist/server/api/openapi.js +17 -1
  99. package/dist/server/api/rest.d.ts +6 -1
  100. package/dist/server/api/rest.js +11 -0
  101. package/dist/server/apps.js +5 -5
  102. package/dist/server/auth/api-keys.d.ts +13 -0
  103. package/dist/server/auth/api-keys.js +65 -0
  104. package/dist/server/auth/base.js +24 -0
  105. package/dist/server/auth/crypto.js +1 -1
  106. package/dist/server/auth/scopes.d.ts +12 -0
  107. package/dist/server/auth/scopes.js +51 -0
  108. package/dist/server/auth/service.d.ts +57 -1
  109. package/dist/server/auth/service.js +229 -5
  110. package/dist/server/demo.d.ts +1 -1
  111. package/dist/server/demo.js +1 -1
  112. package/dist/server/env-names.d.ts +2 -0
  113. package/dist/server/env-names.js +16 -0
  114. package/dist/server/host.js +5 -1
  115. package/dist/server/identity.d.ts +7 -0
  116. package/dist/server/index.d.ts +2 -1
  117. package/dist/server/index.js +2 -1
  118. package/dist/server/registry.js +1 -1
  119. package/dist/server/server.d.ts +5 -0
  120. package/dist/server/server.js +45 -5
  121. package/dist/settings/routes.js +1 -1
  122. package/dist/settings/web/ApiKeysPage.d.ts +8 -0
  123. package/dist/settings/web/ApiKeysPage.js +70 -0
  124. package/dist/settings/web/index.d.ts +1 -0
  125. package/dist/settings/web/index.js +4 -0
  126. package/dist/settings/web/messages.js +62 -0
  127. package/dist/storage/routes.js +1 -1
  128. package/dist/studio/build.js +1 -1
  129. package/dist/studio/generate.js +4 -4
  130. package/dist/studio/module.js +5 -5
  131. package/dist/studio/web/messages.js +2 -2
  132. package/dist/ui/app.js +26 -3
  133. package/dist/ui/i18n/messages.js +2 -2
  134. package/dist/ui/pwa/worker.js +2 -2
  135. package/dist/ui/registry.d.ts +15 -0
  136. package/dist/ui/registry.js +20 -0
  137. package/package.json +15 -3
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The starter project `kosh init` writes: an API server and the back-office app, both importing `shutro-erp`.
2
+ * The starter project `shutro-erp init` writes: an API server and the back-office app, both importing `shutro-erp`.
3
3
  * Server files run on Node.js directly (type stripping); the back-office is a Vite app proxying `/api` to the server.
4
4
  */
5
5
  export type AppName = 'permissions' | 'reporting' | 'messaging' | 'contacts' | 'product' | 'sales' | 'purchase' | 'accounting' | 'sale_account' | 'purchase_account' | 'project' | 'sale_project' | 'l10n_bd' | 'l10n_in';
@@ -16,7 +16,7 @@ export interface AppSpec {
16
16
  /** A localization (RFC 0016): installed, with its bridged apps, for a company in this country (ISO 3166). */
17
17
  readonly country?: string;
18
18
  }
19
- /** The apps `kosh init` can install (base and Settings are always there), in the order the installer lists them. */
19
+ /** The apps `shutro-erp init` can install (base and Settings are always there), in the order the installer lists them. */
20
20
  export declare const APPS: Readonly<Record<AppName, AppSpec>>;
21
21
  export interface StarterOptions {
22
22
  readonly name: string;
@@ -27,7 +27,7 @@ export interface StarterOptions {
27
27
  readonly koshVersion: string;
28
28
  /** The database name of the Docker PostgreSQL service (compose.yaml). */
29
29
  readonly dockerDb?: string;
30
- /** Attachments in a folder (KOSH_STORAGE_DIR) rather than S3. */
30
+ /** Attachments in a folder (SHUTRO_ERP_STORAGE_DIR) rather than S3. */
31
31
  readonly fileStorage?: boolean;
32
32
  }
33
33
  /** The project's package.json (merged into an existing one: missing scripts and dependencies are added). */
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The starter project `kosh init` writes: an API server and the back-office app, both importing `shutro-erp`.
2
+ * The starter project `shutro-erp init` writes: an API server and the back-office app, both importing `shutro-erp`.
3
3
  * Server files run on Node.js directly (type stripping); the back-office is a Vite app proxying `/api` to the server.
4
4
  */
5
- /** The apps `kosh init` can install (base and Settings are always there), in the order the installer lists them. */
5
+ /** The apps `shutro-erp init` can install (base and Settings are always there), in the order the installer lists them. */
6
6
  export const APPS = {
7
7
  permissions: {
8
8
  label: 'Roles, field security and approvals',
@@ -88,6 +88,7 @@ const REACT = {
88
88
  react: '^19.1.0',
89
89
  'react-dom': '^19.1.0',
90
90
  '@tanstack/react-query': '^5.90.0',
91
+ '@tanstack/react-router': '^1.170.0',
91
92
  i18next: '^25.0.0 || ^26.0.0',
92
93
  'react-i18next': '^16.0.0 || ^17.0.0',
93
94
  };
@@ -105,8 +106,8 @@ export function packageJson(options, existing) {
105
106
  start: 'node --env-file=.env src/server.ts',
106
107
  web: 'vite',
107
108
  'build:web': 'vite build',
108
- migrate: 'kosh migrate',
109
- seed: 'kosh seed',
109
+ migrate: 'shutro-erp migrate',
110
+ seed: 'shutro-erp seed',
110
111
  'docker:up': 'docker compose up -d --build',
111
112
  }),
112
113
  engines: { node: '>=24', ...(base['engines'] ?? {}) },
@@ -130,12 +131,13 @@ export function modulesTs(_options) {
130
131
  const imports = apps.map((a) => `import { ${APPS[a].server} } from 'shutro-erp/${a}';`);
131
132
  return `/**
132
133
  * The apps this installation can install (the catalogue). The database records which are installed: Settings → Apps
133
- * in the back-office, or \`npx kosh apps\`, installs, disables and enables them. \`base\` (users, companies, sign-in)
134
+ * in the back-office, or \`npx shutro-erp apps\`, installs, disables and enables them. \`base\` (users, companies, sign-in)
134
135
  * and Settings are always installed. Add your own modules here (\`defineModule\` from \`shutro-erp/modules-sdk\`),
135
136
  * then install them from Settings → Apps.
136
137
  */
137
138
  import { base } from 'shutro-erp';
138
139
  import { automation } from 'shutro-erp/automation';
140
+ import { integrations } from 'shutro-erp/integrations';
139
141
  import { mail } from 'shutro-erp/mail';
140
142
  import { notify } from 'shutro-erp/notify';
141
143
  import { realtime } from 'shutro-erp/realtime';
@@ -145,12 +147,12 @@ import { dataImport } from 'shutro-erp/import';
145
147
  import { studio } from 'shutro-erp/studio';
146
148
  ${imports.join('\n')}
147
149
 
148
- export const modules = [base, settings, realtime, notify, mail, automation, studio, dataImport, assistant${apps.map((a) => `, ${APPS[a].server}`).join('')}];
150
+ export const modules = [base, settings, realtime, notify, mail, automation, integrations, studio, dataImport, assistant${apps.map((a) => `, ${APPS[a].server}`).join('')}];
149
151
  `;
150
152
  }
151
153
  export function serverTs() {
152
154
  return `/**
153
- * The Kosh API server: \`npm start\` (reads .env). It serves the installed apps of src/modules.ts, and switches to
155
+ * The Shutro ERP API server: \`npm start\` (reads .env). It serves the installed apps of src/modules.ts, and switches to
154
156
  * the new set without a restart when apps are installed or disabled (here or on another server).
155
157
  */
156
158
  import { createAppHost, startAppHost } from 'shutro-erp';
@@ -162,11 +164,11 @@ const host = await createAppHost({
162
164
  catalogue: modules,
163
165
  migrationsDir: 'migrations',
164
166
  ...(env['VALKEY_URL'] ? { valkeyUrl: env['VALKEY_URL'] } : {}),
165
- // Secure cookies need HTTPS: on by default in production (KOSH_SECURE_COOKIES=false for plain HTTP).
166
- auth: { secureCookies: (env['KOSH_SECURE_COOKIES'] ?? String(env['NODE_ENV'] === 'production')) === 'true' },
167
+ // Secure cookies need HTTPS: on by default in production (SHUTRO_ERP_SECURE_COOKIES=false for plain HTTP).
168
+ auth: { secureCookies: (env['SHUTRO_ERP_SECURE_COOKIES'] ?? env['KOSH_SECURE_COOKIES'] ?? String(env['NODE_ENV'] === 'production')) === 'true' },
167
169
  });
168
170
  const address = await startAppHost(host, { port: Number(env['PORT'] ?? 3000), host: env['HOST'] ?? '127.0.0.1' });
169
- process.stdout.write(\`Kosh API on \${address}\\n\`);
171
+ process.stdout.write(\`Shutro ERP API on \${address}\\n\`);
170
172
  `;
171
173
  }
172
174
  export function viteConfig(options) {
@@ -176,7 +178,7 @@ import react from '@vitejs/plugin-react';
176
178
  import { koshPwa } from 'shutro-erp/ui/vite';
177
179
  import { defineConfig } from 'vite';
178
180
 
179
- const api = process.env['KOSH_API'] ?? 'http://localhost:${options.port}';
181
+ const api = process.env['SHUTRO_ERP_API'] ?? process.env['KOSH_API'] ?? 'http://localhost:${options.port}';
180
182
 
181
183
  export default defineConfig({
182
184
  // koshPwa: the service worker (the app opens offline and gets web push)
@@ -216,6 +218,7 @@ export function mainTsx(_options) {
216
218
  { name: 'notify', fn: 'registerNotifyWeb' },
217
219
  { name: 'mail', fn: 'registerMailWeb' },
218
220
  { name: 'automation', fn: 'registerAutomationWeb' },
221
+ { name: 'integrations', fn: 'registerIntegrationsWeb' },
219
222
  { name: 'studio', fn: 'registerStudioWeb' },
220
223
  { name: 'assistant', fn: 'registerAssistantWeb' },
221
224
  { name: 'import', fn: 'registerImportWeb' },
@@ -263,7 +266,7 @@ export function manifest(options) {
263
266
  }
264
267
  /** The app icon (replace it with the company's). */
265
268
  export function icon() {
266
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Kosh">
269
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Shutro ERP">
267
270
  <rect width="512" height="512" rx="112" fill="#1d4ed8"/>
268
271
  <path d="M156 128h56v112l108-112h72L270 258l128 126h-74L220 282v102h-64z" fill="#fff"/>
269
272
  </svg>
@@ -273,7 +276,7 @@ export function indexCss() {
273
276
  return `@import "tailwindcss";
274
277
  @import "shutro-erp/ui/styles.css";
275
278
 
276
- /* Tailwind scans Kosh's components for the classes they use. */
279
+ /* Tailwind scans Shutro ERP's components for the classes they use. */
277
280
  @source "../../node_modules/shutro-erp/dist";
278
281
  `;
279
282
  }
@@ -286,7 +289,7 @@ var/
286
289
  }
287
290
  /** Three images from one build: the API (migrates, then serves) and nginx with the back-office. */
288
291
  export function dockerfile() {
289
- return `# Kosh images (kosh init). Built by compose.yaml: docker compose up -d --build
292
+ return `# Shutro ERP images (shutro-erp init). Built by compose.yaml: docker compose up -d --build
290
293
  FROM node:24-alpine AS build
291
294
  WORKDIR /app
292
295
  COPY package.json package-lock.json* ./
@@ -306,7 +309,7 @@ COPY --from=build /app/migrations ./migrations
306
309
  RUN mkdir -p /app/var/attachments && chown -R node:node /app/var
307
310
  USER node
308
311
  EXPOSE 3000
309
- CMD ["sh", "-c", "npx kosh migrate && node src/server.ts"]
312
+ CMD ["sh", "-c", "npx shutro-erp migrate && node src/server.ts"]
310
313
 
311
314
  # The back-office: static files, /api proxied to the API.
312
315
  FROM nginx:1.27-alpine AS web
@@ -315,7 +318,7 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
315
318
  `;
316
319
  }
317
320
  export function nginxConf() {
318
- return `# The back-office (kosh init): the built app, /api proxied to the API service.
321
+ return `# The back-office (shutro-erp init): the built app, /api proxied to the API service.
319
322
  server {
320
323
  listen 80;
321
324
  root /usr/share/nginx/html;
@@ -346,9 +349,9 @@ export function composeYaml(options) {
346
349
  const db = options.dockerDb ?? options.name.replace(/-/g, '_');
347
350
  /** A compose variable: `${NAME}` or `${NAME:-default}` (plain strings: nothing to escape). */
348
351
  const v = (name, fallback) => ['$', '{', name, fallback === undefined ? '' : fallback, '}'].join('');
349
- const user = v('POSTGRES_USER', ':-kosh');
352
+ const user = v('POSTGRES_USER', ':-shutro');
350
353
  const lines = [
351
- '# Kosh in Docker (kosh init).',
354
+ '# Shutro ERP in Docker (shutro-erp init).',
352
355
  '# docker compose up -d db only PostgreSQL (the API runs with npm start)',
353
356
  '# docker compose up -d --build everything: the back-office on http://localhost:8080',
354
357
  `name: ${options.name}`,
@@ -367,7 +370,7 @@ export function composeYaml(options) {
367
370
  ' - db:/var/lib/postgresql',
368
371
  ' healthcheck:',
369
372
  // $$ is a literal $ for compose: the shell in the container expands the variable.
370
- ` test: ["CMD-SHELL", "pg_isready -U $${v('POSTGRES_USER', ':-kosh')}"]`,
373
+ ` test: ["CMD-SHELL", "pg_isready -U $${v('POSTGRES_USER', ':-shutro')}"]`,
371
374
  ' interval: 5s',
372
375
  ' retries: 30',
373
376
  '',
@@ -379,9 +382,11 @@ export function composeYaml(options) {
379
382
  ' env_file: .env',
380
383
  ' environment:',
381
384
  ` DATABASE_URL: ${v('DOCKER_DATABASE_URL', `:-postgres://${user}:${v('POSTGRES_PASSWORD')}@db:5432/${v('POSTGRES_DB', `:-${db}`)}`)}`,
382
- ...(options.fileStorage === false ? [] : [' KOSH_STORAGE_DIR: /app/var/attachments']),
385
+ ...(options.fileStorage === false
386
+ ? []
387
+ : [' SHUTRO_ERP_STORAGE_DIR: /app/var/attachments']),
383
388
  ' # Plain HTTP on localhost; set "true" when the site is served over HTTPS.',
384
- ' KOSH_SECURE_COOKIES: "false"',
389
+ ' SHUTRO_ERP_SECURE_COOKIES: "false"',
385
390
  ' depends_on:',
386
391
  ' db:',
387
392
  ' condition: service_healthy',
@@ -431,19 +436,19 @@ export function tsconfig() {
431
436
  export function readme(options) {
432
437
  return `# ${options.name}
433
438
 
434
- A [Kosh](https://www.npmjs.com/package/shutro-erp) installation, created with \`kosh init\`.
439
+ A [Shutro ERP](https://www.npmjs.com/package/shutro-erp) installation, created with \`shutro-erp init\`.
435
440
 
436
441
  \`\`\`sh
437
442
  npm install
438
443
  npm start # the API on http://localhost:${options.port}
439
444
  npm run web # the back-office on http://localhost:${options.webPort ?? 5173} (sign in as the administrator)
440
- npx kosh migrate # after changing modules: plan and apply database migrations, load seed data
441
- npx kosh seed # load seed data again
445
+ npx shutro-erp migrate # after changing modules: plan and apply database migrations, load seed data
446
+ npx shutro-erp seed # load seed data again
442
447
  # Settings → Apps in the back-office installs, enables and disables apps (or, in the terminal:)
443
- npx kosh apps # the apps: installed, disabled, available
444
- npx kosh apps install <name> # install an app (and what it needs) in one transaction
445
- npx kosh apps disable <name> # switch an app off; its data stays (npx kosh apps enable <name>)
446
- npx kosh module create <name> # a new module in src/modules/<name>/
448
+ npx shutro-erp apps # the apps: installed, disabled, available
449
+ npx shutro-erp apps install <name> # install an app (and what it needs) in one transaction
450
+ npx shutro-erp apps disable <name> # switch an app off; its data stays (npx shutro-erp apps enable <name>)
451
+ npx shutro-erp module create <name> # a new module in src/modules/<name>/
447
452
  docker compose up -d --build # everything in Docker: http://localhost:8080
448
453
  \`\`\`
449
454
 
package/dist/cli/ui.d.ts CHANGED
@@ -1,10 +1,3 @@
1
- /**
2
- * The installer's look in the terminal, without dependencies: a logo, numbered steps, spinners for long work,
3
- * status lines (✔ ▲ ✖) and a summary card. On a Unicode terminal everything hangs on one rail down the left (│),
4
- * the questions of the interactive prompter included (`tui.ts`). Colour, Unicode and animation are detected from
5
- * the terminal (NO_COLOR, FORCE_COLOR, KOSH_ASCII=1, TERM=dumb, Windows consoles without Unicode); output that is not
6
- * a terminal (tests, logs, CI) gets plain lines with the same words and no escape codes.
7
- */
8
1
  /** What the terminal can show. */
9
2
  export interface Terminal {
10
3
  readonly color: boolean;
@@ -23,7 +16,7 @@ type Env = Readonly<Record<string, string | undefined>>;
23
16
  export declare function detectTerminal(stream: {
24
17
  readonly isTTY?: boolean;
25
18
  readonly columns?: number;
26
- }, env?: Env, platform?: string): Terminal;
19
+ }, env?: Env, platform?: string, release?: string): Terminal;
27
20
  export declare const ESC = "\u001B[";
28
21
  /** The colours and weights the installer uses. */
29
22
  export type Style = 'bold' | 'dim' | 'inverse' | 'underline' | 'green' | 'yellow' | 'red' | 'cyan' | 'magenta' | 'gray';
package/dist/cli/ui.js CHANGED
@@ -2,9 +2,10 @@
2
2
  * The installer's look in the terminal, without dependencies: a logo, numbered steps, spinners for long work,
3
3
  * status lines (✔ ▲ ✖) and a summary card. On a Unicode terminal everything hangs on one rail down the left (│),
4
4
  * the questions of the interactive prompter included (`tui.ts`). Colour, Unicode and animation are detected from
5
- * the terminal (NO_COLOR, FORCE_COLOR, KOSH_ASCII=1, TERM=dumb, Windows consoles without Unicode); output that is not
5
+ * the terminal (NO_COLOR, FORCE_COLOR, SHUTRO_ERP_ASCII=1, TERM=dumb, Windows consoles without Unicode); output that is not
6
6
  * a terminal (tests, logs, CI) gets plain lines with the same words and no escape codes.
7
7
  */
8
+ import { release as osRelease } from 'node:os';
8
9
  /** Plain output: logs, tests, pipes. */
9
10
  export const PLAIN_TERMINAL = Object.freeze({
10
11
  color: false,
@@ -14,7 +15,7 @@ export const PLAIN_TERMINAL = Object.freeze({
14
15
  columns: 80,
15
16
  });
16
17
  /** Detects what `stream` (process.stdout) can show. */
17
- export function detectTerminal(stream, env = process.env, platform = process.platform) {
18
+ export function detectTerminal(stream, env = process.env, platform = process.platform, release = osRelease()) {
18
19
  const tty = stream.isTTY === true && env['TERM'] !== 'dumb' && env['CI'] === undefined;
19
20
  const forced = env['FORCE_COLOR'] !== undefined && env['FORCE_COLOR'] !== '0';
20
21
  const color = env['NO_COLOR'] === undefined && (forced || tty);
@@ -24,11 +25,16 @@ export function detectTerminal(stream, env = process.env, platform = process.pla
24
25
  env['ConEmuTask'] === '{cmd::Cmder}' ||
25
26
  env['TERM'] === 'xterm-256color' ||
26
27
  env['TERM'] === 'alacritty';
27
- const unicode = env['KOSH_ASCII'] !== '1' && (platform === 'win32' ? modernWindows : env['TERM'] !== 'linux');
28
+ // Windows 10 and later: the console host shows box drawing and colours too (UTF-8, virtual terminal); pictographs
29
+ // only in the modern terminals. Older Windows keeps ASCII.
30
+ const windows10 = Number(release.split('.')[0]) >= 10;
31
+ const unicode = env['KOSH_ASCII'] !== '1' &&
32
+ env['SHUTRO_ERP_ASCII'] !== '1' &&
33
+ (platform === 'win32' ? modernWindows || windows10 : env['TERM'] !== 'linux');
28
34
  return {
29
35
  color,
30
36
  unicode,
31
- emoji: unicode && tty,
37
+ emoji: unicode && tty && (platform !== 'win32' || modernWindows),
32
38
  animate: tty,
33
39
  columns: Math.max(40, Math.min(stream.columns ?? 80, 120)),
34
40
  };
@@ -92,12 +98,12 @@ export const ICONS = {
92
98
  rocket: ['🚀', '◆'],
93
99
  };
94
100
  const LOGO = [
95
- '██╗ ██╗ ██████╗ ███████╗██╗ ██╗',
96
- '██║ ██╔╝██╔═══██╗██╔════╝██║ ██║',
97
- '█████╔╝ ██║ ██║███████╗███████║',
98
- '██╔═██╗ ██║ ██║╚════██║██╔══██║',
99
- '██║ ██╗╚██████╔╝███████║██║ ██║',
100
- '╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝',
101
+ '███████╗██╗ ██╗██╗ ██╗████████╗██████╗ ██████╗',
102
+ '██╔════╝██║ ██║██║ ██║╚══██╔══╝██╔══██╗██╔═══██╗',
103
+ '███████╗███████║██║ ██║ ██║ ██████╔╝██║ ██║',
104
+ '╚════██║██╔══██║██║ ██║ ██║ ██╔══██╗██║ ██║',
105
+ '███████║██║ ██║╚██████╔╝ ██║ ██║ ██║╚██████╔╝',
106
+ '╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝',
101
107
  ];
102
108
  /** Cyan to violet, one 256-colour step per logo line. */
103
109
  const GRADIENT = [51, 45, 39, 33, 63, 99];
@@ -141,7 +147,7 @@ export function createUi(out, terminal = PLAIN_TERMINAL, options = {}) {
141
147
  icon,
142
148
  async banner(subtitle) {
143
149
  if (!terminal.unicode) {
144
- write(`\n ${s('bold', 'K O S H')} ${subtitle}\n`);
150
+ write(`\n ${s('bold', 'S H U T R O E R P')} ${subtitle}\n`);
145
151
  return;
146
152
  }
147
153
  write('\n');
@@ -245,7 +251,25 @@ export function createUi(out, terminal = PLAIN_TERMINAL, options = {}) {
245
251
  },
246
252
  card(title, rows, tone = 'green') {
247
253
  const labelWidth = Math.max(0, ...rows.map(([l]) => width(l)));
248
- const body = rows.map(([l, v]) => `${s('gray', l.padEnd(labelWidth))} ${v}`);
254
+ // Long values wrap at the terminal's width, aligned after their label.
255
+ const room = Math.max(20, Math.min(terminal.columns, 100) - labelWidth - 10);
256
+ const wrap = (value) => {
257
+ const lines = [];
258
+ let current = '';
259
+ for (const word of value.split(' ')) {
260
+ const next = current === '' ? word : `${current} ${word}`;
261
+ if (current !== '' && width(next) > room) {
262
+ lines.push(current);
263
+ current = word;
264
+ }
265
+ else
266
+ current = next;
267
+ }
268
+ return [...lines, current];
269
+ };
270
+ const body = rows.flatMap(([l, v]) => wrap(v).map((part, i) => i === 0
271
+ ? `${s('gray', l.padEnd(labelWidth))} ${part}`
272
+ : `${' '.repeat(labelWidth)} ${part}`));
249
273
  const inner = Math.max(width(title), ...body.map(width)) + 2;
250
274
  const [tl, tr, bl, br, h, v] = terminal.unicode
251
275
  ? ['╭', '╮', '╰', '╯', '─', '│']
@@ -1,4 +1,4 @@
1
- // GENERATED by `pnpm generate:models` (Shutro’s generator). Do not edit. definitions-hash: 07f36b4355890ebe
1
+ // GENERATED by `pnpm generate:models` (Shutro’s generator). Do not edit. definitions-hash: 2271aaa68e1a18a5
2
2
  // Bindings of module `contacts`: import the definition API from here to get types scoped to what it can see.
3
3
  export { defineModel, defineModule, extendModel, f, replace } from '../../server/orm.js';
4
4
  import { definitions } from '../../server/orm.js';
@@ -1,10 +1,11 @@
1
- // GENERATED by `pnpm generate:models` (Shutro’s generator). Do not edit. definitions-hash: 07f36b4355890ebe
1
+ // GENERATED by `pnpm generate:models` (Shutro’s generator). Do not edit. definitions-hash: 2271aaa68e1a18a5
2
2
  // Schema view of module `contacts` (RFC 0002 §7.7): the models it owns or depends on, with the extensions it can see.
3
3
  import type { F } from '../../server/orm.js';
4
4
 
5
5
  /** Model name → model type (RFC 0002 §7.7). */
6
6
  export interface SchemaView {
7
7
  readonly 'audit.event': AuditEvent;
8
+ readonly 'auth.api_key': AuthApiKey;
8
9
  readonly 'auth.credential': AuthCredential;
9
10
  readonly 'auth.session': AuthSession;
10
11
  readonly 'auth.user_grant': AuthUserGrant;
@@ -42,6 +43,34 @@ export interface AuditEvent {
42
43
  export interface AuditEventMethods extends F.Methods<typeof import('../../server/index.js').baseShutro, 'audit.event'> {
43
44
  }
44
45
 
46
+ /** `auth.api_key` (owner: base). */
47
+ export interface AuthApiKey {
48
+ readonly name: 'auth.api_key';
49
+ readonly fields: {
50
+ readonly id: F.Id<'auth.api_key'>;
51
+ readonly createdAt: F.Datetime<{ readonly required: true; readonly readonly: true }>;
52
+ readonly updatedAt: F.Datetime<{ readonly required: true; readonly readonly: true }>;
53
+ readonly createdBy: F.Many2one<'res.users', { readonly readonly: true }>;
54
+ readonly updatedBy: F.Many2one<'res.users', { readonly readonly: true }>;
55
+ readonly displayName: F.Char<{ readonly required: true; readonly readonly: true; readonly compute: true }>;
56
+ readonly name: F.Char<{ readonly required: true }>;
57
+ readonly user: F.Many2one<'res.users', { readonly required: true }>;
58
+ readonly tokenHash: F.Char<{ readonly required: true }>;
59
+ readonly prefix: F.Char<{ readonly required: true }>;
60
+ readonly scopes: F.Text<{ readonly required: true }>;
61
+ readonly expiresAt: F.Datetime<{ readonly required: true }>;
62
+ readonly lastUsedAt: F.Datetime;
63
+ readonly lastUsedIp: F.Char;
64
+ readonly revoked: F.Bool<{ readonly required: true; readonly default: true }>;
65
+ readonly issuedBy: F.Many2one<'res.users'>;
66
+ };
67
+ readonly methods: AuthApiKeyMethods;
68
+ }
69
+
70
+ /** Methods of `auth.api_key`: declared ones and transitions. */
71
+ export interface AuthApiKeyMethods extends F.Methods<typeof import('../../server/index.js').baseShutro, 'auth.api_key'> {
72
+ }
73
+
45
74
  /** `auth.credential` (owner: base). */
46
75
  export interface AuthCredential {
47
76
  readonly name: 'auth.credential';
@@ -260,6 +289,7 @@ export interface ResUsers {
260
289
  readonly companies: F.Many2many<'res.company', { readonly required: true }>;
261
290
  readonly lang: F.Char<{ readonly default: true }>;
262
291
  readonly tz: F.Char<{ readonly default: true }>;
292
+ readonly isService: F.Bool<{ readonly required: true; readonly default: true }>;
263
293
  };
264
294
  readonly methods: ResUsersMethods;
265
295
  }
@@ -0,0 +1,36 @@
1
+ import type { InboundCall, InboundWebhookSpec } from '../modules-sdk/index.js';
2
+ import type { KoshRegistry } from '../server/index.js';
3
+ import type { Shutro } from '../server/orm.js';
4
+ export declare const INBOUND_JOB = "webhook.inbound";
5
+ /** The declared inbound webhooks of the loaded modules, by `<module>.<key>`. */
6
+ export declare function inboundHooks(registry: KoshRegistry): Map<string, InboundWebhookSpec & {
7
+ readonly module: string;
8
+ }>;
9
+ /**
10
+ * A `verify` for providers that sign with an HMAC of the raw body: the signature in `header` (hex or base64,
11
+ * optionally after a prefix such as `sha256=`), compared in constant time; the event id from `eventId`.
12
+ */
13
+ export declare function hmacVerifier(options: {
14
+ readonly header: string;
15
+ readonly algorithm?: 'sha256' | 'sha512' | 'sha1';
16
+ readonly encoding?: 'hex' | 'base64';
17
+ readonly prefix?: string;
18
+ readonly eventId: (call: InboundCall) => string | null | undefined;
19
+ }): (call: InboundCall, secret: string) => string | null;
20
+ export interface ReceiveResult {
21
+ readonly status: 202 | 200 | 401 | 404;
22
+ readonly body: Record<string, unknown>;
23
+ }
24
+ /** Receives a call on the endpoint of `token`: checked, stored, queued once per event id. */
25
+ export declare function receive(context: {
26
+ readonly shutro: Shutro;
27
+ readonly registry: KoshRegistry;
28
+ }, token: string, call: InboundCall): Promise<ReceiveResult>;
29
+ /**
30
+ * Handles a stored call with its module's `handle`, as the system in the endpoint's company; records the outcome
31
+ * in its own transaction. Throws to be retried while attempts remain.
32
+ */
33
+ export declare function handleInbound(context: {
34
+ readonly shutro: Shutro;
35
+ readonly registry: KoshRegistry;
36
+ }, callId: number, company: number, last: boolean): Promise<void>;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Inbound webhooks (RFC 0017 §5.4). Modules declare endpoints (`inboundWebhooks` in their manifest); a company
3
+ * switches one on (`webhook.endpoint`: a URL token and a secret). A call to `POST /api/v1/hooks/<token>` is checked
4
+ * by the module's `verify` against the raw body, stored (refused calls too, for the log), and — once per event id —
5
+ * handled by the module in a job, as the system in the endpoint's company, retried on failure. A repeated event id
6
+ * is answered 200 and not handled again; a call can be handled again from the log (replay).
7
+ */
8
+ import { createHmac, timingSafeEqual } from 'node:crypto';
9
+ import { ref } from './webhooks.js';
10
+ export const INBOUND_JOB = 'webhook.inbound';
11
+ /** The declared inbound webhooks of the loaded modules, by `<module>.<key>`. */
12
+ export function inboundHooks(registry) {
13
+ const out = new Map();
14
+ for (const m of registry.loaded.modules)
15
+ for (const hook of m.inboundWebhooks ?? [])
16
+ out.set(`${m.name}.${hook.key}`, { ...hook, module: m.name });
17
+ return out;
18
+ }
19
+ /**
20
+ * A `verify` for providers that sign with an HMAC of the raw body: the signature in `header` (hex or base64,
21
+ * optionally after a prefix such as `sha256=`), compared in constant time; the event id from `eventId`.
22
+ */
23
+ export function hmacVerifier(options) {
24
+ return (call, secret) => {
25
+ let given = call.headers[options.header.toLowerCase()] ?? '';
26
+ if (options.prefix !== undefined) {
27
+ if (!given.startsWith(options.prefix))
28
+ return null;
29
+ given = given.slice(options.prefix.length);
30
+ }
31
+ const expected = createHmac(options.algorithm ?? 'sha256', secret)
32
+ .update(call.body)
33
+ .digest(options.encoding ?? 'hex');
34
+ const a = Buffer.from(given);
35
+ const b = Buffer.from(expected);
36
+ if (a.length !== b.length || !timingSafeEqual(a, b))
37
+ return null;
38
+ const id = options.eventId(call);
39
+ return id === null || id === undefined || id === '' ? null : String(id);
40
+ };
41
+ }
42
+ /** Headers worth keeping in the log (no cookies, no credentials). */
43
+ const KEPT = /^(content-type|user-agent|x-[a-z0-9-]+)$/;
44
+ function keptHeaders(headers) {
45
+ return Object.fromEntries(Object.entries(headers).filter((e) => e[1] !== undefined && KEPT.test(e[0]) && !/authorization|api-key|cookie/.test(e[0])));
46
+ }
47
+ /** Receives a call on the endpoint of `token`: checked, stored, queued once per event id. */
48
+ export async function receive(context, token, call) {
49
+ const hooks = inboundHooks(context.registry);
50
+ // Which endpoint (and company) the token names: looked up across companies (the token is unique).
51
+ const found = await context.shutro.bootstrap({ reason: 'inbound webhook: endpoint' }, (env) => env.asSuperuser({ reason: 'inbound webhook: which endpoint' }, async (su) => {
52
+ const set = await ref(su.withFlags({ checkCompany: false }), 'webhook.endpoint').search({
53
+ where: { token, active: true },
54
+ limit: 1,
55
+ });
56
+ if (set.length === 0)
57
+ return null;
58
+ const [row] = (await set.load(['hook', 'secret', 'company'])).read([
59
+ 'id',
60
+ 'hook',
61
+ 'secret',
62
+ 'company',
63
+ ]);
64
+ return row ?? null;
65
+ }));
66
+ const hook = found === null ? undefined : hooks.get(String(found['hook']));
67
+ if (found === null || hook === undefined)
68
+ return { status: 404, body: { title: 'Not found', detail: 'No such webhook endpoint.' } };
69
+ const company = found['company'];
70
+ let eventId = null;
71
+ try {
72
+ eventId = await hook.verify(call, String(found['secret']));
73
+ }
74
+ catch {
75
+ eventId = null;
76
+ }
77
+ return context.shutro.bootstrap({ reason: `inbound webhook ${hook.module}.${hook.key}`, company }, async (env) => {
78
+ const calls = ref(env, 'webhook.inbound');
79
+ const base = {
80
+ endpoint: found['id'],
81
+ headers: keptHeaders(call.headers),
82
+ body: call.body.slice(0, 256 * 1024),
83
+ company,
84
+ };
85
+ if (eventId === null) {
86
+ await calls.create([{ ...base, verified: false, state: 'refused' }]);
87
+ return {
88
+ status: 401,
89
+ body: { title: 'Not verified', detail: 'The call’s signature does not match.' },
90
+ };
91
+ }
92
+ const seen = await calls.search({
93
+ where: { endpoint: found['id'], eventId, verified: true },
94
+ limit: 1,
95
+ });
96
+ if (seen.length > 0)
97
+ return { status: 200, body: { duplicate: true, eventId } };
98
+ const created = await calls.create([{ ...base, eventId, verified: true, state: 'received' }]);
99
+ const id = created.ids[0];
100
+ await env.enqueue(INBOUND_JOB, { call: id, company }, { attempts: 6 });
101
+ return { status: 202, body: { received: true, eventId } };
102
+ });
103
+ }
104
+ /**
105
+ * Handles a stored call with its module's `handle`, as the system in the endpoint's company; records the outcome
106
+ * in its own transaction. Throws to be retried while attempts remain.
107
+ */
108
+ export async function handleInbound(context, callId, company, last) {
109
+ const hooks = inboundHooks(context.registry);
110
+ const system = (reason, fn) => context.shutro.bootstrap({ reason, company }, fn);
111
+ const found = await system('inbound webhook: the call', async (env) => {
112
+ const set = ref(env, 'webhook.inbound').browse([callId]);
113
+ const [c] = (await set.load(['endpoint', 'body', 'headers', 'state', 'attempts'])).read([
114
+ 'endpoint',
115
+ 'body',
116
+ 'headers',
117
+ 'state',
118
+ 'attempts',
119
+ ]);
120
+ if (c === undefined)
121
+ return null;
122
+ const [e] = (await ref(env, 'webhook.endpoint')
123
+ .browse([c['endpoint']])
124
+ .load(['hook'])).read(['hook']);
125
+ return { row: c, hook: String(e?.['hook'] ?? '') };
126
+ });
127
+ if (found === null || found.row['state'] === 'done')
128
+ return;
129
+ const { row, hook: name } = found;
130
+ const hook = hooks.get(name);
131
+ const attempts = Number(row['attempts'] ?? 0) + 1;
132
+ const write = (values) => system('inbound webhook: the outcome', (env) => ref(env, 'webhook.inbound').browse([callId]).write(values));
133
+ if (hook === undefined) {
134
+ await write({
135
+ state: 'failed',
136
+ attempts,
137
+ error: `The endpoint ${name} is no longer installed.`,
138
+ });
139
+ return;
140
+ }
141
+ const body = String(row['body']);
142
+ let json = null;
143
+ try {
144
+ json = JSON.parse(body);
145
+ }
146
+ catch {
147
+ json = null;
148
+ }
149
+ const call = {
150
+ headers: (row['headers'] ?? {}),
151
+ query: {},
152
+ body,
153
+ json,
154
+ };
155
+ try {
156
+ const result = await system(`inbound webhook ${name}`, (env) => hook.handle(env, call));
157
+ await write({
158
+ state: 'done',
159
+ attempts,
160
+ result: typeof result === 'string' ? result : null,
161
+ error: null,
162
+ handledAt: new Date(),
163
+ });
164
+ }
165
+ catch (error) {
166
+ const message = error instanceof Error ? error.message : String(error);
167
+ await write({ state: last ? 'failed' : 'received', attempts, error: message });
168
+ if (!last)
169
+ throw error;
170
+ }
171
+ }
@@ -0,0 +1,18 @@
1
+ import { type KoshModule } from '../modules-sdk/index.js';
2
+ import type { Worker } from '../server/orm.js';
3
+ import { type IntegrationsContext } from './webhooks.js';
4
+ export { handleInbound, hmacVerifier, INBOUND_JOB, inboundHooks, type ReceiveResult, receive, } from './inbound.js';
5
+ export { integrationsShutro, newSecret, newToken } from './models.js';
6
+ export { CHANGE_EVENTS, DELIVER_JOB, deliver, fanOut, type IntegrationsContext, PING_EVENT, signature, urlProblem, type WebhookEvent, webhookEvents, webhookHosts, } from './webhooks.js';
7
+ /** The workers: events to deliveries (change events and the modules' events), and the delivery jobs. */
8
+ export declare function webhookWorkers(integrations: IntegrationsContext): Worker[];
9
+ export interface IntegrationsOptions {
10
+ /** The HTTP client deliveries use (tests). */
11
+ readonly fetch?: typeof fetch;
12
+ /** Start the workers (default: unless `SHUTRO_ERP_SCHEDULES=off`). */
13
+ readonly workers?: boolean;
14
+ }
15
+ /** The Integrations app with explicit options (tests, installations wiring their own). */
16
+ export declare function integrationsModule(options?: IntegrationsOptions): KoshModule;
17
+ /** The Integrations app (providers and workers from the environment). */
18
+ export declare const integrations: KoshModule;