@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,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stored-export pipeline for topo persistence.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from topo-support.ts to isolate store persistence concerns,
|
|
5
|
+
* keeping module boundaries clean.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Database } from 'bun:sqlite';
|
|
9
|
+
|
|
10
|
+
import type { Topo } from '@ontrails/core';
|
|
11
|
+
import {
|
|
12
|
+
ConflictError,
|
|
13
|
+
deriveTrailsDir,
|
|
14
|
+
InternalError,
|
|
15
|
+
openWriteTrailsDb,
|
|
16
|
+
Result,
|
|
17
|
+
TimeoutError,
|
|
18
|
+
} from '@ontrails/core';
|
|
19
|
+
import type {
|
|
20
|
+
LockManifest,
|
|
21
|
+
TopoGraph,
|
|
22
|
+
TopoGraphOverlayRegistration,
|
|
23
|
+
TopoSnapshot,
|
|
24
|
+
TrailsLock,
|
|
25
|
+
} from '@ontrails/topography';
|
|
26
|
+
import type { StoredTopoExport } from '@ontrails/topography/backend-support';
|
|
27
|
+
import {
|
|
28
|
+
annotateTopoGraphForces,
|
|
29
|
+
carryForwardTopoGraphForces,
|
|
30
|
+
deriveSourceFingerprint,
|
|
31
|
+
deriveTopoGraphDiff,
|
|
32
|
+
deriveTopoGraphHash,
|
|
33
|
+
isTopoArtifactRegenerationError,
|
|
34
|
+
readTopoGraph,
|
|
35
|
+
TRAILS_LOCK_SCHEMA_VERSION,
|
|
36
|
+
writeTrailsLock,
|
|
37
|
+
} from '@ontrails/topography';
|
|
38
|
+
import {
|
|
39
|
+
createStoredTopoSnapshot,
|
|
40
|
+
getStoredTopoExport,
|
|
41
|
+
} from '@ontrails/topography/backend-support';
|
|
42
|
+
|
|
43
|
+
import { removeRootRelativeFileIfPresent } from '../local-state-io.js';
|
|
44
|
+
|
|
45
|
+
import type { TopoExportReport } from './topo-support.js';
|
|
46
|
+
import {
|
|
47
|
+
deriveRootDir,
|
|
48
|
+
deriveTopoCounts,
|
|
49
|
+
readGitState,
|
|
50
|
+
} from './topo-support.js';
|
|
51
|
+
|
|
52
|
+
type OverlaysOption = readonly TopoGraphOverlayRegistration[];
|
|
53
|
+
|
|
54
|
+
const persistAndReadStoredExport = (
|
|
55
|
+
app: Topo,
|
|
56
|
+
db: ReturnType<typeof openWriteTrailsDb>,
|
|
57
|
+
rootDir: string,
|
|
58
|
+
options?:
|
|
59
|
+
| {
|
|
60
|
+
readonly overlays?: OverlaysOption | undefined;
|
|
61
|
+
}
|
|
62
|
+
| undefined
|
|
63
|
+
): Result<
|
|
64
|
+
{ snapshot: TopoSnapshot; storedExport: StoredTopoExport },
|
|
65
|
+
Error
|
|
66
|
+
> => {
|
|
67
|
+
const snapshotResult = createStoredTopoSnapshot(db, app, {
|
|
68
|
+
overlays: options?.overlays,
|
|
69
|
+
sourceFingerprint: deriveSourceFingerprint(rootDir),
|
|
70
|
+
...readGitState(rootDir),
|
|
71
|
+
...deriveTopoCounts(app),
|
|
72
|
+
});
|
|
73
|
+
if (snapshotResult.isErr()) {
|
|
74
|
+
return snapshotResult;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const snapshot = snapshotResult.value;
|
|
78
|
+
const storedExport = getStoredTopoExport(db, snapshot.id);
|
|
79
|
+
|
|
80
|
+
if (storedExport === undefined) {
|
|
81
|
+
return Result.err(
|
|
82
|
+
new InternalError(
|
|
83
|
+
`Missing stored topo export for snapshot "${snapshot.id}"`
|
|
84
|
+
)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return Result.ok({
|
|
89
|
+
snapshot,
|
|
90
|
+
storedExport,
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const asError = (error: unknown): Error =>
|
|
95
|
+
error instanceof Error ? error : new Error(String(error));
|
|
96
|
+
|
|
97
|
+
const readErrorCode = (error: unknown): string | undefined => {
|
|
98
|
+
if (typeof error !== 'object' || error === null || !('code' in error)) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
const { code } = error as { readonly code?: unknown };
|
|
102
|
+
return typeof code === 'string' ? code : undefined;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const isSqliteLockError = (error: unknown): boolean => {
|
|
106
|
+
const code = readErrorCode(error);
|
|
107
|
+
if (code === 'SQLITE_BUSY' || code === 'SQLITE_LOCKED') {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
return asError(error).message.match(/database is (locked|busy)/i) !== null;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const mapTopoExportError = (error: unknown): Error => {
|
|
114
|
+
if (!isSqliteLockError(error)) {
|
|
115
|
+
return error instanceof Error
|
|
116
|
+
? error
|
|
117
|
+
: new InternalError('Unable to write topo artifacts');
|
|
118
|
+
}
|
|
119
|
+
return new TimeoutError(
|
|
120
|
+
'Timed out waiting for the Trails topo store lock while compiling artifacts. Another topo write may be running; retry after it finishes.',
|
|
121
|
+
{
|
|
122
|
+
cause: asError(error),
|
|
123
|
+
context: {
|
|
124
|
+
operation: 'compile',
|
|
125
|
+
reason: 'sqlite-lock-contention',
|
|
126
|
+
resource: 'trails.db',
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const deriveCurrentTopoExport = (
|
|
133
|
+
app: Topo,
|
|
134
|
+
options?: {
|
|
135
|
+
readonly rootDir?: string;
|
|
136
|
+
readonly overlays?: OverlaysOption | undefined;
|
|
137
|
+
}
|
|
138
|
+
): Result<StoredTopoExport, Error> => {
|
|
139
|
+
const rootDir = deriveRootDir(options?.rootDir);
|
|
140
|
+
const db = new Database(':memory:');
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const derived = persistAndReadStoredExport(app, db, rootDir, {
|
|
144
|
+
overlays: options?.overlays,
|
|
145
|
+
});
|
|
146
|
+
return derived.isErr() ? derived : Result.ok(derived.value.storedExport);
|
|
147
|
+
} finally {
|
|
148
|
+
db.close();
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const readPreviousCommittedTopo = async (
|
|
153
|
+
rootDir: string
|
|
154
|
+
): Promise<TopoGraph | null> => {
|
|
155
|
+
try {
|
|
156
|
+
const rootTopo = await readTopoGraph({ dir: rootDir });
|
|
157
|
+
return rootTopo ?? readTopoGraph({ dir: deriveTrailsDir({ rootDir }) });
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (isTopoArtifactRegenerationError(error)) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const prepareStoredExportArtifacts = async (
|
|
167
|
+
storedExport: StoredTopoExport,
|
|
168
|
+
rootDir: string,
|
|
169
|
+
options?: { readonly force?: boolean | undefined }
|
|
170
|
+
): Promise<{
|
|
171
|
+
readonly hash: string;
|
|
172
|
+
readonly topoGraph: TopoGraph;
|
|
173
|
+
}> => {
|
|
174
|
+
const previousTopo = await readPreviousCommittedTopo(rootDir);
|
|
175
|
+
const nextTopo = JSON.parse(storedExport.topoGraphJson) as TopoGraph;
|
|
176
|
+
const diff =
|
|
177
|
+
previousTopo === null
|
|
178
|
+
? undefined
|
|
179
|
+
: deriveTopoGraphDiff(previousTopo, nextTopo);
|
|
180
|
+
if (diff !== undefined && diff.breaking.length > 0 && !options?.force) {
|
|
181
|
+
throw new ConflictError(
|
|
182
|
+
`Topo contains ${diff.breaking.length} breaking change(s). Add a version entry, revert the change, or rerun with --force.`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const topoGraphBase =
|
|
187
|
+
previousTopo === null
|
|
188
|
+
? nextTopo
|
|
189
|
+
: carryForwardTopoGraphForces(previousTopo, nextTopo);
|
|
190
|
+
const topoGraph =
|
|
191
|
+
diff === undefined || diff.breaking.length === 0
|
|
192
|
+
? topoGraphBase
|
|
193
|
+
: annotateTopoGraphForces(topoGraphBase, diff.breaking);
|
|
194
|
+
const hash = deriveTopoGraphHash(topoGraph);
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
hash,
|
|
198
|
+
topoGraph,
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const LEGACY_COMMITTED_ARTIFACTS = [
|
|
203
|
+
'.trails/topo.lock',
|
|
204
|
+
'.trails/trails.lock',
|
|
205
|
+
] as const;
|
|
206
|
+
|
|
207
|
+
const removeLegacyCommittedArtifacts = (
|
|
208
|
+
rootDir: string
|
|
209
|
+
): Result<void, Error> => {
|
|
210
|
+
for (const relativePath of LEGACY_COMMITTED_ARTIFACTS) {
|
|
211
|
+
const removed = removeRootRelativeFileIfPresent(rootDir, relativePath);
|
|
212
|
+
if (removed.isErr()) {
|
|
213
|
+
return removed;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return Result.ok();
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const writeStoredExportArtifacts = async (
|
|
221
|
+
storedExport: StoredTopoExport,
|
|
222
|
+
rootDir: string,
|
|
223
|
+
options?: { readonly force?: boolean | undefined }
|
|
224
|
+
): Promise<Pick<TopoExportReport, 'hash' | 'lockPath'>> => {
|
|
225
|
+
const prepared = await prepareStoredExportArtifacts(
|
|
226
|
+
storedExport,
|
|
227
|
+
rootDir,
|
|
228
|
+
options
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const lockManifest = JSON.parse(
|
|
232
|
+
storedExport.lockManifestJson
|
|
233
|
+
) as LockManifest;
|
|
234
|
+
// Omit wallclock provenance from the committed artifact so recompiling the
|
|
235
|
+
// same sources yields byte-identical lock files; the hash never covered it.
|
|
236
|
+
const { generatedAt: _generatedAt, ...committedTopoGraph } =
|
|
237
|
+
prepared.topoGraph;
|
|
238
|
+
const lockPath = await writeTrailsLock(
|
|
239
|
+
{
|
|
240
|
+
scope: lockManifest.scope,
|
|
241
|
+
summary: {
|
|
242
|
+
entities: prepared.topoGraph.entries.filter(
|
|
243
|
+
(entry) => entry.kind === 'entity'
|
|
244
|
+
).length,
|
|
245
|
+
resources: prepared.topoGraph.entries.filter(
|
|
246
|
+
(entry) => entry.kind === 'resource'
|
|
247
|
+
).length,
|
|
248
|
+
signals: prepared.topoGraph.entries.filter(
|
|
249
|
+
(entry) => entry.kind === 'signal'
|
|
250
|
+
).length,
|
|
251
|
+
trails: prepared.topoGraph.entries.filter(
|
|
252
|
+
(entry) => entry.kind === 'trail'
|
|
253
|
+
).length,
|
|
254
|
+
},
|
|
255
|
+
topoGraph: committedTopoGraph,
|
|
256
|
+
topoGraphHash: prepared.hash,
|
|
257
|
+
version: TRAILS_LOCK_SCHEMA_VERSION,
|
|
258
|
+
} as TrailsLock,
|
|
259
|
+
{ dir: rootDir }
|
|
260
|
+
);
|
|
261
|
+
const removedLegacyArtifacts = removeLegacyCommittedArtifacts(rootDir);
|
|
262
|
+
if (removedLegacyArtifacts.isErr()) {
|
|
263
|
+
throw removedLegacyArtifacts.error;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
hash: prepared.hash,
|
|
268
|
+
lockPath,
|
|
269
|
+
};
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
export const exportCurrentTopo = async (
|
|
273
|
+
app: Topo,
|
|
274
|
+
options?: {
|
|
275
|
+
readonly force?: boolean | undefined;
|
|
276
|
+
readonly rootDir?: string;
|
|
277
|
+
readonly overlays?: OverlaysOption | undefined;
|
|
278
|
+
}
|
|
279
|
+
): Promise<Result<TopoExportReport, Error>> => {
|
|
280
|
+
const rootDir = deriveRootDir(options?.rootDir);
|
|
281
|
+
let db: ReturnType<typeof openWriteTrailsDb> | undefined;
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const candidate = deriveCurrentTopoExport(app, {
|
|
285
|
+
overlays: options?.overlays,
|
|
286
|
+
rootDir,
|
|
287
|
+
});
|
|
288
|
+
if (candidate.isErr()) {
|
|
289
|
+
return candidate;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
await prepareStoredExportArtifacts(candidate.value, rootDir, {
|
|
294
|
+
force: options?.force,
|
|
295
|
+
});
|
|
296
|
+
} catch (error: unknown) {
|
|
297
|
+
return Result.err(mapTopoExportError(error));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
db = openWriteTrailsDb({ rootDir });
|
|
301
|
+
const persisted = persistAndReadStoredExport(app, db, rootDir, {
|
|
302
|
+
overlays: options?.overlays,
|
|
303
|
+
});
|
|
304
|
+
if (persisted.isErr()) {
|
|
305
|
+
return persisted;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const { snapshot, storedExport } = persisted.value;
|
|
309
|
+
let artifacts: Pick<TopoExportReport, 'hash' | 'lockPath'>;
|
|
310
|
+
try {
|
|
311
|
+
artifacts = await writeStoredExportArtifacts(storedExport, rootDir, {
|
|
312
|
+
force: options?.force,
|
|
313
|
+
});
|
|
314
|
+
} catch (error: unknown) {
|
|
315
|
+
return Result.err(mapTopoExportError(error));
|
|
316
|
+
}
|
|
317
|
+
return Result.ok({ ...artifacts, snapshot });
|
|
318
|
+
} catch (error: unknown) {
|
|
319
|
+
return Result.err(mapTopoExportError(error));
|
|
320
|
+
} finally {
|
|
321
|
+
db?.close();
|
|
322
|
+
}
|
|
323
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import { deriveTrailsDbPath } from '@ontrails/core';
|
|
4
|
+
import type { Topo } from '@ontrails/core';
|
|
5
|
+
import {
|
|
6
|
+
createTopoSnapshot as persistTopoSnapshot,
|
|
7
|
+
listTopoSnapshots as readTopoSnapshots,
|
|
8
|
+
pinTopoSnapshot,
|
|
9
|
+
unpinTopoSnapshot,
|
|
10
|
+
} from '@ontrails/topography';
|
|
11
|
+
import type { TopoSnapshot } from '@ontrails/topography';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
|
|
14
|
+
import { requireTrailRootDir } from './root-dir.js';
|
|
15
|
+
import type { BriefReport, SurveyListReport } from './topo-reports.js';
|
|
16
|
+
|
|
17
|
+
/** Output schema for a topo snapshot record. Shared across topo trails. */
|
|
18
|
+
export const topoSnapshotOutput = z.object({
|
|
19
|
+
createdAt: z.string(),
|
|
20
|
+
gitDirty: z.boolean(),
|
|
21
|
+
gitSha: z.string().optional(),
|
|
22
|
+
id: z.string(),
|
|
23
|
+
pinnedAs: z.string().optional(),
|
|
24
|
+
resourceCount: z.number(),
|
|
25
|
+
signalCount: z.number(),
|
|
26
|
+
sourceFingerprint: z.string().optional(),
|
|
27
|
+
trailCount: z.number(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_TOPO_HISTORY_LIMIT = 10;
|
|
31
|
+
export const LOCK_PATH = 'trails.lock';
|
|
32
|
+
export interface TopoSummaryReport {
|
|
33
|
+
readonly app: BriefReport;
|
|
34
|
+
readonly dbPath: string;
|
|
35
|
+
readonly list: SurveyListReport;
|
|
36
|
+
readonly lockExists: boolean;
|
|
37
|
+
readonly lockPath: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TopoHistoryReport {
|
|
41
|
+
readonly dbPath: string;
|
|
42
|
+
readonly limit: number;
|
|
43
|
+
readonly pinnedCount: number;
|
|
44
|
+
readonly snapshotCount: number;
|
|
45
|
+
readonly snapshots: TopoSnapshot[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface TopoExportReport {
|
|
49
|
+
readonly hash: string;
|
|
50
|
+
readonly lockPath: string;
|
|
51
|
+
readonly snapshot: TopoSnapshot;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TopoValidateReport {
|
|
55
|
+
readonly committedHash: string;
|
|
56
|
+
readonly currentHash: string;
|
|
57
|
+
readonly lockPath: string;
|
|
58
|
+
readonly stale: false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const deriveRootDir = (cwd?: string): string => requireTrailRootDir(cwd);
|
|
62
|
+
|
|
63
|
+
const safeGit = (cwd: string, args: readonly string[]): string | undefined => {
|
|
64
|
+
const proc = Bun.spawnSync({
|
|
65
|
+
cmd: ['git', '-C', cwd, ...args],
|
|
66
|
+
stderr: 'ignore',
|
|
67
|
+
stdout: 'pipe',
|
|
68
|
+
});
|
|
69
|
+
if (!proc.success) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
const text = Buffer.from(proc.stdout).toString('utf8').trim();
|
|
73
|
+
return text.length === 0 ? undefined : text;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const readGitState = (
|
|
77
|
+
rootDir: string
|
|
78
|
+
): { readonly gitDirty: boolean; readonly gitSha?: string } => {
|
|
79
|
+
const gitSha = safeGit(rootDir, ['rev-parse', 'HEAD']);
|
|
80
|
+
const status = safeGit(rootDir, ['status', '--porcelain']);
|
|
81
|
+
return {
|
|
82
|
+
gitDirty: (status?.length ?? 0) > 0,
|
|
83
|
+
...(gitSha === undefined ? {} : { gitSha }),
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const deriveTopoCounts = (
|
|
88
|
+
app: Topo
|
|
89
|
+
): Pick<TopoSnapshot, 'resourceCount' | 'signalCount' | 'trailCount'> => ({
|
|
90
|
+
resourceCount: app.resources.size,
|
|
91
|
+
signalCount: app.signals.size,
|
|
92
|
+
trailCount: app.trails.size,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const emptyTopoHistory = (
|
|
96
|
+
dbPath: string,
|
|
97
|
+
limit: number
|
|
98
|
+
): TopoHistoryReport => ({
|
|
99
|
+
dbPath,
|
|
100
|
+
limit,
|
|
101
|
+
pinnedCount: 0,
|
|
102
|
+
snapshotCount: 0,
|
|
103
|
+
snapshots: [],
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const collectTopoHistory = (
|
|
107
|
+
dbPath: string,
|
|
108
|
+
limit: number,
|
|
109
|
+
snapshots: readonly TopoSnapshot[]
|
|
110
|
+
): TopoHistoryReport => ({
|
|
111
|
+
dbPath,
|
|
112
|
+
limit,
|
|
113
|
+
pinnedCount: snapshots.filter((snapshot) => snapshot.pinnedAs !== undefined)
|
|
114
|
+
.length,
|
|
115
|
+
snapshotCount: snapshots.length,
|
|
116
|
+
snapshots: snapshots.slice(0, limit),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const buildSnapshotInput = (
|
|
120
|
+
app: Topo,
|
|
121
|
+
rootDir: string
|
|
122
|
+
): {
|
|
123
|
+
readonly gitDirty: boolean;
|
|
124
|
+
readonly gitSha?: string;
|
|
125
|
+
readonly resourceCount: number;
|
|
126
|
+
readonly signalCount: number;
|
|
127
|
+
readonly trailCount: number;
|
|
128
|
+
} => ({
|
|
129
|
+
...readGitState(rootDir),
|
|
130
|
+
...deriveTopoCounts(app),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Fresh app leases cache-bust the entry module, so use the process-global
|
|
134
|
+
// symbol registry to preserve this internal authoring marker across module
|
|
135
|
+
// instances. JSON derivation still omits symbol keys from the public example.
|
|
136
|
+
const currentAppExampleSelection = Symbol.for(
|
|
137
|
+
'@ontrails/trails/current-app-example-selection'
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
export type CurrentAppExampleSelection = 'app-root' | 'configured-project';
|
|
141
|
+
|
|
142
|
+
export const markCurrentAppExampleInput = <
|
|
143
|
+
const TInput extends Record<string, unknown>,
|
|
144
|
+
>(
|
|
145
|
+
input: TInput,
|
|
146
|
+
options?: { readonly selection?: CurrentAppExampleSelection }
|
|
147
|
+
): TInput =>
|
|
148
|
+
({
|
|
149
|
+
...input,
|
|
150
|
+
[currentAppExampleSelection]: options?.selection ?? 'app-root',
|
|
151
|
+
}) as TInput;
|
|
152
|
+
|
|
153
|
+
export const createCurrentAppExampleInput = (options?: {
|
|
154
|
+
readonly selection?: CurrentAppExampleSelection;
|
|
155
|
+
}): {
|
|
156
|
+
readonly module: string;
|
|
157
|
+
readonly rootDir: string;
|
|
158
|
+
} =>
|
|
159
|
+
markCurrentAppExampleInput(
|
|
160
|
+
{
|
|
161
|
+
module: './src/app.ts',
|
|
162
|
+
rootDir: '.',
|
|
163
|
+
},
|
|
164
|
+
options
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
export const readCurrentAppExampleSelection = (
|
|
168
|
+
input: unknown
|
|
169
|
+
): CurrentAppExampleSelection | undefined => {
|
|
170
|
+
if (typeof input !== 'object' || input === null) {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
const selection = (
|
|
174
|
+
input as { readonly [currentAppExampleSelection]?: unknown }
|
|
175
|
+
)[currentAppExampleSelection];
|
|
176
|
+
return selection === 'app-root' || selection === 'configured-project'
|
|
177
|
+
? selection
|
|
178
|
+
: undefined;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export const listTopoHistory = (options?: {
|
|
182
|
+
readonly limit?: number;
|
|
183
|
+
readonly rootDir?: string;
|
|
184
|
+
}): TopoHistoryReport => {
|
|
185
|
+
const rootDir = deriveRootDir(options?.rootDir);
|
|
186
|
+
const limit = options?.limit ?? DEFAULT_TOPO_HISTORY_LIMIT;
|
|
187
|
+
const dbPath = deriveTrailsDbPath({ rootDir });
|
|
188
|
+
if (!existsSync(dbPath)) {
|
|
189
|
+
return emptyTopoHistory(dbPath, limit);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return collectTopoHistory(dbPath, limit, readTopoSnapshots({ rootDir }));
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export const pinCurrentTopoSnapshot = (
|
|
196
|
+
app: Topo,
|
|
197
|
+
input: { readonly name: string; readonly rootDir?: string }
|
|
198
|
+
): { readonly snapshot: TopoSnapshot } => {
|
|
199
|
+
const rootDir = deriveRootDir(input.rootDir);
|
|
200
|
+
const created = persistTopoSnapshot(app, {
|
|
201
|
+
rootDir,
|
|
202
|
+
...buildSnapshotInput(app, rootDir),
|
|
203
|
+
});
|
|
204
|
+
if (created.isErr()) {
|
|
205
|
+
throw created.error;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const snapshot = pinTopoSnapshot(created.value.id, input.name, {
|
|
209
|
+
rootDir,
|
|
210
|
+
});
|
|
211
|
+
if (snapshot === undefined) {
|
|
212
|
+
throw new Error(`Missing topo snapshot "${created.value.id}" to pin`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { snapshot };
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export const removePinnedTopoSnapshot = (input: {
|
|
219
|
+
readonly dryRun: boolean;
|
|
220
|
+
readonly name: string;
|
|
221
|
+
readonly rootDir?: string;
|
|
222
|
+
}): {
|
|
223
|
+
readonly dryRun: boolean;
|
|
224
|
+
readonly removed: boolean;
|
|
225
|
+
readonly snapshot?: TopoSnapshot;
|
|
226
|
+
} => {
|
|
227
|
+
const rootDir = deriveRootDir(input.rootDir);
|
|
228
|
+
if (!existsSync(deriveTrailsDbPath({ rootDir }))) {
|
|
229
|
+
return { dryRun: input.dryRun, removed: false };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (input.dryRun) {
|
|
233
|
+
const snapshot = readTopoSnapshots({ pinned: true, rootDir }).find(
|
|
234
|
+
(candidate) => candidate.pinnedAs === input.name
|
|
235
|
+
);
|
|
236
|
+
return snapshot === undefined
|
|
237
|
+
? { dryRun: true, removed: false }
|
|
238
|
+
: { dryRun: true, removed: false, snapshot };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const snapshot = unpinTopoSnapshot(input.name, { rootDir });
|
|
242
|
+
return {
|
|
243
|
+
dryRun: false,
|
|
244
|
+
removed: snapshot !== undefined,
|
|
245
|
+
...(snapshot === undefined ? {} : { snapshot }),
|
|
246
|
+
};
|
|
247
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Result, ValidationError, trail } from '@ontrails/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createCurrentAppExampleInput,
|
|
6
|
+
removePinnedTopoSnapshot,
|
|
7
|
+
topoSnapshotOutput,
|
|
8
|
+
} from './topo-support.js';
|
|
9
|
+
import { resolveTrailRootDir } from './root-dir.js';
|
|
10
|
+
|
|
11
|
+
export const topoUnpinTrail = trail('topo.unpin', {
|
|
12
|
+
description: 'Remove a named topo pin',
|
|
13
|
+
examples: [
|
|
14
|
+
{
|
|
15
|
+
input: {
|
|
16
|
+
...createCurrentAppExampleInput(),
|
|
17
|
+
dryRun: true,
|
|
18
|
+
name: 'before-auth-refactor',
|
|
19
|
+
},
|
|
20
|
+
name: 'Preview pin removal',
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
implementation: (input, ctx) => {
|
|
24
|
+
if (input.dryRun !== true && input.yes !== true) {
|
|
25
|
+
return Result.err(
|
|
26
|
+
new ValidationError(
|
|
27
|
+
'Refusing to remove a pin without `--yes` or `--dry-run`.'
|
|
28
|
+
)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
|
|
33
|
+
if (rootDirResult.isErr()) {
|
|
34
|
+
return rootDirResult;
|
|
35
|
+
}
|
|
36
|
+
const rootDir = rootDirResult.value;
|
|
37
|
+
return Result.ok(
|
|
38
|
+
removePinnedTopoSnapshot({
|
|
39
|
+
dryRun: input.dryRun,
|
|
40
|
+
name: input.name,
|
|
41
|
+
rootDir,
|
|
42
|
+
})
|
|
43
|
+
);
|
|
44
|
+
},
|
|
45
|
+
input: z.object({
|
|
46
|
+
dryRun: z
|
|
47
|
+
.boolean()
|
|
48
|
+
.default(true)
|
|
49
|
+
.describe('Preview the removal without changing state'),
|
|
50
|
+
name: z.string().describe('Pin name'),
|
|
51
|
+
rootDir: z.string().optional().describe('Workspace root directory'),
|
|
52
|
+
yes: z.boolean().default(false).describe('Confirm destructive changes'),
|
|
53
|
+
}),
|
|
54
|
+
intent: 'destroy',
|
|
55
|
+
output: z.object({
|
|
56
|
+
dryRun: z.boolean(),
|
|
57
|
+
removed: z.boolean(),
|
|
58
|
+
snapshot: topoSnapshotOutput.optional(),
|
|
59
|
+
}),
|
|
60
|
+
permit: { scopes: ['topo:write'] },
|
|
61
|
+
});
|