@principal-ai/principal-studio-cli 0.35.3
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/README.md +172 -0
- package/dist/commands/agent-session.d.ts +3 -0
- package/dist/commands/agent-session.d.ts.map +1 -0
- package/dist/commands/agent-session.js +67 -0
- package/dist/commands/agent-sessions.d.ts +11 -0
- package/dist/commands/agent-sessions.d.ts.map +1 -0
- package/dist/commands/agent-sessions.js +39 -0
- package/dist/commands/auxiliary/index.d.ts +3 -0
- package/dist/commands/auxiliary/index.d.ts.map +1 -0
- package/dist/commands/auxiliary/index.js +9 -0
- package/dist/commands/auxiliary/validate.d.ts +3 -0
- package/dist/commands/auxiliary/validate.d.ts.map +1 -0
- package/dist/commands/auxiliary/validate.js +123 -0
- package/dist/commands/collector/check.d.ts +6 -0
- package/dist/commands/collector/check.d.ts.map +1 -0
- package/dist/commands/collector/check.js +153 -0
- package/dist/commands/collector/diagnose.d.ts +6 -0
- package/dist/commands/collector/diagnose.d.ts.map +1 -0
- package/dist/commands/collector/diagnose.js +229 -0
- package/dist/commands/collector/index.d.ts +6 -0
- package/dist/commands/collector/index.d.ts.map +1 -0
- package/dist/commands/collector/index.js +16 -0
- package/dist/commands/collector/status.d.ts +6 -0
- package/dist/commands/collector/status.d.ts.map +1 -0
- package/dist/commands/collector/status.js +77 -0
- package/dist/commands/collector/utils.d.ts +92 -0
- package/dist/commands/collector/utils.d.ts.map +1 -0
- package/dist/commands/collector/utils.js +154 -0
- package/dist/commands/coverage.d.ts +9 -0
- package/dist/commands/coverage.d.ts.map +1 -0
- package/dist/commands/coverage.js +252 -0
- package/dist/commands/create.d.ts +6 -0
- package/dist/commands/create.d.ts.map +1 -0
- package/dist/commands/create.js +50 -0
- package/dist/commands/events/index.d.ts +3 -0
- package/dist/commands/events/index.d.ts.map +1 -0
- package/dist/commands/events/index.js +9 -0
- package/dist/commands/events/validate.d.ts +3 -0
- package/dist/commands/events/validate.d.ts.map +1 -0
- package/dist/commands/events/validate.js +124 -0
- package/dist/commands/formats.d.ts +6 -0
- package/dist/commands/formats.d.ts.map +1 -0
- package/dist/commands/formats.js +1118 -0
- package/dist/commands/hooks.d.ts +9 -0
- package/dist/commands/hooks.d.ts.map +1 -0
- package/dist/commands/hooks.js +295 -0
- package/dist/commands/inbox.d.ts +13 -0
- package/dist/commands/inbox.d.ts.map +1 -0
- package/dist/commands/inbox.js +179 -0
- package/dist/commands/init.d.ts +6 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +220 -0
- package/dist/commands/lint.d.ts +11 -0
- package/dist/commands/lint.d.ts.map +1 -0
- package/dist/commands/lint.js +71 -0
- package/dist/commands/list.d.ts +6 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +78 -0
- package/dist/commands/migrate-nodes.d.ts +9 -0
- package/dist/commands/migrate-nodes.d.ts.map +1 -0
- package/dist/commands/migrate-nodes.js +363 -0
- package/dist/commands/migrate-scopes-to-canvas.d.ts +3 -0
- package/dist/commands/migrate-scopes-to-canvas.d.ts.map +1 -0
- package/dist/commands/migrate-scopes-to-canvas.js +143 -0
- package/dist/commands/migration.d.ts +6 -0
- package/dist/commands/migration.d.ts.map +1 -0
- package/dist/commands/migration.js +247 -0
- package/dist/commands/open-studio.d.ts +10 -0
- package/dist/commands/open-studio.d.ts.map +1 -0
- package/dist/commands/open-studio.js +32 -0
- package/dist/commands/opencode/fetch.d.ts +3 -0
- package/dist/commands/opencode/fetch.d.ts.map +1 -0
- package/dist/commands/opencode/fetch.js +25 -0
- package/dist/commands/opencode/index.d.ts +3 -0
- package/dist/commands/opencode/index.d.ts.map +1 -0
- package/dist/commands/opencode/index.js +11 -0
- package/dist/commands/opencode/list-sessions.d.ts +3 -0
- package/dist/commands/opencode/list-sessions.d.ts.map +1 -0
- package/dist/commands/opencode/list-sessions.js +20 -0
- package/dist/commands/repo.d.ts +18 -0
- package/dist/commands/repo.d.ts.map +1 -0
- package/dist/commands/repo.js +233 -0
- package/dist/commands/schema.d.ts +6 -0
- package/dist/commands/schema.d.ts.map +1 -0
- package/dist/commands/schema.js +367 -0
- package/dist/commands/scopes/index.d.ts +3 -0
- package/dist/commands/scopes/index.d.ts.map +1 -0
- package/dist/commands/scopes/index.js +9 -0
- package/dist/commands/scopes/validate.d.ts +3 -0
- package/dist/commands/scopes/validate.d.ts.map +1 -0
- package/dist/commands/scopes/validate.js +187 -0
- package/dist/commands/setup.d.ts +6 -0
- package/dist/commands/setup.d.ts.map +1 -0
- package/dist/commands/setup.js +406 -0
- package/dist/commands/starred-collections.d.ts +12 -0
- package/dist/commands/starred-collections.d.ts.map +1 -0
- package/dist/commands/starred-collections.js +310 -0
- package/dist/commands/subsystem-model.d.ts +11 -0
- package/dist/commands/subsystem-model.d.ts.map +1 -0
- package/dist/commands/subsystem-model.js +249 -0
- package/dist/commands/topic.d.ts +15 -0
- package/dist/commands/topic.d.ts.map +1 -0
- package/dist/commands/topic.js +683 -0
- package/dist/commands/tour.d.ts +23 -0
- package/dist/commands/tour.d.ts.map +1 -0
- package/dist/commands/tour.js +783 -0
- package/dist/commands/trace/flow.d.ts +6 -0
- package/dist/commands/trace/flow.d.ts.map +1 -0
- package/dist/commands/trace/flow.js +158 -0
- package/dist/commands/trace/index.d.ts +6 -0
- package/dist/commands/trace/index.d.ts.map +1 -0
- package/dist/commands/trace/index.js +20 -0
- package/dist/commands/trace/inspect.d.ts +6 -0
- package/dist/commands/trace/inspect.d.ts.map +1 -0
- package/dist/commands/trace/inspect.js +187 -0
- package/dist/commands/trace/list.d.ts +6 -0
- package/dist/commands/trace/list.d.ts.map +1 -0
- package/dist/commands/trace/list.js +145 -0
- package/dist/commands/trace/registrations.d.ts +6 -0
- package/dist/commands/trace/registrations.d.ts.map +1 -0
- package/dist/commands/trace/registrations.js +81 -0
- package/dist/commands/trace/validate.d.ts +6 -0
- package/dist/commands/trace/validate.d.ts.map +1 -0
- package/dist/commands/trace/validate.js +276 -0
- package/dist/commands/trail.d.ts +36 -0
- package/dist/commands/trail.d.ts.map +1 -0
- package/dist/commands/trail.js +622 -0
- package/dist/commands/validate.d.ts +12 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +3334 -0
- package/dist/commands/workflow/implementation.d.ts +3 -0
- package/dist/commands/workflow/implementation.d.ts.map +1 -0
- package/dist/commands/workflow/implementation.js +51 -0
- package/dist/commands/workflow/index.d.ts +3 -0
- package/dist/commands/workflow/index.d.ts.map +1 -0
- package/dist/commands/workflow/index.js +19 -0
- package/dist/commands/workflow/inspect.d.ts +3 -0
- package/dist/commands/workflow/inspect.d.ts.map +1 -0
- package/dist/commands/workflow/inspect.js +123 -0
- package/dist/commands/workflow/list.d.ts +3 -0
- package/dist/commands/workflow/list.d.ts.map +1 -0
- package/dist/commands/workflow/list.js +95 -0
- package/dist/commands/workflow/render.d.ts +3 -0
- package/dist/commands/workflow/render.d.ts.map +1 -0
- package/dist/commands/workflow/render.js +88 -0
- package/dist/commands/workflow/test.d.ts +3 -0
- package/dist/commands/workflow/test.d.ts.map +1 -0
- package/dist/commands/workflow/test.js +141 -0
- package/dist/commands/workflow/utils.d.ts +49 -0
- package/dist/commands/workflow/utils.d.ts.map +1 -0
- package/dist/commands/workflow/utils.js +164 -0
- package/dist/commands/workflow/validate.d.ts +3 -0
- package/dist/commands/workflow/validate.d.ts.map +1 -0
- package/dist/commands/workflow/validate.js +344 -0
- package/dist/file-utils.d.ts +15 -0
- package/dist/file-utils.d.ts.map +1 -0
- package/dist/file-utils.js +29 -0
- package/dist/index.cjs +285718 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +81 -0
- package/dist/lib/bridge-ipc.d.ts +29 -0
- package/dist/lib/bridge-ipc.d.ts.map +1 -0
- package/dist/lib/bridge-ipc.js +128 -0
- package/dist/lib/github-user.d.ts +17 -0
- package/dist/lib/github-user.d.ts.map +1 -0
- package/dist/lib/github-user.js +53 -0
- package/dist/lib/open-url.d.ts +11 -0
- package/dist/lib/open-url.d.ts.map +1 -0
- package/dist/lib/open-url.js +45 -0
- package/dist/lib/subsystem-model-store.d.ts +74 -0
- package/dist/lib/subsystem-model-store.d.ts.map +1 -0
- package/dist/lib/subsystem-model-store.js +292 -0
- package/dist/lib/tour-cache.d.ts +26 -0
- package/dist/lib/tour-cache.d.ts.map +1 -0
- package/dist/lib/tour-cache.js +46 -0
- package/dist/lib/trail-cache.d.ts +40 -0
- package/dist/lib/trail-cache.d.ts.map +1 -0
- package/dist/lib/trail-cache.js +97 -0
- package/dist/lib/viewer-ipc.d.ts +44 -0
- package/dist/lib/viewer-ipc.d.ts.map +1 -0
- package/dist/lib/viewer-ipc.js +68 -0
- package/package.json +68 -0
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tour command — author, validate, analyze, and open File City introduction
|
|
3
|
+
* tours (`*.tour.json`).
|
|
4
|
+
*
|
|
5
|
+
* Tours are the sibling of trails: a trail pins markers to `file:line`, a tour
|
|
6
|
+
* scopes steps to a `focusDirectory` + highlight layers. This command bundles
|
|
7
|
+
* the full tour lifecycle:
|
|
8
|
+
*
|
|
9
|
+
* principal-ai tour init scaffold a tour from a template
|
|
10
|
+
* principal-ai tour validate validate a tour against the spec
|
|
11
|
+
* principal-ai tour stats timing/length analysis vs. the guidelines
|
|
12
|
+
* principal-ai tour view open a tour in the standalone viewer
|
|
13
|
+
*
|
|
14
|
+
* `init`/`validate`/`stats` were folded in from the deprecated
|
|
15
|
+
* `@principal-ai/file-city-cli`; they delegate all schema work to
|
|
16
|
+
* `@principal-ai/file-city-builder` (`parseTour`, `IntroductionTour`). `view`
|
|
17
|
+
* is the lighter cousin of `trail view --file`, reusing the same viewer-launch
|
|
18
|
+
* + IPC handoff plumbing — and because steps address whole directories, tours
|
|
19
|
+
* are local-mode only (no fetch, no remote slice resolution, no token).
|
|
20
|
+
*/
|
|
21
|
+
import { Command } from 'commander';
|
|
22
|
+
import { spawn } from 'node:child_process';
|
|
23
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { tmpdir } from 'node:os';
|
|
25
|
+
import { basename, join, resolve } from 'node:path';
|
|
26
|
+
import chalk from 'chalk';
|
|
27
|
+
import { parseTour } from '@principal-ai/file-city-builder';
|
|
28
|
+
import { handoffToRunning } from '../lib/viewer-ipc.js';
|
|
29
|
+
import * as tourCache from '../lib/tour-cache.js';
|
|
30
|
+
import { BASE_URL, describeHttpError, exitWithTokenError, gitRemoteUrl, ownerRepoFromGitRemote, resolveToken, resolveViewerLaunch, } from './trail.js';
|
|
31
|
+
const ONE_HOUR_MS = 60 * 60 * 1000;
|
|
32
|
+
/**
|
|
33
|
+
* Extract a tour id from a bare id or a web-ade `/tour/<id>` URL. Falls back to
|
|
34
|
+
* the input unchanged when it isn't a URL.
|
|
35
|
+
*/
|
|
36
|
+
function parseTourId(input) {
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(input);
|
|
39
|
+
const match = url.pathname.match(/\/tour\/([^/]+)\/?$/);
|
|
40
|
+
if (match)
|
|
41
|
+
return match[1];
|
|
42
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
43
|
+
if (segments.length)
|
|
44
|
+
return segments[segments.length - 1];
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// not a URL — treat as a bare id
|
|
48
|
+
}
|
|
49
|
+
return input;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the `{ owner, repo }` a tour publishes under. Unlike trails, a tour
|
|
53
|
+
* document carries no `repos[]`, so we look at explicit flags first, then fall
|
|
54
|
+
* back to the cwd's `origin` git remote. Exits with guidance when neither
|
|
55
|
+
* yields an owner/repo.
|
|
56
|
+
*/
|
|
57
|
+
function resolveTourOwnerRepo(options) {
|
|
58
|
+
if (options.purl) {
|
|
59
|
+
const match = options.purl.match(/^pkg:(?:github|gitlab|bitbucket)\/([^/]+)\/([^/@]+)/);
|
|
60
|
+
if (!match) {
|
|
61
|
+
process.stderr.write(`--purl is not a valid github/gitlab/bitbucket Purl: ${options.purl}\n`);
|
|
62
|
+
process.exit(2);
|
|
63
|
+
}
|
|
64
|
+
return { owner: options.owner ?? match[1], repo: options.repo ?? match[2] };
|
|
65
|
+
}
|
|
66
|
+
if (options.owner && options.repo) {
|
|
67
|
+
return { owner: options.owner, repo: options.repo };
|
|
68
|
+
}
|
|
69
|
+
const remote = gitRemoteUrl(process.cwd());
|
|
70
|
+
const fromRemote = remote ? ownerRepoFromGitRemote(remote) : null;
|
|
71
|
+
const owner = options.owner ?? fromRemote?.owner;
|
|
72
|
+
const repo = options.repo ?? fromRemote?.name;
|
|
73
|
+
if (!owner || !repo) {
|
|
74
|
+
process.stderr.write('Could not determine owner/repo. Pass --owner and --repo (or --purl), or run inside a clone with an `origin` remote.\n');
|
|
75
|
+
process.exit(2);
|
|
76
|
+
}
|
|
77
|
+
return { owner, repo };
|
|
78
|
+
}
|
|
79
|
+
/** Build a tour's primary repo ref from resolved owner/repo. */
|
|
80
|
+
function tourRepoRefFor(owner, repo) {
|
|
81
|
+
return {
|
|
82
|
+
id: `pkg:github/${owner.toLowerCase()}/${repo}`,
|
|
83
|
+
name: repo,
|
|
84
|
+
remote: { host: 'github', owner, name: repo },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Fetch + cache a tour by id. Exits the process on any error. */
|
|
88
|
+
async function fetchAndCacheTour(id) {
|
|
89
|
+
const token = resolveToken();
|
|
90
|
+
if (!token)
|
|
91
|
+
exitWithTokenError();
|
|
92
|
+
const url = `${BASE_URL}/api/tours/by-id/${encodeURIComponent(id)}`;
|
|
93
|
+
let response;
|
|
94
|
+
try {
|
|
95
|
+
response = await fetch(url, {
|
|
96
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
process.stderr.write(`Network error fetching tour: ${err.message}\n`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
process.stderr.write(`${await describeHttpError(response)}\n`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
const body = await response.text();
|
|
108
|
+
let cachePath;
|
|
109
|
+
try {
|
|
110
|
+
cachePath = tourCache.write(id, body).path;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// Cache write failed — continue without a cache hit for this call.
|
|
114
|
+
cachePath = '';
|
|
115
|
+
}
|
|
116
|
+
return { body, cachePath };
|
|
117
|
+
}
|
|
118
|
+
async function fetchTour(input) {
|
|
119
|
+
const id = parseTourId(input);
|
|
120
|
+
if (!id) {
|
|
121
|
+
process.stderr.write('Invalid tour id\n');
|
|
122
|
+
process.exit(2);
|
|
123
|
+
}
|
|
124
|
+
const { body } = await fetchAndCacheTour(id);
|
|
125
|
+
process.stdout.write(body);
|
|
126
|
+
if (!body.endsWith('\n'))
|
|
127
|
+
process.stdout.write('\n');
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Publish a tour document to web-ade. The server mints the share id and pins
|
|
131
|
+
* audio coordinates; we print the resulting share URL to stdout.
|
|
132
|
+
*/
|
|
133
|
+
async function publishTour(file, options) {
|
|
134
|
+
const path = file && file !== '-' ? resolve(process.cwd(), file) : undefined;
|
|
135
|
+
if (!path) {
|
|
136
|
+
process.stderr.write('Pass the path to a *.tour.json file to publish.\n');
|
|
137
|
+
process.exit(2);
|
|
138
|
+
}
|
|
139
|
+
if (!existsSync(path)) {
|
|
140
|
+
process.stderr.write(`Tour file not found: ${path}\n`);
|
|
141
|
+
process.exit(2);
|
|
142
|
+
}
|
|
143
|
+
let tour;
|
|
144
|
+
try {
|
|
145
|
+
tour = JSON.parse(readFileSync(path, 'utf8'));
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
process.stderr.write(`Tour file is not valid JSON: ${err.message}\n`);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
// Resolve the publish target and stamp it as the tour's primary repo. Tours
|
|
152
|
+
// require `repos[]`, but an author's file usually omits it — so we derive it
|
|
153
|
+
// from flags / the git remote and inject it (preserving any extra repos the
|
|
154
|
+
// author declared for a multi-repo tour) before validating. The server
|
|
155
|
+
// re-stamps repos[0] authoritatively; this keeps local validation honest and
|
|
156
|
+
// the published artifact consistent.
|
|
157
|
+
const { owner, repo } = resolveTourOwnerRepo(options);
|
|
158
|
+
const existingRepos = tour && typeof tour === 'object' && Array.isArray(tour.repos)
|
|
159
|
+
? tour.repos.slice(1)
|
|
160
|
+
: [];
|
|
161
|
+
const stampedTour = {
|
|
162
|
+
...tour,
|
|
163
|
+
repos: [tourRepoRefFor(owner, repo), ...existingRepos],
|
|
164
|
+
};
|
|
165
|
+
// Validate locally before the round-trip so authors get the spec errors here
|
|
166
|
+
// rather than as a 400 from the server.
|
|
167
|
+
const parsed = parseTour(JSON.stringify(stampedTour));
|
|
168
|
+
if (!parsed.success || !parsed.tour) {
|
|
169
|
+
const detail = parsed.errors?.map((e) => e.message).join(', ') || 'unknown error';
|
|
170
|
+
process.stderr.write(`Invalid tour: ${detail}\n`);
|
|
171
|
+
process.exit(2);
|
|
172
|
+
}
|
|
173
|
+
const token = resolveToken();
|
|
174
|
+
if (!token)
|
|
175
|
+
exitWithTokenError();
|
|
176
|
+
let response;
|
|
177
|
+
try {
|
|
178
|
+
response = await fetch(`${BASE_URL}/api/tours/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
headers: {
|
|
181
|
+
Authorization: `Bearer ${token}`,
|
|
182
|
+
'Content-Type': 'application/json',
|
|
183
|
+
Accept: 'application/json',
|
|
184
|
+
},
|
|
185
|
+
body: JSON.stringify({ owner, repo, tour: parsed.tour }),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
process.stderr.write(`Network error publishing tour: ${err.message}\n`);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
if (!response.ok) {
|
|
193
|
+
process.stderr.write(`${await describeHttpError(response)}\n`);
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
const resBody = (await response.json());
|
|
197
|
+
if (!resBody.url) {
|
|
198
|
+
process.stderr.write('Server response missing share URL\n');
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
const shareUrl = resBody.url.startsWith('http')
|
|
202
|
+
? resBody.url
|
|
203
|
+
: `${BASE_URL}${resBody.url}`;
|
|
204
|
+
process.stdout.write(`${shareUrl}\n`);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Cheap pre-flight so an obviously-broken tour fails here with a clear message
|
|
208
|
+
* instead of opening the viewer to an idle, empty city. The viewer host runs
|
|
209
|
+
* the full `parseTourOrThrow` validation on load — this only catches the
|
|
210
|
+
* coarse "not JSON / not a tour" cases without pulling in the builder package.
|
|
211
|
+
*/
|
|
212
|
+
function assertLooksLikeTour(absolute) {
|
|
213
|
+
let body;
|
|
214
|
+
try {
|
|
215
|
+
body = readFileSync(absolute, 'utf8');
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
process.stderr.write(`Failed to read ${absolute}: ${err.message}\n`);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
let payload;
|
|
222
|
+
try {
|
|
223
|
+
payload = JSON.parse(body);
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
process.stderr.write(`Tour file is not valid JSON: ${err.message}\n`);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
const steps = typeof payload === 'object' && payload !== null
|
|
230
|
+
? payload.steps
|
|
231
|
+
: undefined;
|
|
232
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
233
|
+
process.stderr.write(`${absolute} does not look like a tour (expected a non-empty \`steps\` array).\n`);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Materialize a viewer-ready *.tour.json from a store id. The by-id response is
|
|
239
|
+
* a `{ owner, repo, entry, payload }` wrapper where `payload` is the tour; we
|
|
240
|
+
* pull it out and write it to a temp `*.tour.json` the viewer can load. The
|
|
241
|
+
* fetched wrapper is still cached verbatim by `fetchAndCacheTour` for `fetch`.
|
|
242
|
+
*/
|
|
243
|
+
async function resolveTourFileFromId(input, refresh) {
|
|
244
|
+
const id = parseTourId(input);
|
|
245
|
+
if (!id) {
|
|
246
|
+
process.stderr.write('Invalid tour id\n');
|
|
247
|
+
process.exit(2);
|
|
248
|
+
}
|
|
249
|
+
const cached = refresh ? null : tourCache.read(id, ONE_HOUR_MS);
|
|
250
|
+
const body = cached ? cached.body : (await fetchAndCacheTour(id)).body;
|
|
251
|
+
let wrapper;
|
|
252
|
+
try {
|
|
253
|
+
wrapper = JSON.parse(body);
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
process.stderr.write(`Tour body is not valid JSON: ${err.message}\n`);
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
// The by-id response wraps the tour as `{ owner, repo, entry, payload }`,
|
|
260
|
+
// where `payload` IS the tour. A bare local file is already the tour. Accept
|
|
261
|
+
// either shape.
|
|
262
|
+
const tour = typeof wrapper === 'object' && wrapper !== null
|
|
263
|
+
? (wrapper.payload ?? wrapper)
|
|
264
|
+
: wrapper;
|
|
265
|
+
const viewerPath = join(tmpdir(), `principal-tour-${id.replace(/[^A-Za-z0-9._-]/g, '_')}.tour.json`);
|
|
266
|
+
writeFileSync(viewerPath, JSON.stringify(tour), 'utf8');
|
|
267
|
+
assertLooksLikeTour(viewerPath);
|
|
268
|
+
return viewerPath;
|
|
269
|
+
}
|
|
270
|
+
async function viewTour(input, options) {
|
|
271
|
+
if (!input && !options.file) {
|
|
272
|
+
process.stderr.write('Pass a *.tour.json path/id-or-url, or --file <path>.\n');
|
|
273
|
+
process.exit(2);
|
|
274
|
+
}
|
|
275
|
+
if (input && options.file) {
|
|
276
|
+
process.stderr.write('Pass either a path/id-or-url OR --file, not both.\n');
|
|
277
|
+
process.exit(2);
|
|
278
|
+
}
|
|
279
|
+
let absolute;
|
|
280
|
+
if (options.file) {
|
|
281
|
+
absolute = resolve(process.cwd(), options.file);
|
|
282
|
+
if (!existsSync(absolute)) {
|
|
283
|
+
process.stderr.write(`Tour file not found: ${absolute}\n`);
|
|
284
|
+
process.exit(2);
|
|
285
|
+
}
|
|
286
|
+
assertLooksLikeTour(absolute);
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
// A positional arg that resolves to an existing file is a local tour; any
|
|
290
|
+
// other value is treated as a store id/url and fetched.
|
|
291
|
+
const maybePath = resolve(process.cwd(), input);
|
|
292
|
+
if (existsSync(maybePath)) {
|
|
293
|
+
absolute = maybePath;
|
|
294
|
+
assertLooksLikeTour(absolute);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
absolute = await resolveTourFileFromId(input, options.refresh ?? false);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Default the repo root to cwd. The user sees "no directory matched" framing
|
|
301
|
+
// in the viewer if cwd isn't actually the repo the tour was authored against,
|
|
302
|
+
// which is the right signal to re-run with --repo-root.
|
|
303
|
+
const repoRoot = options.repoRoot
|
|
304
|
+
? resolve(process.cwd(), options.repoRoot)
|
|
305
|
+
: process.cwd();
|
|
306
|
+
const env = {
|
|
307
|
+
...process.env,
|
|
308
|
+
TRAIL_FILE: absolute,
|
|
309
|
+
TRAIL_MODE: 'local',
|
|
310
|
+
TRAIL_REPO_ROOT: repoRoot,
|
|
311
|
+
};
|
|
312
|
+
// The viewer host auto-detects tour vs trail from the filename/shape, so the
|
|
313
|
+
// `LOAD_TRAIL` message carries the tour file just like a trail would.
|
|
314
|
+
const ipcMessage = {
|
|
315
|
+
kind: 'LOAD_TRAIL',
|
|
316
|
+
trailFile: absolute,
|
|
317
|
+
mode: 'local',
|
|
318
|
+
repoRoot,
|
|
319
|
+
};
|
|
320
|
+
if (await handoffToRunning(ipcMessage)) {
|
|
321
|
+
process.stderr.write(`Tour handed off to running viewer: ${absolute}\n`);
|
|
322
|
+
process.exit(0);
|
|
323
|
+
}
|
|
324
|
+
const launch = resolveViewerLaunch(options.viewerDir);
|
|
325
|
+
process.stderr.write(`Launching tour viewer for ${absolute}\n`);
|
|
326
|
+
const child = launch.kind === 'installed'
|
|
327
|
+
? spawn(launch.bin, [], { env, stdio: 'inherit' })
|
|
328
|
+
: spawn('bun', ['start'], { cwd: launch.dir, env, stdio: 'inherit' });
|
|
329
|
+
child.on('error', (err) => {
|
|
330
|
+
process.stderr.write(`Failed to launch viewer: ${err.message}\n`);
|
|
331
|
+
process.exit(1);
|
|
332
|
+
});
|
|
333
|
+
child.on('exit', (code) => {
|
|
334
|
+
process.exit(code ?? 0);
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
function getMinimalTemplate() {
|
|
338
|
+
return {
|
|
339
|
+
id: 'quick-start',
|
|
340
|
+
title: 'Quick Start Guide',
|
|
341
|
+
description: 'Get started with the codebase in 5 minutes',
|
|
342
|
+
version: '1.0.0',
|
|
343
|
+
audience: 'New Users & AI Assistants',
|
|
344
|
+
steps: [
|
|
345
|
+
{
|
|
346
|
+
id: 'step-1-welcome',
|
|
347
|
+
title: 'Welcome!',
|
|
348
|
+
description: 'This is a simple introduction to the project structure.',
|
|
349
|
+
estimatedTime: 30,
|
|
350
|
+
// Last step must focus on repository root ("") for a complete overview.
|
|
351
|
+
focusDirectory: '',
|
|
352
|
+
colorMode: 'fileTypes',
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
function getOnboardingTemplate() {
|
|
358
|
+
return {
|
|
359
|
+
id: 'codebase-onboarding',
|
|
360
|
+
title: 'Codebase Onboarding Tour',
|
|
361
|
+
description: 'Learn the structure and key components of this codebase',
|
|
362
|
+
version: '1.0.0',
|
|
363
|
+
audience: 'New Developers',
|
|
364
|
+
prerequisites: ['Basic understanding of the technology stack'],
|
|
365
|
+
steps: [
|
|
366
|
+
{
|
|
367
|
+
id: 'step-1-overview',
|
|
368
|
+
title: 'Project Overview',
|
|
369
|
+
description: 'Welcome to the codebase! This tour will guide you through the main areas.',
|
|
370
|
+
estimatedTime: 60,
|
|
371
|
+
colorMode: 'fileTypes',
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
id: 'step-2-core',
|
|
375
|
+
title: 'Core Components',
|
|
376
|
+
description: 'These are the main building blocks of the application.',
|
|
377
|
+
estimatedTime: 120,
|
|
378
|
+
focusDirectory: 'src',
|
|
379
|
+
highlightLayers: [
|
|
380
|
+
{
|
|
381
|
+
id: 'core-layer',
|
|
382
|
+
name: 'Core Files',
|
|
383
|
+
color: '#3b82f6',
|
|
384
|
+
items: [
|
|
385
|
+
{ path: 'src/index.ts', type: 'file' },
|
|
386
|
+
{ path: 'src/components', type: 'directory' },
|
|
387
|
+
],
|
|
388
|
+
opacity: 0.7,
|
|
389
|
+
borderWidth: 2,
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
colorMode: 'fileTypes',
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
id: 'step-3-configuration',
|
|
396
|
+
title: 'Configuration',
|
|
397
|
+
description: 'Configuration files that control the application behavior.',
|
|
398
|
+
estimatedTime: 60,
|
|
399
|
+
highlightFiles: ['package.json', 'tsconfig.json'],
|
|
400
|
+
// Last step focuses on repository root ("") for a complete overview.
|
|
401
|
+
focusDirectory: '',
|
|
402
|
+
colorMode: 'fileTypes',
|
|
403
|
+
},
|
|
404
|
+
],
|
|
405
|
+
metadata: {
|
|
406
|
+
author: 'Your Name',
|
|
407
|
+
createdAt: new Date().toISOString(),
|
|
408
|
+
tags: ['onboarding', 'tutorial'],
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
function getArchitectureTemplate() {
|
|
413
|
+
return {
|
|
414
|
+
id: 'architecture-overview',
|
|
415
|
+
title: 'Architecture Overview',
|
|
416
|
+
description: 'Understand the architectural decisions and patterns in this codebase',
|
|
417
|
+
version: '1.0.0',
|
|
418
|
+
audience: 'Engineers & Architects',
|
|
419
|
+
steps: [
|
|
420
|
+
{
|
|
421
|
+
id: 'step-1-layered-architecture',
|
|
422
|
+
title: 'Layered Architecture',
|
|
423
|
+
description: 'The application follows a layered architecture pattern.',
|
|
424
|
+
estimatedTime: 120,
|
|
425
|
+
// Steps with highlightLayers must set focusDirectory; "" frames the
|
|
426
|
+
// whole tree so all three layers are visible at once.
|
|
427
|
+
focusDirectory: '',
|
|
428
|
+
highlightLayers: [
|
|
429
|
+
{
|
|
430
|
+
id: 'presentation-layer',
|
|
431
|
+
name: 'Presentation Layer',
|
|
432
|
+
color: '#10b981',
|
|
433
|
+
items: [{ path: 'src/components', type: 'directory' }],
|
|
434
|
+
opacity: 0.6,
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
id: 'business-layer',
|
|
438
|
+
name: 'Business Logic',
|
|
439
|
+
color: '#f59e0b',
|
|
440
|
+
items: [{ path: 'src/services', type: 'directory' }],
|
|
441
|
+
opacity: 0.6,
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
id: 'data-layer',
|
|
445
|
+
name: 'Data Layer',
|
|
446
|
+
color: '#ef4444',
|
|
447
|
+
items: [{ path: 'src/models', type: 'directory' }],
|
|
448
|
+
opacity: 0.6,
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
colorMode: 'fileTypes',
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
id: 'step-2-patterns',
|
|
455
|
+
title: 'Design Patterns',
|
|
456
|
+
description: 'Key design patterns used throughout the codebase.',
|
|
457
|
+
estimatedTime: 180,
|
|
458
|
+
// Last step focuses on repository root ("") for a complete overview.
|
|
459
|
+
focusDirectory: '',
|
|
460
|
+
resources: [
|
|
461
|
+
{
|
|
462
|
+
title: 'Design Patterns Documentation',
|
|
463
|
+
url: 'https://refactoring.guru/design-patterns',
|
|
464
|
+
type: 'documentation',
|
|
465
|
+
},
|
|
466
|
+
],
|
|
467
|
+
colorMode: 'fileTypes',
|
|
468
|
+
},
|
|
469
|
+
],
|
|
470
|
+
metadata: {
|
|
471
|
+
author: 'Architecture Team',
|
|
472
|
+
createdAt: new Date().toISOString(),
|
|
473
|
+
tags: ['architecture', 'patterns', 'advanced'],
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
function getTemplate(templateType) {
|
|
478
|
+
switch (templateType) {
|
|
479
|
+
case 'onboarding':
|
|
480
|
+
return getOnboardingTemplate();
|
|
481
|
+
case 'architecture':
|
|
482
|
+
return getArchitectureTemplate();
|
|
483
|
+
case 'minimal':
|
|
484
|
+
default:
|
|
485
|
+
return getMinimalTemplate();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
function initTour(options) {
|
|
489
|
+
const template = options.template || 'minimal';
|
|
490
|
+
const base = getTemplate(template);
|
|
491
|
+
// Tours require `repos[]`. Anchor to the cwd git remote when there is one;
|
|
492
|
+
// otherwise scaffold an obvious placeholder for the author to edit.
|
|
493
|
+
const remote = gitRemoteUrl(process.cwd());
|
|
494
|
+
const fromRemote = remote ? ownerRepoFromGitRemote(remote) : null;
|
|
495
|
+
const repos = fromRemote
|
|
496
|
+
? [tourRepoRefFor(fromRemote.owner, fromRemote.name)]
|
|
497
|
+
: [
|
|
498
|
+
{
|
|
499
|
+
id: 'pkg:github/OWNER/REPO',
|
|
500
|
+
name: 'REPO',
|
|
501
|
+
remote: { host: 'github', owner: 'OWNER', name: 'REPO' },
|
|
502
|
+
},
|
|
503
|
+
];
|
|
504
|
+
const tour = { ...base, repos };
|
|
505
|
+
const outputFile = options.output || `${tour.id}.tour.json`;
|
|
506
|
+
const absolutePath = resolve(process.cwd(), outputFile);
|
|
507
|
+
try {
|
|
508
|
+
writeFileSync(absolutePath, JSON.stringify(tour, null, 2), 'utf-8');
|
|
509
|
+
}
|
|
510
|
+
catch (error) {
|
|
511
|
+
console.error(chalk.red(`\n✗ ${error instanceof Error ? error.message : 'Unknown error'}\n`));
|
|
512
|
+
process.exit(1);
|
|
513
|
+
}
|
|
514
|
+
console.log(chalk.green(`✓ Tour file created: ${outputFile}`));
|
|
515
|
+
console.log(chalk.dim(` Template: ${template}`));
|
|
516
|
+
console.log(chalk.dim(` Tour ID: ${tour.id}`));
|
|
517
|
+
console.log(chalk.dim(` Steps: ${tour.steps.length}`));
|
|
518
|
+
console.log(chalk.dim(` Repo: ${repos[0].id}`));
|
|
519
|
+
if (!fromRemote) {
|
|
520
|
+
console.log(chalk.yellow(' ⚠ No git remote found — edit `repos[0]` to point at the real repository.'));
|
|
521
|
+
}
|
|
522
|
+
console.log('\nNext steps:');
|
|
523
|
+
console.log(' 1. Edit the tour file to customize it for your codebase');
|
|
524
|
+
console.log(` 2. Validate the tour: principal-ai tour validate ${outputFile}`);
|
|
525
|
+
console.log(` 3. Publish it: principal-ai tour publish ${outputFile}\n`);
|
|
526
|
+
}
|
|
527
|
+
function validateTourFile(file, options) {
|
|
528
|
+
const absolutePath = resolve(process.cwd(), file);
|
|
529
|
+
const fileName = basename(absolutePath);
|
|
530
|
+
let content;
|
|
531
|
+
try {
|
|
532
|
+
content = readFileSync(absolutePath, 'utf-8');
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
536
|
+
if (options.json) {
|
|
537
|
+
console.log(JSON.stringify({ valid: false, error: message }, null, 2));
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
console.error(chalk.red(`\n✗ Error: ${message}\n`));
|
|
541
|
+
}
|
|
542
|
+
process.exit(1);
|
|
543
|
+
}
|
|
544
|
+
const result = parseTour(content);
|
|
545
|
+
if (options.json) {
|
|
546
|
+
console.log(JSON.stringify({
|
|
547
|
+
file: fileName,
|
|
548
|
+
valid: result.success,
|
|
549
|
+
errors: result.errors?.map((e) => ({
|
|
550
|
+
message: e.message,
|
|
551
|
+
field: e.field,
|
|
552
|
+
value: e.value,
|
|
553
|
+
})),
|
|
554
|
+
tour: result.tour
|
|
555
|
+
? {
|
|
556
|
+
id: result.tour.id,
|
|
557
|
+
title: result.tour.title,
|
|
558
|
+
version: result.tour.version,
|
|
559
|
+
stepCount: result.tour.steps.length,
|
|
560
|
+
}
|
|
561
|
+
: undefined,
|
|
562
|
+
}, null, 2));
|
|
563
|
+
if (!result.success)
|
|
564
|
+
process.exit(1);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (result.success && result.tour) {
|
|
568
|
+
console.log(chalk.green(`✓ Tour "${fileName}" is valid!`));
|
|
569
|
+
console.log(chalk.dim(` Tour ID: ${result.tour.id}`));
|
|
570
|
+
console.log(chalk.dim(` Title: ${result.tour.title}`));
|
|
571
|
+
console.log(chalk.dim(` Version: ${result.tour.version}`));
|
|
572
|
+
console.log(chalk.dim(` Steps: ${result.tour.steps.length}`));
|
|
573
|
+
if (result.tour.audience) {
|
|
574
|
+
console.log(chalk.dim(` Audience: ${result.tour.audience}`));
|
|
575
|
+
}
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
console.error(chalk.red(`\n✗ Tour "${fileName}" is invalid:\n`));
|
|
579
|
+
for (const e of result.errors ?? []) {
|
|
580
|
+
const where = e.field ? chalk.dim(` (${e.field})`) : '';
|
|
581
|
+
console.error(chalk.red(` • ${e.message}`) + where);
|
|
582
|
+
}
|
|
583
|
+
console.error('');
|
|
584
|
+
process.exit(1);
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Estimate time for a step from character count. Guideline: 200-250 chars take
|
|
588
|
+
* ~20-30s — roughly 10 chars/second covering reading + viewing + interaction.
|
|
589
|
+
*/
|
|
590
|
+
function estimateStepTime(charCount) {
|
|
591
|
+
return Math.round(charCount / 10 / 5) * 5;
|
|
592
|
+
}
|
|
593
|
+
function analyzeTour(tour) {
|
|
594
|
+
const steps = tour.steps.map((step) => {
|
|
595
|
+
const characterCount = step.description.length;
|
|
596
|
+
const hasTime = step.estimatedTime !== undefined;
|
|
597
|
+
const estimatedTime = hasTime ? step.estimatedTime : estimateStepTime(characterCount);
|
|
598
|
+
return { id: step.id, title: step.title, estimatedTime, characterCount, hasTime };
|
|
599
|
+
});
|
|
600
|
+
const totalCharacters = steps.reduce((sum, s) => sum + s.characterCount, 0);
|
|
601
|
+
const totalTime = steps.reduce((sum, s) => sum + s.estimatedTime, 0);
|
|
602
|
+
const hasAllTimes = steps.every((s) => s.hasTime);
|
|
603
|
+
const stepCount = steps.length;
|
|
604
|
+
const recommendations = [];
|
|
605
|
+
for (const step of steps.filter((s) => s.characterCount > 300)) {
|
|
606
|
+
const excess = step.characterCount - 300;
|
|
607
|
+
recommendations.push(`Reduce "${step.title}" by ${excess} character${excess > 1 ? 's' : ''}`);
|
|
608
|
+
}
|
|
609
|
+
if (stepCount > 8) {
|
|
610
|
+
recommendations.push(`Reduce step count from ${stepCount} to 6-8 steps (consolidate related concepts)`);
|
|
611
|
+
}
|
|
612
|
+
else if (stepCount > 6) {
|
|
613
|
+
recommendations.push(`Consider reducing from ${stepCount} to 4-6 steps for ideal 2-minute duration`);
|
|
614
|
+
}
|
|
615
|
+
if (totalTime > 180) {
|
|
616
|
+
recommendations.push(`Reduce total duration by ~${Math.round((totalTime - 180) / 60)}m to meet 3-minute maximum`);
|
|
617
|
+
}
|
|
618
|
+
else if (totalTime > 120) {
|
|
619
|
+
recommendations.push(`Consider reducing duration by ~${Math.round((totalTime - 120) / 60)}m to meet 2-minute ideal`);
|
|
620
|
+
}
|
|
621
|
+
if (totalCharacters > 2000) {
|
|
622
|
+
recommendations.push(`Reduce total text by ${totalCharacters - 2000} characters to meet 2,000 char limit`);
|
|
623
|
+
}
|
|
624
|
+
else if (totalCharacters > 1500) {
|
|
625
|
+
recommendations.push(`Consider reducing text by ${totalCharacters - 1500} characters to meet ideal range`);
|
|
626
|
+
}
|
|
627
|
+
if (!hasAllTimes) {
|
|
628
|
+
recommendations.push('Add `estimatedTime` field to all steps for accurate tracking');
|
|
629
|
+
}
|
|
630
|
+
return { stepCount, totalTime, hasAllTimes, totalCharacters, steps, recommendations };
|
|
631
|
+
}
|
|
632
|
+
function formatTime(seconds) {
|
|
633
|
+
const mins = Math.floor(seconds / 60);
|
|
634
|
+
const secs = seconds % 60;
|
|
635
|
+
return mins === 0 ? `${secs}s` : `${mins}m ${secs}s`;
|
|
636
|
+
}
|
|
637
|
+
function statusIndicator(value, idealMax, acceptableMax) {
|
|
638
|
+
if (value <= idealMax)
|
|
639
|
+
return chalk.green('✓');
|
|
640
|
+
if (value <= acceptableMax)
|
|
641
|
+
return chalk.yellow('⚠');
|
|
642
|
+
return chalk.red('✗');
|
|
643
|
+
}
|
|
644
|
+
function printStats(tour, stats) {
|
|
645
|
+
console.log('');
|
|
646
|
+
console.log(chalk.bold.cyan(`Tour Statistics: "${tour.title}"`));
|
|
647
|
+
console.log(chalk.cyan('━'.repeat(60)));
|
|
648
|
+
console.log('');
|
|
649
|
+
console.log(chalk.bold('Steps: ') + `${stats.stepCount} step${stats.stepCount !== 1 ? 's' : ''}`);
|
|
650
|
+
const timeStr = stats.hasAllTimes ? formatTime(stats.totalTime) : `${formatTime(stats.totalTime)} (estimated)`;
|
|
651
|
+
console.log(chalk.bold('Total duration: ') + timeStr);
|
|
652
|
+
console.log(chalk.bold('Total description: ') + `${stats.totalCharacters} characters`);
|
|
653
|
+
console.log('');
|
|
654
|
+
console.log(chalk.bold('Target Guidelines:'));
|
|
655
|
+
console.log(` ${statusIndicator(stats.stepCount, 6, 8)} Steps: ${stats.stepCount} (4-6 ideal, 6-8 acceptable, >8 over)`);
|
|
656
|
+
console.log(` ${statusIndicator(stats.totalTime, 120, 180)} Duration: ${formatTime(stats.totalTime)} (2min ideal, 3min max)`);
|
|
657
|
+
console.log(` ${statusIndicator(stats.totalCharacters, 1500, 2000)} Characters: ${stats.totalCharacters} (800-1,500 ideal, 2,000 max)`);
|
|
658
|
+
console.log('');
|
|
659
|
+
console.log(chalk.bold('Per-Step Breakdown:'));
|
|
660
|
+
stats.steps.forEach((step, index) => {
|
|
661
|
+
const timeStr = step.hasTime ? formatTime(step.estimatedTime) : `${formatTime(step.estimatedTime)} est`;
|
|
662
|
+
const charStatus = step.characterCount > 300
|
|
663
|
+
? chalk.red('✗ Exceeds 300 char limit')
|
|
664
|
+
: step.characterCount >= 280
|
|
665
|
+
? chalk.yellow('⚠ Approaching limit')
|
|
666
|
+
: chalk.green('✓');
|
|
667
|
+
console.log(` ${(index + 1).toString().padStart(2)}. ${step.title.padEnd(30)} (${timeStr.padEnd(7)}, ${step.characterCount} chars) ${charStatus}`);
|
|
668
|
+
});
|
|
669
|
+
console.log('');
|
|
670
|
+
if (!stats.hasAllTimes) {
|
|
671
|
+
console.log(chalk.yellow('⚠ Missing `estimatedTime` field on some steps (values estimated)'));
|
|
672
|
+
console.log('');
|
|
673
|
+
}
|
|
674
|
+
if (stats.recommendations.length > 0) {
|
|
675
|
+
console.log(chalk.bold('Recommendations:'));
|
|
676
|
+
for (const rec of stats.recommendations)
|
|
677
|
+
console.log(` • ${rec}`);
|
|
678
|
+
console.log('');
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
console.log(chalk.green.bold('✓ Tour meets all guidelines!'));
|
|
682
|
+
console.log('');
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
function statsTourFile(file, options) {
|
|
686
|
+
const absolutePath = resolve(process.cwd(), file);
|
|
687
|
+
const fileName = basename(absolutePath);
|
|
688
|
+
let content;
|
|
689
|
+
try {
|
|
690
|
+
content = readFileSync(absolutePath, 'utf-8');
|
|
691
|
+
}
|
|
692
|
+
catch (error) {
|
|
693
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
694
|
+
if (options.json)
|
|
695
|
+
console.log(JSON.stringify({ error: message }, null, 2));
|
|
696
|
+
else
|
|
697
|
+
console.error(chalk.red(`\n✗ Error: ${message}\n`));
|
|
698
|
+
process.exit(1);
|
|
699
|
+
}
|
|
700
|
+
const result = parseTour(content);
|
|
701
|
+
if (!result.success || !result.tour) {
|
|
702
|
+
console.error(chalk.red('\n✗ Cannot analyze tour — validation failed. Run `tour validate` first.\n'));
|
|
703
|
+
process.exit(1);
|
|
704
|
+
}
|
|
705
|
+
const stats = analyzeTour(result.tour);
|
|
706
|
+
if (options.json) {
|
|
707
|
+
console.log(JSON.stringify({
|
|
708
|
+
file: fileName,
|
|
709
|
+
tour: { id: result.tour.id, title: result.tour.title, version: result.tour.version },
|
|
710
|
+
stats: {
|
|
711
|
+
stepCount: stats.stepCount,
|
|
712
|
+
totalTime: stats.totalTime,
|
|
713
|
+
totalCharacters: stats.totalCharacters,
|
|
714
|
+
hasAllTimes: stats.hasAllTimes,
|
|
715
|
+
steps: stats.steps,
|
|
716
|
+
recommendations: stats.recommendations,
|
|
717
|
+
},
|
|
718
|
+
meetsGuidelines: stats.recommendations.length === 0,
|
|
719
|
+
}, null, 2));
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
printStats(result.tour, stats);
|
|
723
|
+
}
|
|
724
|
+
// ---------------------------------------------------------------------------
|
|
725
|
+
// command wiring
|
|
726
|
+
// ---------------------------------------------------------------------------
|
|
727
|
+
export function createTourCommand() {
|
|
728
|
+
const command = new Command('tour');
|
|
729
|
+
command.description('Author, validate, analyze, and open File City introduction tours');
|
|
730
|
+
command
|
|
731
|
+
.command('init')
|
|
732
|
+
.description('Scaffold a new *.tour.json from a template')
|
|
733
|
+
.option('-t, --template <type>', 'Template type (minimal, onboarding, architecture)', 'minimal')
|
|
734
|
+
.option('-o, --output <file>', 'Output filename')
|
|
735
|
+
.action((options) => {
|
|
736
|
+
initTour(options);
|
|
737
|
+
});
|
|
738
|
+
command
|
|
739
|
+
.command('validate')
|
|
740
|
+
.description('Validate a *.tour.json against the spec')
|
|
741
|
+
.argument('<file>', 'Path to a *.tour.json file')
|
|
742
|
+
.option('-j, --json', 'Output results as JSON')
|
|
743
|
+
.action((file, options) => {
|
|
744
|
+
validateTourFile(file, options);
|
|
745
|
+
});
|
|
746
|
+
command
|
|
747
|
+
.command('stats')
|
|
748
|
+
.description('Show tour timing/length analysis vs. the authoring guidelines')
|
|
749
|
+
.argument('<file>', 'Path to a *.tour.json file')
|
|
750
|
+
.option('-j, --json', 'Output results as JSON')
|
|
751
|
+
.action((file, options) => {
|
|
752
|
+
statsTourFile(file, options);
|
|
753
|
+
});
|
|
754
|
+
command
|
|
755
|
+
.command('view')
|
|
756
|
+
.description('Open a tour in the standalone viewer (local mode): a *.tour.json path, or a store id/url')
|
|
757
|
+
.argument('[file-or-id]', 'Path to a *.tour.json file, a tour id, or a /tour/<id> URL (omit when using --file)')
|
|
758
|
+
.option('--file <path>', 'Open a local *.tour.json file directly (skips fetch + cache)')
|
|
759
|
+
.option('--repo-root <path>', 'Working tree the tour is authored against (default: cwd)')
|
|
760
|
+
.option('--refresh', 'Bypass the tour JSON cache and re-fetch (id/url only)')
|
|
761
|
+
.option('--viewer-dir <path>', 'Path to the @principal-ai/subsystems-studio package (overrides PRINCIPAL_STUDIO_DIR)')
|
|
762
|
+
.action(async (input, options) => {
|
|
763
|
+
await viewTour(input, options);
|
|
764
|
+
});
|
|
765
|
+
command
|
|
766
|
+
.command('fetch')
|
|
767
|
+
.description('Fetch a tour by id/url from web-ade and print its JSON (also caches locally)')
|
|
768
|
+
.argument('<id-or-url>', 'Tour id, or full https://app.principal-ade.com/tour/<id> URL')
|
|
769
|
+
.action(async (input) => {
|
|
770
|
+
await fetchTour(input);
|
|
771
|
+
});
|
|
772
|
+
command
|
|
773
|
+
.command('publish')
|
|
774
|
+
.description('Publish a *.tour.json to web-ade')
|
|
775
|
+
.argument('<file>', 'Path to a *.tour.json file')
|
|
776
|
+
.option('--owner <owner>', 'GitHub owner to publish under (overrides the git remote)')
|
|
777
|
+
.option('--repo <repo>', 'GitHub repo to publish under (overrides the git remote)')
|
|
778
|
+
.option('--purl <purl>', 'Anchor by Purl (e.g. pkg:github/owner/repo); overrides --owner/--repo derivation')
|
|
779
|
+
.action(async (file, options) => {
|
|
780
|
+
await publishTour(file, options);
|
|
781
|
+
});
|
|
782
|
+
return command;
|
|
783
|
+
}
|