@ultimat3/cli 23.0.0 → 25.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +28 -13
- package/README.md +32 -7
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +30 -36
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +26 -25
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +4 -10
- package/src/app-mcp.ts +31 -46
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +123 -0
- package/src/app-permissions-site.ts +48 -0
- package/src/app-permissions.ts +7 -17
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +117 -154
- package/src/cdp-shot-driver.ts +20 -1
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +151 -22
- package/src/cmd-db-backfill.ts +26 -13
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +10 -6
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -356
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +57 -80
- package/src/cmd-env.ts +55 -16
- package/src/cmd-generate.ts +89 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +21 -17
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +33 -140
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +71 -22
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +24 -8
- package/src/cmd-routes.ts +15 -5
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +22 -7
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +46 -41
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +14 -30
- package/src/cmd-verify.ts +4 -4
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +27 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +56 -30
- package/src/dev-lock.ts +100 -31
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +17 -13
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/drift.ts +29 -6
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +29 -3
- package/src/error-page-csp.ts +14 -15
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +25 -3
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +18 -0
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +95 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +25 -9
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +2 -6
- package/src/island-bundle.ts +57 -79
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +36 -20
- package/src/local-cli-handoff.ts +90 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +20 -4
- package/src/mcp-host.ts +22 -14
- package/src/mcp-test-run.ts +38 -0
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +29 -16
- package/src/metrics-endpoint.ts +28 -3
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +24 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +19 -31
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-out.ts +34 -7
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +71 -103
- package/src/pwa-artifacts.ts +33 -94
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/registry.ts +2 -0
- package/src/retry-memo.ts +1 -1
- package/src/revalidated-response.ts +4 -3
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +124 -29
- package/src/role-start.ts +69 -85
- package/src/role-sync.ts +119 -25
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +26 -9
- package/src/runtime-bindings.ts +27 -7
- package/src/runtime-cache.ts +46 -55
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +76 -41
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +4 -4
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +43 -22
- package/src/runtime-realtime.ts +11 -45
- package/src/runtime-render.ts +136 -114
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +49 -26
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +13 -8
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +51 -12
- package/src/serve-drain.ts +11 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +31 -15
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -33
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +7 -5
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +19 -3
- package/src/static-report.ts +5 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +3 -3
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +7 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/naming.ts +28 -0
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +26 -14
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +8 -3
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +16 -8
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-client.ts +4 -2
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +100 -19
- package/src/templates/scaffold-helm.ts +62 -5
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +27 -16
- package/src/templates/scaffold-shell.ts +55 -37
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-only-shipped.ts +193 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +7 -15
- package/src/test-shards.ts +40 -95
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +8 -33
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +29 -48
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +207 -33
- package/src/verify-merge.ts +108 -43
- package/src/verify-part-step.ts +98 -0
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +32 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +5 -3
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/build-errors.ts +0 -54
- package/src/jobs-drain.ts +0 -163
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
- package/src/verify-errors.ts +0 -58
package/src/cmd-jobs-spec.ts
CHANGED
|
@@ -15,17 +15,14 @@ export const JOBS_SUBCOMMANDS = [
|
|
|
15
15
|
'drain',
|
|
16
16
|
] as const;
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
* The drivers a drain may move work ONTO — every one of them durable, and that is the whole rule.
|
|
20
|
-
* Closed, and read three ways: the flag summary, the refusal, and the `memory` case below.
|
|
21
|
-
*/
|
|
22
|
-
export const DRAIN_TARGETS = ['redis', 'nats'] as const;
|
|
23
|
-
|
|
24
18
|
export const jobsSpec: CommandSpec = {
|
|
25
19
|
name: 'jobs',
|
|
26
|
-
|
|
20
|
+
// Never ENDING in "(planned)": that suffix is how `x help` and the planned-table tests tell a
|
|
21
|
+
// planned COMMAND from a shipped one, and `x jobs` ships — only its `drain` does not.
|
|
22
|
+
summary:
|
|
23
|
+
'list, show, retry, cancel, remove and promote jobs; pause and resume a queue (drain is planned)',
|
|
27
24
|
usage:
|
|
28
|
-
'x jobs [ls|show <id>|retry <id>|cancel <id>|rm <id>|promote <id>|pause <queue>|resume <queue>|drain
|
|
25
|
+
'x jobs [ls|show <id>|retry <id>|cancel <id>|rm <id>|promote <id>|pause <queue>|resume <queue>|drain] [--queue q] [--state s] [--name n] [--limit n] [--after cursor] [--from-step name] [--reason text] [--json]',
|
|
29
26
|
requiresApp: true,
|
|
30
27
|
subcommands: JOBS_SUBCOMMANDS,
|
|
31
28
|
// The bare `x jobs` lists; it never retries, cancels or drains anything.
|
|
@@ -41,8 +38,8 @@ export const jobsSpec: CommandSpec = {
|
|
|
41
38
|
summary: 'ls: the next page — the cursor the previous page printed',
|
|
42
39
|
subcommands: ['ls'],
|
|
43
40
|
},
|
|
44
|
-
// Each of these is read by ONE subcommand — `retryJob`, `cancelJob
|
|
45
|
-
//
|
|
41
|
+
// Each of these is read by ONE subcommand — `retryJob`, `cancelJob` — and says so in its own
|
|
42
|
+
// summary. The scope is what makes the parser refuse it anywhere else instead
|
|
46
43
|
// of accepting it and ignoring it: `x db gen --dry-run` parsed and wrote the migration.
|
|
47
44
|
{
|
|
48
45
|
name: 'from-step',
|
|
@@ -56,17 +53,5 @@ export const jobsSpec: CommandSpec = {
|
|
|
56
53
|
summary: 'cancel: why, recorded on the job',
|
|
57
54
|
subcommands: ['cancel'],
|
|
58
55
|
},
|
|
59
|
-
{
|
|
60
|
-
name: 'to',
|
|
61
|
-
type: 'string',
|
|
62
|
-
summary: `drain: target driver — ${DRAIN_TARGETS.join(', ')}`,
|
|
63
|
-
subcommands: ['drain'],
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
name: 'dry-run',
|
|
67
|
-
type: 'boolean',
|
|
68
|
-
summary: 'drain: report the plan, move nothing',
|
|
69
|
-
subcommands: ['drain'],
|
|
70
|
-
},
|
|
71
56
|
],
|
|
72
57
|
};
|
package/src/cmd-jobs.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// `x jobs ls|show|retry|cancel|rm|promote|pause|resume
|
|
2
|
-
// `@ultimat3/jobs`'s
|
|
3
|
-
//
|
|
1
|
+
// `x jobs ls|show|retry|cancel|rm|promote|pause|resume` — introspect and recover the job queue,
|
|
2
|
+
// bound to `@ultimat3/jobs`'s own introspection so the CLI, `/_x` and MCP report identically;
|
|
3
|
+
// `drain` is planned (`cmd-planned.ts`): Postgres is the one durable driver. CLI wiring only:
|
|
4
4
|
// the driver-injected logic is `jobs-report.ts`, the `--json` shapes `jobs-json.ts`, the table
|
|
5
5
|
// `jobs-table.ts`, and getting hold of the queue at all is `jobs-driver.ts` — shared with `x db`.
|
|
6
6
|
|
|
@@ -8,10 +8,6 @@ import { renderFixShellArg } from '@ultimat3/core';
|
|
|
8
8
|
import type { JobDriver } from '@ultimat3/jobs';
|
|
9
9
|
import {
|
|
10
10
|
cancelJob,
|
|
11
|
-
createNatsDriver,
|
|
12
|
-
createRedisDriver,
|
|
13
|
-
DEFAULT_JOB_PAGE,
|
|
14
|
-
jobCursor,
|
|
15
11
|
MAX_JOB_PAGE,
|
|
16
12
|
pauseQueue,
|
|
17
13
|
promoteJob,
|
|
@@ -20,18 +16,15 @@ import {
|
|
|
20
16
|
} from '@ultimat3/jobs';
|
|
21
17
|
import { loadApp } from './app-load';
|
|
22
18
|
import { requireAppRoot } from './app-root';
|
|
23
|
-
import {
|
|
19
|
+
import { jobsSpec } from './cmd-jobs-spec';
|
|
20
|
+
import { plannedSubcommand } from './cmd-planned';
|
|
24
21
|
import type { CliCommand, CommandContext } from './command';
|
|
25
22
|
import { BadFlagError, JobUnknownError, MissingPositionalError } from './errors';
|
|
26
|
-
import type { DrainOutcome } from './jobs-drain';
|
|
27
|
-
import { drainJobs } from './jobs-drain';
|
|
28
23
|
import { withJobDriver } from './jobs-driver';
|
|
29
24
|
import {
|
|
30
25
|
backfillToJson,
|
|
31
26
|
deadLetterToJson,
|
|
32
27
|
depthToJson,
|
|
33
|
-
drainFailureToJson,
|
|
34
|
-
drainSkipToJson,
|
|
35
28
|
jobRecordToJson,
|
|
36
29
|
jobTraceToJson,
|
|
37
30
|
pausedToJson,
|
|
@@ -41,32 +34,22 @@ import { listJobs, retryJob, showJob } from './jobs-report';
|
|
|
41
34
|
import { renderJobTable } from './jobs-table';
|
|
42
35
|
import { msg } from './messages';
|
|
43
36
|
import type { CommandResult } from './output';
|
|
44
|
-
import {
|
|
37
|
+
import { flagString } from './parse';
|
|
45
38
|
|
|
46
|
-
export {
|
|
39
|
+
export { JOBS_SUBCOMMANDS } from './cmd-jobs-spec';
|
|
47
40
|
|
|
48
41
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* durable row off the source. Reproduced against two real drivers — source ready 1 -> 0, target
|
|
52
|
-
* ready 1, `ok: true` — and the target dies with the command. `wiki/CLI-Reference.md` said the
|
|
53
|
-
* crash window "duplicates a job … instead of losing it"; this target lost every one of them.
|
|
54
|
-
*
|
|
55
|
-
* Refused by NAME rather than folded into the unknown-value message, for `cmd-deploy.ts`'s
|
|
56
|
-
* `readMethod` reason: `--to memory` is a spelling that used to work, so a reader who types it is
|
|
57
|
-
* owed the fact that it moved work into a process that is about to exit, not a list of words.
|
|
42
|
+
* A uuid in any spelling Postgres' `uuid` input accepts: canonical, upper-case, unhyphenated,
|
|
43
|
+
* braced. Every driver mints its ids with core's `uuid()`, and `x_jobs.id` is a `uuid` column.
|
|
58
44
|
*/
|
|
59
|
-
|
|
60
|
-
throw new BadFlagError({
|
|
61
|
-
flag: 'to',
|
|
62
|
-
command: 'jobs',
|
|
63
|
-
reason:
|
|
64
|
-
'memory is a Map inside this process — the drain would ack every durable row and lose the copy when the command exits',
|
|
65
|
-
fix: 'x jobs drain --to redis --json # or --to nats; --dry-run reports the plan and moves nothing',
|
|
66
|
-
});
|
|
67
|
-
}
|
|
45
|
+
const JOB_ID = /^\{?[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}\}?$/i;
|
|
68
46
|
|
|
69
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The id, parsed at the door — admin's `readableId` rule: an id no driver could hold is a job that
|
|
49
|
+
* does not exist, answered before a statement is sent. `x jobs show nosuch` reached Postgres and
|
|
50
|
+
* came back a raw `X_DB_STATEMENT_FAILED [22P02]` whose fix was a psql session.
|
|
51
|
+
*/
|
|
52
|
+
function requireIdPositional(ctx: CommandContext, sub: string, driver: JobDriver): string {
|
|
70
53
|
const id = ctx.args.positionals[0];
|
|
71
54
|
if (id === undefined) {
|
|
72
55
|
// `--id on "x jobs"` is a flag `x jobs` does not declare; the id is a positional and says so.
|
|
@@ -76,6 +59,7 @@ function requireIdPositional(ctx: CommandContext, sub: string): string {
|
|
|
76
59
|
example: 'x jobs ls --json',
|
|
77
60
|
});
|
|
78
61
|
}
|
|
62
|
+
if (!JOB_ID.test(id)) throw new JobUnknownError({ id, driver: driver.name });
|
|
79
63
|
return id;
|
|
80
64
|
}
|
|
81
65
|
|
|
@@ -102,42 +86,10 @@ function refuseOversizedPage(limit: string | undefined): void {
|
|
|
102
86
|
command: 'jobs',
|
|
103
87
|
reason: `one page holds at most ${MAX_JOB_PAGE} jobs`,
|
|
104
88
|
// The bound is the queue's own constant, screened like any value spliced into a command.
|
|
105
|
-
fix: `x jobs ls --limit ${renderFixShellArg(String(MAX_JOB_PAGE), '200')} --json # then pass its data.
|
|
89
|
+
fix: `x jobs ls --limit ${renderFixShellArg(String(MAX_JOB_PAGE), '200')} --json # then pass its data.nextCursor as --after while data.hasMore is true`,
|
|
106
90
|
});
|
|
107
91
|
}
|
|
108
92
|
|
|
109
|
-
function requireEnvUrl(env: CommandContext['env'], name: string, target: string): string {
|
|
110
|
-
const value = env[name];
|
|
111
|
-
if (value === undefined || value.trim().length === 0) {
|
|
112
|
-
throw new BadFlagError({
|
|
113
|
-
flag: 'to',
|
|
114
|
-
command: 'jobs',
|
|
115
|
-
reason: `--to ${target} needs ${name} set in the environment`,
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
return value;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* `redis`/`nats` are honest `X_NOT_IMPLEMENTED` stubs in `@ultimat3/jobs` — building one here is
|
|
123
|
-
* fine even though every `enqueue` on it will fail; `drainJobs` reports that per record. What is
|
|
124
|
-
* NOT fine is a target that accepts every enqueue and then vanishes, which is why `memory` is
|
|
125
|
-
* refused first and by name rather than falling into the closed-set message below.
|
|
126
|
-
* Exported so a test can drive the `--to`/env-var validation without a driver or a boot.
|
|
127
|
-
*/
|
|
128
|
-
export function buildDrainTarget(to: string | undefined, env: CommandContext['env']): JobDriver {
|
|
129
|
-
if (to === 'memory') refuseMemoryTarget();
|
|
130
|
-
if (to === undefined || !(DRAIN_TARGETS as readonly string[]).includes(to)) {
|
|
131
|
-
throw new BadFlagError({
|
|
132
|
-
flag: 'to',
|
|
133
|
-
command: 'jobs',
|
|
134
|
-
reason: `expects one of: ${DRAIN_TARGETS.join(', ')}`,
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
if (to === 'redis') return createRedisDriver({ url: requireEnvUrl(env, 'REDIS_URL', 'redis') });
|
|
138
|
-
return createNatsDriver({ servers: [requireEnvUrl(env, 'NATS_URL', 'nats')] });
|
|
139
|
-
}
|
|
140
|
-
|
|
141
93
|
async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
142
94
|
const limit = flagString(ctx.args, 'limit');
|
|
143
95
|
refuseOversizedPage(limit);
|
|
@@ -148,11 +100,9 @@ async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
|
|
|
148
100
|
limit,
|
|
149
101
|
after: flagString(ctx.args, 'after'),
|
|
150
102
|
});
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
const
|
|
154
|
-
const last = result.rows[result.rows.length - 1];
|
|
155
|
-
const next = last === undefined || result.rows.length < size ? null : jobCursor(last);
|
|
103
|
+
// `nextCursor` exists exactly when a row past this page does (`listJobs` asks the queue), so a
|
|
104
|
+
// full last page prints no "next page" a caller would follow to an empty one.
|
|
105
|
+
const next = result.nextCursor;
|
|
156
106
|
const [paused, workers] = await Promise.all([
|
|
157
107
|
driver.introspect?.pausedQueues() ?? [],
|
|
158
108
|
driver.introspect?.workers() ?? [],
|
|
@@ -203,7 +153,8 @@ async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
|
|
|
203
153
|
rows: result.rows.map(jobRecordToJson),
|
|
204
154
|
deadLetters: result.deadLetters.map(deadLetterToJson),
|
|
205
155
|
backfills: result.backfills.map(backfillToJson),
|
|
206
|
-
|
|
156
|
+
nextCursor: result.nextCursor,
|
|
157
|
+
hasMore: result.hasMore,
|
|
207
158
|
pausedQueues: paused.map(pausedToJson),
|
|
208
159
|
workers: workers.map(workerToJson),
|
|
209
160
|
},
|
|
@@ -211,7 +162,7 @@ async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
|
|
|
211
162
|
}
|
|
212
163
|
|
|
213
164
|
async function runShow(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
214
|
-
const trace = await showJob(driver, requireIdPositional(ctx, 'show'));
|
|
165
|
+
const trace = await showJob(driver, requireIdPositional(ctx, 'show', driver));
|
|
215
166
|
return {
|
|
216
167
|
ok: true,
|
|
217
168
|
command: 'jobs',
|
|
@@ -226,7 +177,7 @@ async function runShow(driver: JobDriver, ctx: CommandContext): Promise<CommandR
|
|
|
226
177
|
}
|
|
227
178
|
|
|
228
179
|
async function runRetry(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
229
|
-
const id = requireIdPositional(ctx, 'retry');
|
|
180
|
+
const id = requireIdPositional(ctx, 'retry', driver);
|
|
230
181
|
const trace = await retryJob(driver, id, flagString(ctx.args, 'from-step'));
|
|
231
182
|
return {
|
|
232
183
|
ok: true,
|
|
@@ -243,7 +194,7 @@ async function runRetry(driver: JobDriver, ctx: CommandContext): Promise<Command
|
|
|
243
194
|
* The trace is rendered by the same projection `show` and `retry` use — one shape for one job.
|
|
244
195
|
*/
|
|
245
196
|
async function runCancel(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
246
|
-
const id = requireIdPositional(ctx, 'cancel');
|
|
197
|
+
const id = requireIdPositional(ctx, 'cancel', driver);
|
|
247
198
|
const trace = await cancelJob(driver, id, flagString(ctx.args, 'reason'));
|
|
248
199
|
// `cancelJob` re-reads the job after cancelling, so `undefined` would mean the row vanished
|
|
249
200
|
// between the two — reported as the same refusal rather than rendered as a success with no job.
|
|
@@ -258,7 +209,7 @@ async function runCancel(driver: JobDriver, ctx: CommandContext): Promise<Comman
|
|
|
258
209
|
|
|
259
210
|
/** Exit 0 means the row is gone. An id nobody queued is `X_JOB_UNKNOWN`; a running one refuses. */
|
|
260
211
|
async function runRm(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
261
|
-
const id = requireIdPositional(ctx, 'rm');
|
|
212
|
+
const id = requireIdPositional(ctx, 'rm', driver);
|
|
262
213
|
const removed = await removeJob(driver, id);
|
|
263
214
|
if (removed === undefined) throw new JobUnknownError({ id, driver: driver.name });
|
|
264
215
|
return {
|
|
@@ -270,7 +221,7 @@ async function runRm(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
|
|
|
270
221
|
}
|
|
271
222
|
|
|
272
223
|
async function runPromote(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
|
|
273
|
-
const promoted = await promoteJob(driver, requireIdPositional(ctx, 'promote'));
|
|
224
|
+
const promoted = await promoteJob(driver, requireIdPositional(ctx, 'promote', driver));
|
|
274
225
|
return {
|
|
275
226
|
ok: true,
|
|
276
227
|
command: 'jobs',
|
|
@@ -298,59 +249,6 @@ async function runPause(
|
|
|
298
249
|
};
|
|
299
250
|
}
|
|
300
251
|
|
|
301
|
-
/**
|
|
302
|
-
* A skipped candidate is not an error — a job whose `runAt` has not arrived is unclaimable by
|
|
303
|
-
* design — so it carries no `X_*` finding. It still fails the command: `x jobs drain` is run to
|
|
304
|
-
* empty a driver, and a partial move that exited 0 would read as "the queue is clear".
|
|
305
|
-
*
|
|
306
|
-
* Exported, and separate from the flag reading above it, because every target the flag now accepts
|
|
307
|
-
* needs a server: a test can produce a real outcome from two drivers and render THAT, where
|
|
308
|
-
* driving the whole command would need a redis or a nats to move anything at all.
|
|
309
|
-
*/
|
|
310
|
-
export function drainResult(outcome: DrainOutcome): CommandResult {
|
|
311
|
-
const dryRun = outcome.dryRun;
|
|
312
|
-
const findings = outcome.failures.map((failure) => failure.finding);
|
|
313
|
-
const lines: string[] = [];
|
|
314
|
-
if (outcome.skipped.length > 0) {
|
|
315
|
-
const count = outcome.skipped.length;
|
|
316
|
-
lines.push(` ${msg('cli.jobs.skipped', { count, from: outcome.from })}`);
|
|
317
|
-
for (const skip of outcome.skipped) {
|
|
318
|
-
lines.push(` ${skip.id} ${skip.name} (${skip.queue}) ${skip.state} ${skip.reason}`);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
const partial = outcome.skipped.length > 0;
|
|
322
|
-
return {
|
|
323
|
-
ok: findings.length === 0 && !partial,
|
|
324
|
-
command: 'jobs',
|
|
325
|
-
summary: msg(partial ? 'cli.jobs.drainedPartial' : 'cli.jobs.drained', {
|
|
326
|
-
count: dryRun ? outcome.candidates.length : outcome.moved.length,
|
|
327
|
-
from: outcome.from,
|
|
328
|
-
to: outcome.to,
|
|
329
|
-
skipped: outcome.skipped.length,
|
|
330
|
-
}),
|
|
331
|
-
lines,
|
|
332
|
-
findings,
|
|
333
|
-
data: {
|
|
334
|
-
from: outcome.from,
|
|
335
|
-
to: outcome.to,
|
|
336
|
-
dryRun: outcome.dryRun,
|
|
337
|
-
candidates: outcome.candidates.map(jobRecordToJson),
|
|
338
|
-
moved: outcome.moved.map(jobRecordToJson),
|
|
339
|
-
skipped: outcome.skipped.map(drainSkipToJson),
|
|
340
|
-
failures: outcome.failures.map(drainFailureToJson),
|
|
341
|
-
},
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/** The move, then the render. The target is built ABOVE `withJobDriver` — see `run` below. */
|
|
346
|
-
async function runDrain(
|
|
347
|
-
driver: JobDriver,
|
|
348
|
-
target: JobDriver,
|
|
349
|
-
ctx: CommandContext,
|
|
350
|
-
): Promise<CommandResult> {
|
|
351
|
-
return drainResult(await drainJobs(driver, target, flagBool(ctx.args, 'dry-run')));
|
|
352
|
-
}
|
|
353
|
-
|
|
354
252
|
/** The subcommands that answer a `JobTrace`. `ls`, `rm`, `promote` and the rest read rows only. */
|
|
355
253
|
const TRACE_SUBCOMMANDS: ReadonlySet<string> = new Set(['show', 'retry', 'cancel']);
|
|
356
254
|
|
|
@@ -359,13 +257,11 @@ export const jobsCommand: CliCommand = {
|
|
|
359
257
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
360
258
|
const root = requireAppRoot('jobs', ctx.cwd).dir;
|
|
361
259
|
const sub = ctx.args.subcommand ?? 'ls';
|
|
362
|
-
// BEFORE `withJobDriver`, which boots the
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
// then refuses, which is a socket nothing closes.
|
|
368
|
-
const target = sub === 'drain' ? buildDrainTarget(flagString(ctx.args, 'to'), ctx.env) : null;
|
|
260
|
+
// BEFORE `withJobDriver`, which boots the source queue: the answer needs no server, so a box
|
|
261
|
+
// whose database is down gets it rather than the boot failure of a queue never to be used —
|
|
262
|
+
// and nothing is leased. 24.x's `--to`/`--dry-run` are deleted, so the parser refuses them
|
|
263
|
+
// (`X_CLI_BAD_FLAG`); only a bare `x jobs drain` reaches this answer.
|
|
264
|
+
if (sub === 'drain') throw plannedSubcommand('jobs', 'drain');
|
|
369
265
|
// A TRACE is the queue row projected through the job's own declaration — its concurrency key,
|
|
370
266
|
// its retry schedule — and a declaration exists in this process only once the app is loaded.
|
|
371
267
|
// Unloaded, `show` answered `concurrencyKey: null` and `retryDelaysMs: []` for every job of
|
|
@@ -379,13 +275,10 @@ export const jobsCommand: CliCommand = {
|
|
|
379
275
|
if (sub === 'rm') return runRm(driver, ctx);
|
|
380
276
|
if (sub === 'promote') return runPromote(driver, ctx);
|
|
381
277
|
if (sub === 'pause' || sub === 'resume') return runPause(driver, ctx, sub);
|
|
382
|
-
if (target !== null) return runDrain(driver, target, ctx);
|
|
383
278
|
return runLs(driver, ctx);
|
|
384
279
|
});
|
|
385
280
|
},
|
|
386
281
|
};
|
|
387
282
|
|
|
388
|
-
export type { DrainFailure, DrainOutcome, DrainSkip } from './jobs-drain';
|
|
389
|
-
export { drainJobs } from './jobs-drain';
|
|
390
283
|
export type { JobsListFilter, JobsListResult } from './jobs-report';
|
|
391
284
|
export { listJobs, retryJob, showJob } from './jobs-report';
|
package/src/cmd-manifest-spec.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { CommandSpec } from './parse';
|
|
|
6
6
|
export const manifestSpec: CommandSpec = {
|
|
7
7
|
name: 'manifest',
|
|
8
8
|
summary: 'regenerate x.manifest.json and openapi.json from the code',
|
|
9
|
-
usage: 'x manifest [--check] [--json]',
|
|
9
|
+
usage: 'x manifest [--check] [--no-openapi] [--json]',
|
|
10
10
|
requiresApp: true,
|
|
11
11
|
flags: [
|
|
12
12
|
{ name: 'check', type: 'boolean', summary: 'fail if the committed files are stale' },
|
package/src/cmd-manifest.ts
CHANGED
|
@@ -4,16 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import type { Manifest } from '@ultimat3/manifest';
|
|
7
|
-
import {
|
|
7
|
+
import { MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
8
8
|
import { writeAppArtifacts } from './app-artifacts';
|
|
9
9
|
import { appManifest } from './app-manifest';
|
|
10
|
-
import { openApiStaleness } from './app-openapi';
|
|
11
10
|
import { requireAppRoot } from './app-root';
|
|
12
11
|
import { manifestSpec } from './cmd-manifest-spec';
|
|
13
12
|
import type { CliCommand, CommandContext } from './command';
|
|
13
|
+
import { manifestStaleness } from './manifest-staleness';
|
|
14
14
|
import { msg } from './messages';
|
|
15
|
-
import type { CommandResult,
|
|
16
|
-
import { findingFrom } from './output';
|
|
15
|
+
import type { CommandResult, JsonValue } from './output';
|
|
17
16
|
import { flagBool } from './parse';
|
|
18
17
|
|
|
19
18
|
const countsOf = (manifest: Manifest): JsonValue => ({
|
|
@@ -27,21 +26,6 @@ const countsOf = (manifest: Manifest): JsonValue => ({
|
|
|
27
26
|
policies: manifest.policies.length,
|
|
28
27
|
});
|
|
29
28
|
|
|
30
|
-
/**
|
|
31
|
-
* The identical comparison `x verify`'s `manifest` step makes, through the identical function.
|
|
32
|
-
* A buildId equality test here would answer "fresh" for a hand-edited file whose body no longer
|
|
33
|
-
* hashes to the id it carries — and two commands giving two answers about one file is itself the
|
|
34
|
-
* drift the manifest exists to prevent.
|
|
35
|
-
*/
|
|
36
|
-
async function staleness(root: string, manifest: Manifest): Promise<Finding | undefined> {
|
|
37
|
-
try {
|
|
38
|
-
await assertNoDrift({ manifest, path: join(root, MANIFEST_FILENAME) });
|
|
39
|
-
return undefined;
|
|
40
|
-
} catch (error) {
|
|
41
|
-
return { ...findingFrom(error), at: MANIFEST_FILENAME };
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
29
|
export const manifestCommand: CliCommand = {
|
|
46
30
|
spec: manifestSpec,
|
|
47
31
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
@@ -50,12 +34,9 @@ export const manifestCommand: CliCommand = {
|
|
|
50
34
|
const counts = countsOf(manifest);
|
|
51
35
|
|
|
52
36
|
if (flagBool(ctx.args, 'check')) {
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
const stale =
|
|
56
|
-
...[await staleness(root, manifest)].filter((one) => one !== undefined),
|
|
57
|
-
...(await openApiStaleness(root, manifest)),
|
|
58
|
-
];
|
|
37
|
+
// The gate's own check, through its function (`manifest-staleness.ts`): both files this
|
|
38
|
+
// command writes, and the same code for each condition `x verify`'s `manifest` step gives.
|
|
39
|
+
const stale = await manifestStaleness(root, manifest);
|
|
59
40
|
return {
|
|
60
41
|
ok: stale.length === 0 && findings.length === 0,
|
|
61
42
|
command: 'manifest',
|
package/src/cmd-mcp.ts
CHANGED
|
@@ -3,17 +3,24 @@
|
|
|
3
3
|
// supplies only the app, the caller and the socket. A tool answered here would be a second answer
|
|
4
4
|
// to a question the framework already answers.
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
markListening,
|
|
8
|
+
nanoid,
|
|
9
|
+
renderFixShellArg,
|
|
10
|
+
timingSafeEqual,
|
|
11
|
+
UltimateError,
|
|
12
|
+
} from '@ultimat3/core';
|
|
13
|
+
import { mcpHttpRoute, type StdioTransportInput, serveStdio } from '@ultimat3/mcp';
|
|
8
14
|
import { requireAppRoot } from './app-root';
|
|
9
15
|
import { DEFAULT_PORT, mcpSpec } from './cmd-mcp-spec';
|
|
10
16
|
import type { CliCommand, CommandContext } from './command';
|
|
11
17
|
import { BadFlagError } from './errors';
|
|
12
|
-
import { intFlagOr, PORT_RANGE } from './flag-number';
|
|
18
|
+
import { intFlagOr, neighbouringPort, PORT_RANGE } from './flag-number';
|
|
13
19
|
import { holdUntilShutdown } from './hold';
|
|
14
20
|
import type { CliMcpServer } from './mcp-host';
|
|
15
21
|
import { createDevMcpServer, DEV_TOOL_SCOPES } from './mcp-host';
|
|
16
22
|
import { msg } from './messages';
|
|
23
|
+
import { isAddressInUse } from './metrics-endpoint';
|
|
17
24
|
import type { CommandResult } from './output';
|
|
18
25
|
import { flagString } from './parse';
|
|
19
26
|
|
|
@@ -30,8 +37,12 @@ const isTransport = (value: string): value is Transport =>
|
|
|
30
37
|
|
|
31
38
|
/** The catalog, from the server's own registry — never a list kept here. */
|
|
32
39
|
async function catalog(host: CliMcpServer): Promise<CommandResult> {
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
let tools: ReturnType<CliMcpServer['server']['list']>;
|
|
41
|
+
try {
|
|
42
|
+
tools = host.server.list(host.caller);
|
|
43
|
+
} finally {
|
|
44
|
+
await host.close();
|
|
45
|
+
}
|
|
35
46
|
return {
|
|
36
47
|
ok: true,
|
|
37
48
|
command: 'mcp',
|
|
@@ -56,6 +67,21 @@ const notFound = (path: string): Response =>
|
|
|
56
67
|
{ status: 404, headers: { 'content-type': 'application/json' } },
|
|
57
68
|
);
|
|
58
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The MCP port is bound already — by another `x mcp serve`, or by the inspector, whose default
|
|
72
|
+
* 9229 this one shares. `metrics-endpoint.ts`'s pattern: an `EADDRINUSE` throw becomes the
|
|
73
|
+
* framework's `X_PORT_IN_USE`, with the invocation one port over.
|
|
74
|
+
*/
|
|
75
|
+
export class McpPortInUseError extends UltimateError {
|
|
76
|
+
constructor(input: { port: number }) {
|
|
77
|
+
super({
|
|
78
|
+
code: 'X_PORT_IN_USE',
|
|
79
|
+
cause: `port ${String(input.port)} is already bound, so x mcp serve could not open its HTTP transport`,
|
|
80
|
+
fix: `x mcp serve --transport http --port ${renderFixShellArg(String(neighbouringPort(input.port)), '9230')}`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
/** A running HTTP transport. `stop()` releases the socket AND the host's lazily booted services. */
|
|
60
86
|
export interface McpHttpServer {
|
|
61
87
|
readonly result: CommandResult;
|
|
@@ -70,7 +96,7 @@ export interface McpHttpServer {
|
|
|
70
96
|
* directory outlive `run()` otherwise, and nothing — a test, an embedding caller, or a signal —
|
|
71
97
|
* could ever release them.
|
|
72
98
|
*/
|
|
73
|
-
export function startMcpHttp(host: CliMcpServer, port: number): McpHttpServer {
|
|
99
|
+
export async function startMcpHttp(host: CliMcpServer, port: number): Promise<McpHttpServer> {
|
|
74
100
|
const token = nanoid(32);
|
|
75
101
|
const route = mcpHttpRoute({
|
|
76
102
|
server: host.server,
|
|
@@ -83,17 +109,26 @@ export function startMcpHttp(host: CliMcpServer, port: number): McpHttpServer {
|
|
|
83
109
|
? { actor: host.caller.actor, scopes: DEV_TOOL_SCOPES }
|
|
84
110
|
: null,
|
|
85
111
|
});
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
112
|
+
let handle: ReturnType<typeof Bun.serve>;
|
|
113
|
+
try {
|
|
114
|
+
handle = Bun.serve({
|
|
115
|
+
port,
|
|
116
|
+
hostname: 'localhost',
|
|
117
|
+
fetch: (request: Request, server): Response | Promise<Response> => {
|
|
118
|
+
const url = new URL(request.url);
|
|
119
|
+
if (request.method !== route.method || url.pathname !== route.path) {
|
|
120
|
+
return notFound(route.path);
|
|
121
|
+
}
|
|
122
|
+
// The socket's peer: failed tokens are metered per address, and only the server knows it.
|
|
123
|
+
return route.handle(request, { address: server.requestIP(request)?.address });
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
} catch (error) {
|
|
127
|
+
// The host booted before the bind; a refusal that left it open would leak its database.
|
|
128
|
+
await host.close();
|
|
129
|
+
if (isAddressInUse(error)) throw new McpPortInUseError({ port });
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
97
132
|
// Announces the socket as this process's own, so a caller on it is never mistaken for egress.
|
|
98
133
|
const stopListening = markListening(handle.url.origin);
|
|
99
134
|
const url = `${handle.url.origin}${route.path}`;
|
|
@@ -131,10 +166,24 @@ export const stdioResult = (tools: number): CommandResult => ({
|
|
|
131
166
|
stream: 'stderr',
|
|
132
167
|
});
|
|
133
168
|
|
|
134
|
-
/**
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
169
|
+
/** The pipe a session reads, when it is not this process's own stdin — a test's script. */
|
|
170
|
+
export type StdioPipe = Pick<StdioTransportInput, 'input' | 'write'>;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* stdout is the WIRE: `serveStdio` owns fd 1 for as long as the peer holds stdin open. Exported
|
|
174
|
+
* with `pipe` so a test can end the session with a throw instead of a closed stdin.
|
|
175
|
+
*/
|
|
176
|
+
export async function serveOverStdio(
|
|
177
|
+
host: CliMcpServer,
|
|
178
|
+
pipe: StdioPipe = {},
|
|
179
|
+
): Promise<CommandResult> {
|
|
180
|
+
// `finally`, as `startMcpHttp`'s refused bind does: a session that throws must not leave the
|
|
181
|
+
// host's PGlite data directory held by a process about to report the failure.
|
|
182
|
+
try {
|
|
183
|
+
await serveStdio({ server: host.server, caller: host.caller, ...pipe });
|
|
184
|
+
} finally {
|
|
185
|
+
await host.close();
|
|
186
|
+
}
|
|
138
187
|
return stdioResult(host.tools.length);
|
|
139
188
|
}
|
|
140
189
|
|
|
@@ -179,7 +228,7 @@ export const mcpCommand: CliCommand = {
|
|
|
179
228
|
// starts is what releases the socket and the database. Registering a shutdown hook and
|
|
180
229
|
// returning was the older shape — nothing installed a signal handler, so the hook was never
|
|
181
230
|
// reached and the exit code closed the socket the line above had just announced.
|
|
182
|
-
const served = startMcpHttp(host, port);
|
|
231
|
+
const served = await startMcpHttp(host, port);
|
|
183
232
|
return { ...served.result, hold: holdUntilShutdown('mcp', () => served.stop()) };
|
|
184
233
|
},
|
|
185
234
|
};
|