@ontrails/trails 0.2.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/CHANGELOG.md +1906 -0
- package/README.md +48 -0
- package/bin/trails.ts +3 -0
- package/package.json +57 -0
- package/src/app.ts +167 -0
- package/src/clack.ts +111 -0
- package/src/cli.ts +308 -0
- package/src/completions.ts +431 -0
- package/src/lifecycle-source-io.ts +33 -0
- package/src/load-app-mirror.ts +202 -0
- package/src/local-state-io.ts +129 -0
- package/src/mcp-app.ts +42 -0
- package/src/mcp-options.ts +92 -0
- package/src/mcp.ts +8 -0
- package/src/project-writes.ts +377 -0
- package/src/regrade/audit.ts +571 -0
- package/src/regrade/config.ts +152 -0
- package/src/regrade/history.ts +636 -0
- package/src/regrade/lifecycle.ts +76 -0
- package/src/regrade/live-api-preserve.ts +123 -0
- package/src/regrade/plan-artifact.ts +515 -0
- package/src/regrade/plan-derivation.ts +301 -0
- package/src/regrade/prepared-run.ts +259 -0
- package/src/regrade/receipt-history.ts +446 -0
- package/src/regrade/source-transaction.ts +185 -0
- package/src/release/bindings.ts +58 -0
- package/src/release/changeset-packages.ts +99 -0
- package/src/release/check.ts +1191 -0
- package/src/release/cli-bundle.ts +575 -0
- package/src/release/config.ts +73 -0
- package/src/release/contract-facts.ts +425 -0
- package/src/release/homebrew.ts +221 -0
- package/src/release/index.ts +180 -0
- package/src/release/lock-roundtrip-smoke.ts +255 -0
- package/src/release/lock-roundtrip-workspace.ts +107 -0
- package/src/release/native-bun-publish.ts +964 -0
- package/src/release/native-bun-registry.ts +848 -0
- package/src/release/notes-cli.ts +171 -0
- package/src/release/notes.ts +390 -0
- package/src/release/pack-coherence.ts +455 -0
- package/src/release/package-route-facts.ts +146 -0
- package/src/release/packed-artifacts-smoke.ts +236 -0
- package/src/release/policy.ts +1780 -0
- package/src/release/semver.ts +104 -0
- package/src/release/smoke.ts +56 -0
- package/src/release/stable-version-release.ts +80 -0
- package/src/release/wayfinder-dogfood-smoke.ts +762 -0
- package/src/release/zero-line-transition.ts +68 -0
- package/src/retired-topo-command.ts +36 -0
- package/src/run-adapter-check.ts +76 -0
- package/src/run-argv.ts +133 -0
- package/src/run-collision.ts +126 -0
- package/src/run-completions-install.ts +179 -0
- package/src/run-example.ts +149 -0
- package/src/run-examples.ts +148 -0
- package/src/run-quiet.ts +75 -0
- package/src/run-regrade-progress.ts +47 -0
- package/src/run-release-check.ts +74 -0
- package/src/run-schema.ts +74 -0
- package/src/run-trace.ts +273 -0
- package/src/run-warden.ts +39 -0
- package/src/run-watch-project.ts +52 -0
- package/src/run-watch.ts +381 -0
- package/src/run-wayfind-outline.ts +170 -0
- package/src/scaffold-version-sync.ts +183 -0
- package/src/scaffold-versions.generated.ts +12 -0
- package/src/trails/adapter-check.ts +244 -0
- package/src/trails/add-surface.ts +816 -0
- package/src/trails/add-trail.ts +141 -0
- package/src/trails/add-verify.ts +252 -0
- package/src/trails/compile.ts +118 -0
- package/src/trails/completions-complete.ts +236 -0
- package/src/trails/completions.ts +47 -0
- package/src/trails/config-explain.ts +43 -0
- package/src/trails/create-adapter.ts +785 -0
- package/src/trails/create-scaffold.ts +1215 -0
- package/src/trails/create-versions.ts +62 -0
- package/src/trails/create.ts +652 -0
- package/src/trails/deprecate.ts +59 -0
- package/src/trails/dev-clean.ts +80 -0
- package/src/trails/dev-reset.ts +48 -0
- package/src/trails/dev-stats.ts +71 -0
- package/src/trails/dev-support.ts +360 -0
- package/src/trails/doctor.ts +77 -0
- package/src/trails/draft-promote.ts +949 -0
- package/src/trails/guide.ts +106 -0
- package/src/trails/load-app.ts +1145 -0
- package/src/trails/operator-context.ts +66 -0
- package/src/trails/project-context-output.ts +304 -0
- package/src/trails/project-context.ts +613 -0
- package/src/trails/project.ts +65 -0
- package/src/trails/regrade.ts +4951 -0
- package/src/trails/release-check.ts +113 -0
- package/src/trails/release-smoke.ts +49 -0
- package/src/trails/revise.ts +53 -0
- package/src/trails/root-dir.ts +21 -0
- package/src/trails/run-example.ts +592 -0
- package/src/trails/run-examples.ts +149 -0
- package/src/trails/run.ts +496 -0
- package/src/trails/scaffold-json.ts +60 -0
- package/src/trails/scaffold-topo-identity.ts +479 -0
- package/src/trails/survey.ts +990 -0
- package/src/trails/topo-activation.ts +14 -0
- package/src/trails/topo-constants.ts +2 -0
- package/src/trails/topo-history.ts +47 -0
- package/src/trails/topo-output-schemas.ts +259 -0
- package/src/trails/topo-pin.ts +38 -0
- package/src/trails/topo-read-support.ts +368 -0
- package/src/trails/topo-reports.ts +809 -0
- package/src/trails/topo-store-support.ts +323 -0
- package/src/trails/topo-support.ts +247 -0
- package/src/trails/topo-unpin.ts +61 -0
- package/src/trails/topo.ts +92 -0
- package/src/trails/validate.ts +348 -0
- package/src/trails/version-lifecycle-support.ts +936 -0
- package/src/trails/warden-guide.ts +134 -0
- package/src/trails/warden.ts +598 -0
- package/src/trails/wayfind-diff.ts +716 -0
- package/src/trails/wayfind-outline.ts +876 -0
- package/src/trails/wayfind.ts +1319 -0
- package/src/versions.ts +31 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time release facts for moving the unpublished 1.0.0 source state onto
|
|
3
|
+
* the first normal 0.x line. Exact predicates keep ordinary downgrade guards
|
|
4
|
+
* intact and become inert after 0.2.0.
|
|
5
|
+
*/
|
|
6
|
+
export const INITIAL_ZERO_LINE_VERSION = '0.2.0';
|
|
7
|
+
|
|
8
|
+
const INITIAL_ZERO_LINE_PACKAGES = new Set([
|
|
9
|
+
'@ontrails/adapter-kit',
|
|
10
|
+
'@ontrails/cli',
|
|
11
|
+
'@ontrails/cloudflare',
|
|
12
|
+
'@ontrails/commander',
|
|
13
|
+
'@ontrails/config',
|
|
14
|
+
'@ontrails/core',
|
|
15
|
+
'@ontrails/drizzle',
|
|
16
|
+
'@ontrails/hono',
|
|
17
|
+
'@ontrails/http',
|
|
18
|
+
'@ontrails/library',
|
|
19
|
+
'@ontrails/logtape',
|
|
20
|
+
'@ontrails/mcp',
|
|
21
|
+
'@ontrails/observability',
|
|
22
|
+
'@ontrails/permits',
|
|
23
|
+
'@ontrails/pino',
|
|
24
|
+
'@ontrails/regrade',
|
|
25
|
+
'@ontrails/source',
|
|
26
|
+
'@ontrails/store',
|
|
27
|
+
'@ontrails/testing',
|
|
28
|
+
'@ontrails/topography',
|
|
29
|
+
'@ontrails/trails',
|
|
30
|
+
'@ontrails/vite',
|
|
31
|
+
'@ontrails/warden',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
export const isCompleteInitialZeroLinePackageSet = (
|
|
35
|
+
names: readonly string[]
|
|
36
|
+
): boolean =>
|
|
37
|
+
names.length === INITIAL_ZERO_LINE_PACKAGES.size &&
|
|
38
|
+
new Set(names).size === INITIAL_ZERO_LINE_PACKAGES.size &&
|
|
39
|
+
names.every((name) => INITIAL_ZERO_LINE_PACKAGES.has(name));
|
|
40
|
+
|
|
41
|
+
const BETA_LINE_VERSION = /^1\.0\.0-beta\.(?:0|[1-9]\d*)$/u;
|
|
42
|
+
|
|
43
|
+
export const isInitialZeroLineSourceTransition = (
|
|
44
|
+
previousVersion: string,
|
|
45
|
+
nextVersion: string,
|
|
46
|
+
name: string | undefined
|
|
47
|
+
): boolean =>
|
|
48
|
+
previousVersion === '1.0.0' &&
|
|
49
|
+
nextVersion === INITIAL_ZERO_LINE_VERSION &&
|
|
50
|
+
name !== undefined &&
|
|
51
|
+
INITIAL_ZERO_LINE_PACKAGES.has(name);
|
|
52
|
+
|
|
53
|
+
export const isInitialZeroLineRegistryTransition = ({
|
|
54
|
+
currentTagVersion,
|
|
55
|
+
expectedTag,
|
|
56
|
+
name,
|
|
57
|
+
targetVersion,
|
|
58
|
+
}: {
|
|
59
|
+
readonly currentTagVersion: string;
|
|
60
|
+
readonly expectedTag: string | undefined;
|
|
61
|
+
readonly name: string | undefined;
|
|
62
|
+
readonly targetVersion: string;
|
|
63
|
+
}): boolean =>
|
|
64
|
+
expectedTag === 'latest' &&
|
|
65
|
+
targetVersion === INITIAL_ZERO_LINE_VERSION &&
|
|
66
|
+
name !== undefined &&
|
|
67
|
+
INITIAL_ZERO_LINE_PACKAGES.has(name) &&
|
|
68
|
+
BETA_LINE_VERSION.test(currentTagVersion);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const retiredTopoCommandReplacements = {
|
|
2
|
+
check: 'trails validate',
|
|
3
|
+
compile: 'trails compile',
|
|
4
|
+
verify: 'trails validate',
|
|
5
|
+
} as const;
|
|
6
|
+
|
|
7
|
+
export type RetiredTopoCommand = keyof typeof retiredTopoCommandReplacements;
|
|
8
|
+
|
|
9
|
+
export interface RetiredTopoCommandDiagnostic {
|
|
10
|
+
readonly attempted: `trails topo ${RetiredTopoCommand}`;
|
|
11
|
+
readonly message: string;
|
|
12
|
+
readonly replacement: (typeof retiredTopoCommandReplacements)[RetiredTopoCommand];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const isRetiredTopoCommand = (
|
|
16
|
+
command: string | undefined
|
|
17
|
+
): command is RetiredTopoCommand =>
|
|
18
|
+
command !== undefined && command in retiredTopoCommandReplacements;
|
|
19
|
+
|
|
20
|
+
export const getRetiredTopoCommandDiagnostic = (
|
|
21
|
+
argv: readonly string[]
|
|
22
|
+
): RetiredTopoCommandDiagnostic | null => {
|
|
23
|
+
const [command, subcommand] = argv.slice(2);
|
|
24
|
+
if (command !== 'topo' || !isRetiredTopoCommand(subcommand)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const replacement = retiredTopoCommandReplacements[subcommand];
|
|
29
|
+
const attempted = `trails topo ${subcommand}` as const;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
attempted,
|
|
33
|
+
message: `"${attempted}" was retired. Use "${replacement}" instead.\nTopography artifact commands now live at the top level: "trails compile", "trails validate", and "trails diff". "trails topo" is for topo-store history, pin, and unpin.`,
|
|
34
|
+
replacement,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ActionResultContext } from '@ontrails/cli';
|
|
2
|
+
import { deriveOutputMode } from '@ontrails/cli';
|
|
3
|
+
|
|
4
|
+
interface AdapterCheckResultValue {
|
|
5
|
+
readonly formatted: string;
|
|
6
|
+
readonly passed: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const isAdapterCheckResultValue = (
|
|
10
|
+
value: unknown
|
|
11
|
+
): value is AdapterCheckResultValue => {
|
|
12
|
+
if (typeof value !== 'object' || value === null) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
const candidate = value as Record<string, unknown>;
|
|
16
|
+
return (
|
|
17
|
+
typeof candidate['formatted'] === 'string' &&
|
|
18
|
+
typeof candidate['passed'] === 'boolean'
|
|
19
|
+
);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const wantsStructuredOutput = (ctx: ActionResultContext): boolean =>
|
|
23
|
+
deriveOutputMode(ctx.flags, ctx.topoName).mode !== 'text';
|
|
24
|
+
|
|
25
|
+
const isAdapterCheckTrail = (ctx: ActionResultContext): boolean =>
|
|
26
|
+
ctx.trail.id === 'adapter.check';
|
|
27
|
+
|
|
28
|
+
const readAdapterCheckResultValue = (
|
|
29
|
+
ctx: ActionResultContext
|
|
30
|
+
): AdapterCheckResultValue | undefined => {
|
|
31
|
+
if (!isAdapterCheckTrail(ctx) || ctx.result.isErr()) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return isAdapterCheckResultValue(ctx.result.value)
|
|
36
|
+
? ctx.result.value
|
|
37
|
+
: undefined;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const applyAdapterCheckExitCode = (
|
|
41
|
+
ctx: ActionResultContext
|
|
42
|
+
): boolean => {
|
|
43
|
+
if (!isAdapterCheckTrail(ctx)) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (ctx.result.isErr()) {
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const value = readAdapterCheckResultValue(ctx);
|
|
53
|
+
if (!value) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
process.exitCode = value.passed ? 0 : 1;
|
|
58
|
+
return true;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const tryAdapterCheckOutput = (ctx: ActionResultContext): boolean => {
|
|
62
|
+
const value = readAdapterCheckResultValue(ctx);
|
|
63
|
+
if (!value) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
applyAdapterCheckExitCode(ctx);
|
|
68
|
+
if (wantsStructuredOutput(ctx)) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (value.formatted.length > 0) {
|
|
73
|
+
process.stdout.write(`${value.formatted}\n`);
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
};
|
package/src/run-argv.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/** Shared pre-surface parsers for operator selection and `trails run`. */
|
|
2
|
+
|
|
3
|
+
const SELECTION_FLAGS: ReadonlySet<string> = new Set([
|
|
4
|
+
'--app',
|
|
5
|
+
'--module',
|
|
6
|
+
'--root-dir',
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
const RUN_FLAGS_WITH_VALUES: ReadonlySet<string> = new Set([
|
|
10
|
+
'--app',
|
|
11
|
+
'--input',
|
|
12
|
+
'--input-json',
|
|
13
|
+
'--module',
|
|
14
|
+
'--output',
|
|
15
|
+
'--root-dir',
|
|
16
|
+
'--token',
|
|
17
|
+
'--permit',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const RUN_SHORT_FLAGS_WITH_VALUES: ReadonlySet<string> = new Set(['-o']);
|
|
21
|
+
|
|
22
|
+
export interface ArgvSelectionControls {
|
|
23
|
+
readonly app?: string | undefined;
|
|
24
|
+
readonly module?: string | undefined;
|
|
25
|
+
readonly rootDir?: string | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ParsedRunArgv {
|
|
29
|
+
readonly positionals: readonly string[];
|
|
30
|
+
readonly selection: ArgvSelectionControls;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const assignSelection = (
|
|
34
|
+
selection: { app?: string; module?: string; rootDir?: string },
|
|
35
|
+
flag: string,
|
|
36
|
+
value: string
|
|
37
|
+
): void => {
|
|
38
|
+
if (flag === '--app') {
|
|
39
|
+
selection.app = value;
|
|
40
|
+
} else if (flag === '--module') {
|
|
41
|
+
selection.module = value;
|
|
42
|
+
} else if (flag === '--root-dir') {
|
|
43
|
+
selection.rootDir = value;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read typed operator selection controls from any partial command argv.
|
|
49
|
+
*
|
|
50
|
+
* Separated and inline values are equivalent. Repeated controls use the last
|
|
51
|
+
* completed value, matching the command surface's normal parsing contract.
|
|
52
|
+
*/
|
|
53
|
+
export const parseSelectionControls = (
|
|
54
|
+
args: readonly string[]
|
|
55
|
+
): ArgvSelectionControls => {
|
|
56
|
+
const selection: {
|
|
57
|
+
app?: string;
|
|
58
|
+
module?: string;
|
|
59
|
+
rootDir?: string;
|
|
60
|
+
} = {};
|
|
61
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
62
|
+
const arg = args[index];
|
|
63
|
+
if (arg === undefined || !arg.startsWith('--')) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const separator = arg.indexOf('=');
|
|
67
|
+
const flag = separator === -1 ? arg : arg.slice(0, separator);
|
|
68
|
+
if (!SELECTION_FLAGS.has(flag)) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (separator === -1) {
|
|
72
|
+
const value = args[index + 1];
|
|
73
|
+
if (value !== undefined) {
|
|
74
|
+
assignSelection(selection, flag, value);
|
|
75
|
+
index += 1;
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
assignSelection(selection, flag, arg.slice(separator + 1));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return selection;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Normalize a partial or complete argv slice containing `run`.
|
|
86
|
+
*
|
|
87
|
+
* Known value flags are removed from the positional stream. Config-owned
|
|
88
|
+
* selection controls are retained so pre-surface consumers resolve the same
|
|
89
|
+
* app as the eventual run command. Both separated and inline long values are
|
|
90
|
+
* accepted, and the last completed value wins.
|
|
91
|
+
*/
|
|
92
|
+
export const parseRunArgv = (args: readonly string[]): ParsedRunArgv => {
|
|
93
|
+
const runIndex = args.indexOf('run');
|
|
94
|
+
const positionals: string[] = [];
|
|
95
|
+
if (runIndex === -1) {
|
|
96
|
+
return { positionals, selection: {} };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const runArgs = args.slice(runIndex + 1);
|
|
100
|
+
const selection = parseSelectionControls(runArgs);
|
|
101
|
+
|
|
102
|
+
for (let index = 0; index < runArgs.length; index += 1) {
|
|
103
|
+
const arg = runArgs[index];
|
|
104
|
+
if (arg === undefined) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (arg.startsWith('--')) {
|
|
108
|
+
const separator = arg.indexOf('=');
|
|
109
|
+
const flag = separator === -1 ? arg : arg.slice(0, separator);
|
|
110
|
+
if (RUN_FLAGS_WITH_VALUES.has(flag) && separator === -1) {
|
|
111
|
+
const value = runArgs[index + 1];
|
|
112
|
+
if (value !== undefined) {
|
|
113
|
+
index += 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (arg.startsWith('-')) {
|
|
119
|
+
if (RUN_SHORT_FLAGS_WITH_VALUES.has(arg)) {
|
|
120
|
+
index += 1;
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
positionals.push(arg);
|
|
125
|
+
}
|
|
126
|
+
return { positionals, selection };
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** Read the trail targeted by a normalized run invocation. */
|
|
130
|
+
export const readRunTrailId = (args: readonly string[]): string | undefined => {
|
|
131
|
+
const [first, second] = parseRunArgv(args).positionals;
|
|
132
|
+
return first === 'examples' || first === 'example' ? second : first;
|
|
133
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-surface bridge for the `run` trail's collision UX.
|
|
3
|
+
*
|
|
4
|
+
* The `run` trail is surface-agnostic: when a trail id collides across two or
|
|
5
|
+
* more workspace apps and no `--app` override is provided, the trail returns
|
|
6
|
+
* `Result.err(AmbiguousError)` with the candidate app names in `error.context`.
|
|
7
|
+
*
|
|
8
|
+
* The CLI surface decides whether to prompt the user (TTY) or surface the
|
|
9
|
+
* error verbatim (non-TTY). This module owns that surface decision so the
|
|
10
|
+
* trail itself never reads `process.stdin.isTTY` or imports a prompt library.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ActionResultContext } from '@ontrails/cli';
|
|
14
|
+
import { AmbiguousError, executeTrail, isPlainObject } from '@ontrails/core';
|
|
15
|
+
import type { Result, Topo } from '@ontrails/core';
|
|
16
|
+
import * as clack from '@clack/prompts';
|
|
17
|
+
|
|
18
|
+
/** Runtime dependencies the wrapper resolves through; injectable for tests. */
|
|
19
|
+
export interface RunCollisionDeps {
|
|
20
|
+
readonly graph: Topo;
|
|
21
|
+
readonly isTTY?: () => boolean;
|
|
22
|
+
readonly promptForApp?: (
|
|
23
|
+
candidates: readonly string[],
|
|
24
|
+
trailId: string
|
|
25
|
+
) => Promise<string | undefined>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const defaultIsTTY = (): boolean => process.stdin.isTTY === true;
|
|
29
|
+
|
|
30
|
+
const defaultPromptForApp = async (
|
|
31
|
+
candidates: readonly string[],
|
|
32
|
+
trailId: string
|
|
33
|
+
): Promise<string | undefined> => {
|
|
34
|
+
const choice = await clack.select({
|
|
35
|
+
message: `Trail ID '${trailId}' is exposed by multiple apps. Choose one:`,
|
|
36
|
+
options: candidates.map((appName) => ({
|
|
37
|
+
label: appName,
|
|
38
|
+
value: appName,
|
|
39
|
+
})),
|
|
40
|
+
});
|
|
41
|
+
return clack.isCancel(choice) ? undefined : (choice as string);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const isAmbiguousCollision = (
|
|
45
|
+
ctx: ActionResultContext
|
|
46
|
+
): ctx is ActionResultContext & {
|
|
47
|
+
readonly result: { readonly error: AmbiguousError };
|
|
48
|
+
} =>
|
|
49
|
+
ctx.trail.id === 'run' &&
|
|
50
|
+
ctx.result.isErr() &&
|
|
51
|
+
ctx.result.error instanceof AmbiguousError;
|
|
52
|
+
|
|
53
|
+
const readCandidates = (error: AmbiguousError): readonly string[] => {
|
|
54
|
+
const ctx = error.context;
|
|
55
|
+
if (!isPlainObject(ctx)) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
const raw = ctx['candidates'];
|
|
59
|
+
if (!Array.isArray(raw)) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
return raw.filter((entry): entry is string => typeof entry === 'string');
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const readTrailId = (error: AmbiguousError): string | undefined => {
|
|
66
|
+
const ctx = error.context;
|
|
67
|
+
if (!isPlainObject(ctx)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const raw = ctx['trailId'];
|
|
71
|
+
return typeof raw === 'string' ? raw : undefined;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const hasAppOverride = (input: unknown): boolean =>
|
|
75
|
+
isPlainObject(input) && typeof input['app'] === 'string';
|
|
76
|
+
|
|
77
|
+
const mergeAppOverride = (
|
|
78
|
+
input: unknown,
|
|
79
|
+
app: string
|
|
80
|
+
): Record<string, unknown> => ({
|
|
81
|
+
...(isPlainObject(input) ? input : {}),
|
|
82
|
+
app,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Try to recover from an ambiguous-trail-id collision on the run trail.
|
|
87
|
+
*
|
|
88
|
+
* Returns the re-execution result when a TTY prompt yielded a chosen app, or
|
|
89
|
+
* `undefined` when there is nothing to recover (non-TTY, non-collision, or the
|
|
90
|
+
* user cancelled). The caller forwards `undefined` to the default result
|
|
91
|
+
* handler, which surfaces the error verbatim and maps it to exit code 1.
|
|
92
|
+
*/
|
|
93
|
+
export const tryRecoverFromRunCollision = async (
|
|
94
|
+
ctx: ActionResultContext,
|
|
95
|
+
deps: RunCollisionDeps
|
|
96
|
+
): Promise<Result<unknown, Error> | undefined> => {
|
|
97
|
+
if (!isAmbiguousCollision(ctx)) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (hasAppOverride(ctx.input)) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const isTTY = deps.isTTY ?? defaultIsTTY;
|
|
105
|
+
if (!isTTY()) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const { error } = ctx.result;
|
|
110
|
+
const candidates = readCandidates(error);
|
|
111
|
+
const trailId = readTrailId(error);
|
|
112
|
+
if (candidates.length === 0 || trailId === undefined) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const promptForApp = deps.promptForApp ?? defaultPromptForApp;
|
|
117
|
+
const chosen = await promptForApp(candidates, trailId);
|
|
118
|
+
if (chosen === undefined) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return await executeTrail(ctx.trail, mergeAppOverride(ctx.input, chosen), {
|
|
123
|
+
ctx: ctx.permit === undefined ? {} : { permit: ctx.permit },
|
|
124
|
+
topo: deps.graph,
|
|
125
|
+
});
|
|
126
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI bridge for installing shell completion scripts.
|
|
3
|
+
*
|
|
4
|
+
* This is intentionally not a trail: it resolves CLI-local defaults such as
|
|
5
|
+
* `$SHELL` and the user's home directory, then writes to the user's completion
|
|
6
|
+
* directory. The surface-agnostic trail remains `completions`, which renders a
|
|
7
|
+
* script string for any caller.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdir } from 'node:fs/promises';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
renderPublicSurfaceError,
|
|
16
|
+
Result,
|
|
17
|
+
ValidationError,
|
|
18
|
+
} from '@ontrails/core';
|
|
19
|
+
import type { Command } from 'commander';
|
|
20
|
+
|
|
21
|
+
import { renderCompletionScript } from './completions.js';
|
|
22
|
+
import type { CompletionShell } from './completions.js';
|
|
23
|
+
|
|
24
|
+
export const COMPLETIONS_BIN_NAME = 'trails';
|
|
25
|
+
|
|
26
|
+
const SHELLS = new Set<CompletionShell>(['bash', 'fish', 'zsh']);
|
|
27
|
+
|
|
28
|
+
const INSTALL_PATH_BY_SHELL: Readonly<Record<CompletionShell, string>> = {
|
|
29
|
+
bash: '.local/share/bash-completion/completions/trails',
|
|
30
|
+
fish: '.config/fish/completions/trails.fish',
|
|
31
|
+
zsh: '.local/share/zsh/site-functions/_trails',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface CompletionsInstallOptions {
|
|
35
|
+
readonly binName?: string | undefined;
|
|
36
|
+
readonly homeDir?: string | undefined;
|
|
37
|
+
readonly shell?: string | undefined;
|
|
38
|
+
readonly shellEnv?: string | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CompletionsInstallResult {
|
|
42
|
+
readonly created: boolean;
|
|
43
|
+
readonly message: string;
|
|
44
|
+
readonly path: string;
|
|
45
|
+
readonly shell: CompletionShell;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface StdoutLike {
|
|
49
|
+
write(chunk: string): unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface AttachCompletionsInstallOptions {
|
|
53
|
+
readonly binName?: string | undefined;
|
|
54
|
+
readonly homeDir?: string | undefined;
|
|
55
|
+
readonly shellEnv?: string | undefined;
|
|
56
|
+
readonly stdout?: StdoutLike | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const isCompletionShell = (value: string): value is CompletionShell =>
|
|
60
|
+
SHELLS.has(value as CompletionShell);
|
|
61
|
+
|
|
62
|
+
const detectShellFromEnv = (shellEnv: string): CompletionShell | null => {
|
|
63
|
+
if (shellEnv.length === 0) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const slashIndex = shellEnv.lastIndexOf('/');
|
|
67
|
+
const base = slashIndex === -1 ? shellEnv : shellEnv.slice(slashIndex + 1);
|
|
68
|
+
return isCompletionShell(base) ? base : null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const unsupportedShellMessage =
|
|
72
|
+
'Could not detect shell from $SHELL. Pass --shell with one of: bash, zsh, fish.';
|
|
73
|
+
|
|
74
|
+
const resolveTargetShell = (input: {
|
|
75
|
+
readonly shell?: string | undefined;
|
|
76
|
+
readonly shellEnv?: string | undefined;
|
|
77
|
+
}): Result<CompletionShell, ValidationError> => {
|
|
78
|
+
if (input.shell !== undefined) {
|
|
79
|
+
if (isCompletionShell(input.shell)) {
|
|
80
|
+
return Result.ok(input.shell);
|
|
81
|
+
}
|
|
82
|
+
return Result.err(
|
|
83
|
+
new ValidationError(
|
|
84
|
+
`Unsupported shell "${input.shell}". Pass one of: bash, zsh, fish.`
|
|
85
|
+
)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const envValue = input.shellEnv ?? process.env['SHELL'] ?? '';
|
|
89
|
+
const detected = detectShellFromEnv(envValue);
|
|
90
|
+
return detected === null
|
|
91
|
+
? Result.err(new ValidationError(unsupportedShellMessage))
|
|
92
|
+
: Result.ok(detected);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const fileExists = async (path: string): Promise<boolean> =>
|
|
96
|
+
await Bun.file(path).exists();
|
|
97
|
+
|
|
98
|
+
export const runCompletionsInstall = async (
|
|
99
|
+
options: CompletionsInstallOptions = {}
|
|
100
|
+
): Promise<Result<CompletionsInstallResult, Error>> => {
|
|
101
|
+
const shellResult = resolveTargetShell(options);
|
|
102
|
+
if (shellResult.isErr()) {
|
|
103
|
+
return shellResult;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const shell = shellResult.value;
|
|
107
|
+
const home = options.homeDir ?? homedir();
|
|
108
|
+
const path = join(home, INSTALL_PATH_BY_SHELL[shell]);
|
|
109
|
+
const scriptResult = renderCompletionScript(
|
|
110
|
+
shell,
|
|
111
|
+
options.binName ?? COMPLETIONS_BIN_NAME
|
|
112
|
+
);
|
|
113
|
+
if (scriptResult.isErr()) {
|
|
114
|
+
return scriptResult;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let existed: boolean;
|
|
118
|
+
try {
|
|
119
|
+
existed = await fileExists(path);
|
|
120
|
+
await mkdir(dirname(path), { recursive: true });
|
|
121
|
+
await Bun.write(path, scriptResult.value);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return Result.err(
|
|
124
|
+
error instanceof Error ? error : new Error(String(error))
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const created = !existed;
|
|
128
|
+
|
|
129
|
+
return Result.ok({
|
|
130
|
+
created,
|
|
131
|
+
message: created
|
|
132
|
+
? `Installed ${shell} completions to ${path}. Run \`exec $SHELL\` or restart your shell to activate.`
|
|
133
|
+
: `Updated ${shell} completions at ${path}.`,
|
|
134
|
+
path,
|
|
135
|
+
shell,
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const handleCliError = (error: unknown): void => {
|
|
140
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
141
|
+
const rendering = renderPublicSurfaceError('cli', err);
|
|
142
|
+
process.stderr.write(`Error: ${rendering.message}\n`);
|
|
143
|
+
process.exit(rendering.code);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const findCompletionsCommand = (program: Command): Command | undefined =>
|
|
147
|
+
program.commands.find((command) => command.name() === 'completions');
|
|
148
|
+
|
|
149
|
+
export const attachCompletionsInstallCommand = (
|
|
150
|
+
program: Command,
|
|
151
|
+
options: AttachCompletionsInstallOptions = {}
|
|
152
|
+
): void => {
|
|
153
|
+
const completionsCommand =
|
|
154
|
+
findCompletionsCommand(program) ??
|
|
155
|
+
program
|
|
156
|
+
.command('completions')
|
|
157
|
+
.description('Render and install shell completion scripts');
|
|
158
|
+
|
|
159
|
+
completionsCommand
|
|
160
|
+
.command('install')
|
|
161
|
+
.description('Install a shell completion script for the trails CLI')
|
|
162
|
+
.option(
|
|
163
|
+
'-s, --shell <shell>',
|
|
164
|
+
'Target shell; auto-detected from $SHELL when omitted.'
|
|
165
|
+
)
|
|
166
|
+
.action(async (flags: { readonly shell?: string | undefined }) => {
|
|
167
|
+
const result = await runCompletionsInstall({
|
|
168
|
+
binName: options.binName,
|
|
169
|
+
homeDir: options.homeDir,
|
|
170
|
+
shell: flags.shell,
|
|
171
|
+
shellEnv: options.shellEnv,
|
|
172
|
+
});
|
|
173
|
+
if (result.isErr()) {
|
|
174
|
+
handleCliError(result.error);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
(options.stdout ?? process.stdout).write(`${result.value.message}\n`);
|
|
178
|
+
});
|
|
179
|
+
};
|