@asrunjs/asrun 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/explore-case.md +8 -3
- package/.claude/skills/exploring-cases/SKILL.md +72 -14
- package/.claude/skills/exploring-cases/references/handoff-card.js +81 -7
- package/.claude/skills/exploring-cases/references/notes.md +42 -4
- package/.claude/skills/exploring-cases/references/open-questions.md +2 -2
- package/.claude/skills/exploring-cases/references/readiness.md +6 -0
- package/.claude/skills/exploring-cases/references/user-handoff.md +60 -27
- package/.claude/skills/generating-specs/SKILL.md +10 -3
- package/AGENTS.md +12 -4
- package/README.md +14 -2
- package/dist/lib/desktop-notify.d.ts +38 -0
- package/dist/lib/desktop-notify.js +158 -0
- package/dist/lib/explore-notes.d.ts +15 -0
- package/dist/lib/explore-notes.js +34 -1
- package/dist/lib/migrations.d.ts +43 -4
- package/dist/lib/migrations.js +39 -7
- package/dist/lib/paths.d.ts +9 -2
- package/dist/lib/paths.js +32 -5
- package/dist/lib/pdf.js +23 -3
- package/dist/lib/scaffold.d.ts +87 -0
- package/dist/lib/scaffold.js +220 -0
- package/dist/lib/term.d.ts +36 -0
- package/dist/lib/term.js +77 -0
- package/dist/lib/update-check.d.ts +1 -7
- package/dist/lib/update-check.js +3 -15
- package/dist/lib/upgrade-prompt.d.ts +84 -0
- package/dist/lib/upgrade-prompt.js +233 -0
- package/dist/migrations/index.d.ts +12 -4
- package/dist/migrations/index.js +40 -5
- package/dist/migrations/manifest.json +27 -1
- package/dist/scripts/build.js +4 -1
- package/dist/scripts/check-cases.js +3 -3
- package/dist/scripts/cli.js +11 -3
- package/dist/scripts/doctor.js +73 -4
- package/dist/scripts/explore-notes.js +19 -2
- package/dist/scripts/init.d.ts +6 -26
- package/dist/scripts/init.js +82 -101
- package/dist/scripts/migrate.js +84 -21
- package/dist/scripts/migrations-check.d.ts +1 -0
- package/dist/scripts/migrations-check.js +272 -0
- package/dist/scripts/migrations-freeze.d.ts +1 -0
- package/dist/scripts/migrations-freeze.js +126 -0
- package/dist/scripts/notify-test.d.ts +1 -0
- package/dist/scripts/notify-test.js +48 -0
- package/dist/scripts/upgrade.js +53 -10
- package/dist/ui/server/actions.js +4 -2
- package/dist/ui/server/ai-stream.d.ts +7 -1
- package/dist/ui/server/ai-stream.js +26 -7
- package/dist/ui/server/app.js +4 -0
- package/dist/ui/server/jobs.d.ts +10 -0
- package/dist/ui/server/jobs.js +41 -1
- package/dist/ui/shared/ai-commands.js +4 -2
- package/dist/ui/shared/types.d.ts +8 -0
- package/docs/agents/case-format.md +8 -1
- package/docs/agents/framework-dev.md +44 -5
- package/docs/ai-workflow.md +27 -10
- package/docs/cases.md +5 -5
- package/docs/commands.md +73 -15
- package/docs/install.md +18 -5
- package/docs/troubleshooting.md +17 -0
- package/docs/web-ui.md +23 -8
- package/package.json +5 -2
- package/templates/.claude/commands/explore-case.md +9 -3
- package/templates/.claude/commands/upgrade-workspace.md +21 -0
- package/templates/AGENTS.md +1 -1
- package/templates/package.json +1 -1
- package/ui/dist/assets/{dist-C46EmZuI.js → dist-B4pM62Hc.js} +1 -1
- package/ui/dist/assets/{dist-DGzg2gyA.js → dist-BFADGd4p.js} +1 -1
- package/ui/dist/assets/{dist-P9roUi1O.js → dist-Bd-TmWTD.js} +1 -1
- package/ui/dist/assets/{dist-DMiOs9Qj.js → dist-Bt053MSo.js} +1 -1
- package/ui/dist/assets/{dist-DrFzCwMc.js → dist-CD9RixNE.js} +1 -1
- package/ui/dist/assets/{dist-_0g6iC9K.js → dist-ChACtLiA.js} +1 -1
- package/ui/dist/assets/{dist-D9GmSkjm.js → dist-CiW9sEPF.js} +1 -1
- package/ui/dist/assets/{dist-Db_XhDui.js → dist-CxEpzoZU.js} +1 -1
- package/ui/dist/assets/{dist-BB2cF3XN.js → dist-Dh7um7By.js} +1 -1
- package/ui/dist/assets/{dist-Dy45nXf8.js → dist-gHwKkZ6g.js} +1 -1
- package/ui/dist/assets/{index-BN4ucmmn.js → index-Ckax27uf.js} +158 -158
- package/ui/dist/index.html +1 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type WorkspaceMarker } from './migrations.js';
|
|
2
|
+
/**
|
|
3
|
+
* The files a workspace gets from the framework, and the baseline that says whether the project has aligned a
|
|
4
|
+
* project-owned file with the template of the release it now runs.
|
|
5
|
+
*
|
|
6
|
+
* `init` is the only writer of the files themselves; this module is also what the upgrade prompt and `doctor` read, so
|
|
7
|
+
* "which project files still need a person or an AI" is a computed answer rather than a guess. The baseline lives in
|
|
8
|
+
* `.asrun.json` (`scaffold`), so it travels with the repository: a clone sees exactly what the person who upgraded saw.
|
|
9
|
+
*/
|
|
10
|
+
/** Files `init` owns: `--update` rewrites exactly these, and a project never edits them. */
|
|
11
|
+
export declare const GENERATED: {
|
|
12
|
+
file: string;
|
|
13
|
+
template: string;
|
|
14
|
+
}[];
|
|
15
|
+
/** Files `init` creates once; after that the project owns them and may edit them freely. */
|
|
16
|
+
export declare const ONCE: {
|
|
17
|
+
file: string;
|
|
18
|
+
template: string;
|
|
19
|
+
}[];
|
|
20
|
+
/** True for a file a release may only merge into, never replace. */
|
|
21
|
+
export declare function isProjectData(file: string): boolean;
|
|
22
|
+
/** How a workspace reaches the framework: `./node_modules/@asrunjs/asrun`, or a relative folder in the framework repo. */
|
|
23
|
+
export declare function frameworkPath(root: string): string;
|
|
24
|
+
/** True when this workspace has the package installed (or is the framework's own checkout). */
|
|
25
|
+
export declare function isInstalled(root: string): boolean;
|
|
26
|
+
/** Fills the `{{framework}}`, `{{playwright}}` and `{{asrun}}` placeholders of a template. */
|
|
27
|
+
export declare function renderTemplate(name: string, root: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* The Playwright CLI the MCP server runs. The workspace's own install is the normal place; a workspace that lives
|
|
30
|
+
* inside the framework repository (examples/demo) has no `node_modules` of its own, so it points at the framework's.
|
|
31
|
+
* Kept relative: the file is committed and must not name a machine.
|
|
32
|
+
*/
|
|
33
|
+
export declare function playwrightBin(root: string): string;
|
|
34
|
+
/** The version and commit of the framework that is installed (or of this checkout). */
|
|
35
|
+
export declare function frameworkVersion(): {
|
|
36
|
+
version: string;
|
|
37
|
+
commit: string;
|
|
38
|
+
};
|
|
39
|
+
/** One entry of the scaffold baseline: the template the project file is known to be aligned with. */
|
|
40
|
+
export interface ScaffoldRecord {
|
|
41
|
+
/** The release whose template the file was aligned with; `unknown` for a record written from an older marker. */
|
|
42
|
+
version: string;
|
|
43
|
+
/** sha256 (16 hex) of the rendered template at that release. */
|
|
44
|
+
template: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* What an upgrade reports about one project-owned file.
|
|
48
|
+
*
|
|
49
|
+
* - `current`: the file equals the template of the installed release; nothing to do.
|
|
50
|
+
* - `aligned`: the project edited it, and it was confirmed aligned with this template (`init --ack` or a migration).
|
|
51
|
+
* - `replace`: nobody edited it and the template changed; `init --force <file>` is safe.
|
|
52
|
+
* - `merge`: both changed; a person or an AI merges the template's part and then confirms with `init --ack`.
|
|
53
|
+
* - `unknown-base`: no baseline (the workspace predates the baseline); compare the two by hand and report.
|
|
54
|
+
* - `missing`: the template has the file and the workspace does not.
|
|
55
|
+
*/
|
|
56
|
+
export type ScaffoldState = 'current' | 'aligned' | 'replace' | 'merge' | 'unknown-base' | 'missing';
|
|
57
|
+
export interface ScaffoldDrift {
|
|
58
|
+
file: string;
|
|
59
|
+
state: ScaffoldState;
|
|
60
|
+
/** The template's hash at the installed release. */
|
|
61
|
+
templateHash: string;
|
|
62
|
+
/** The hash recorded in `.asrun.json`, or null when the workspace never recorded one. */
|
|
63
|
+
recordedHash: string | null;
|
|
64
|
+
/** True when the project changed the file away from the template it was created from. */
|
|
65
|
+
edited: boolean;
|
|
66
|
+
/** `profiles.yaml` / `package.json`: merge by hand, never replace. */
|
|
67
|
+
projectData: boolean;
|
|
68
|
+
}
|
|
69
|
+
/** A short, stable fingerprint of a rendered template: enough to tell "same" from "changed", never a secret. */
|
|
70
|
+
export declare function templateHash(text: string): string;
|
|
71
|
+
/** The scaffold baseline of a marker, tolerant of an older marker without one (or a hand-edited entry). */
|
|
72
|
+
export declare function scaffoldRecords(marker: WorkspaceMarker | null): Record<string, ScaffoldRecord>;
|
|
73
|
+
/**
|
|
74
|
+
* The state of every once-only file against the installed release's templates. Read-only: it never writes the marker,
|
|
75
|
+
* because `doctor` and the prompt must be safe to run at any time.
|
|
76
|
+
*/
|
|
77
|
+
export declare function scaffoldDrift(root: string, marker?: WorkspaceMarker | null): ScaffoldDrift[];
|
|
78
|
+
/** The drift entries that still need somebody: everything except `current` and `aligned`. */
|
|
79
|
+
export declare function outstandingDrift(drift: readonly ScaffoldDrift[]): ScaffoldDrift[];
|
|
80
|
+
/**
|
|
81
|
+
* Records that these project files are aligned with the installed release's templates, without touching the files:
|
|
82
|
+
* what `init --ack <file>` and `migrate --ack <id>` (for the files its `files` names) do. A file that does not exist
|
|
83
|
+
* is skipped: `init --force <file>` is the command for a missing one.
|
|
84
|
+
*/
|
|
85
|
+
export declare function ackScaffold(root: string, files: readonly string[], version?: string): string[];
|
|
86
|
+
/** The once-only files among a list of paths: what a migration's `files` may name and what an ack can advance. */
|
|
87
|
+
export declare function scaffoldFiles(files: readonly string[]): string[];
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { FRAMEWORK_ROOT, PACKAGE, TEMPLATES_DIR, isRepoCheckout } from './paths.js';
|
|
6
|
+
import { readMarker, updateMarker } from './migrations.js';
|
|
7
|
+
/**
|
|
8
|
+
* The files a workspace gets from the framework, and the baseline that says whether the project has aligned a
|
|
9
|
+
* project-owned file with the template of the release it now runs.
|
|
10
|
+
*
|
|
11
|
+
* `init` is the only writer of the files themselves; this module is also what the upgrade prompt and `doctor` read, so
|
|
12
|
+
* "which project files still need a person or an AI" is a computed answer rather than a guess. The baseline lives in
|
|
13
|
+
* `.asrun.json` (`scaffold`), so it travels with the repository: a clone sees exactly what the person who upgraded saw.
|
|
14
|
+
*/
|
|
15
|
+
/** Files `init` owns: `--update` rewrites exactly these, and a project never edits them. */
|
|
16
|
+
export const GENERATED = [
|
|
17
|
+
{ file: 'playwright.config.ts', template: 'playwright.config.ts' },
|
|
18
|
+
{ file: 'tests/seed.spec.ts', template: 'seed.spec.ts' },
|
|
19
|
+
{ file: '.claude/commands/make-case.md', template: '.claude/commands/make-case.md' },
|
|
20
|
+
{ file: '.claude/commands/explore-case.md', template: '.claude/commands/explore-case.md' },
|
|
21
|
+
{ file: '.claude/commands/gen-spec.md', template: '.claude/commands/gen-spec.md' },
|
|
22
|
+
{ file: '.claude/commands/heal-spec.md', template: '.claude/commands/heal-spec.md' },
|
|
23
|
+
{ file: '.claude/commands/upgrade-workspace.md', template: '.claude/commands/upgrade-workspace.md' },
|
|
24
|
+
// OpenCode does not read `.claude/commands` (only `.claude/skills`), so the same commands live here too.
|
|
25
|
+
{ file: '.opencode/commands/make-case.md', template: '.claude/commands/make-case.md' },
|
|
26
|
+
{ file: '.opencode/commands/explore-case.md', template: '.claude/commands/explore-case.md' },
|
|
27
|
+
{ file: '.opencode/commands/gen-spec.md', template: '.claude/commands/gen-spec.md' },
|
|
28
|
+
{ file: '.opencode/commands/heal-spec.md', template: '.claude/commands/heal-spec.md' },
|
|
29
|
+
{ file: '.opencode/commands/upgrade-workspace.md', template: '.claude/commands/upgrade-workspace.md' },
|
|
30
|
+
];
|
|
31
|
+
/** Files `init` creates once; after that the project owns them and may edit them freely. */
|
|
32
|
+
export const ONCE = [
|
|
33
|
+
{ file: 'AGENTS.md', template: 'AGENTS.md' },
|
|
34
|
+
{ file: 'CLAUDE.md', template: 'CLAUDE.md' },
|
|
35
|
+
{ file: '.mcp.json', template: '.mcp.json' },
|
|
36
|
+
{ file: 'opencode.jsonc', template: 'opencode.jsonc' },
|
|
37
|
+
{ file: '.claude/settings.json', template: '.claude/settings.json' },
|
|
38
|
+
{ file: 'tsconfig.json', template: 'tsconfig.json' },
|
|
39
|
+
{ file: 'pnpm-workspace.yaml', template: 'pnpm-workspace.yaml' },
|
|
40
|
+
{ file: 'profiles.yaml', template: 'profiles.yaml' },
|
|
41
|
+
{ file: 'cases/_template.md', template: 'cases/_template.md' },
|
|
42
|
+
{ file: 'package.json', template: 'package.json' },
|
|
43
|
+
];
|
|
44
|
+
/**
|
|
45
|
+
* The project's own data among the once-only files: a release never replaces it (`--force` refuses too), so a drift
|
|
46
|
+
* report says "merge by hand" instead of offering a command.
|
|
47
|
+
*/
|
|
48
|
+
const PROJECT_DATA = new Set(['profiles.yaml', 'package.json']);
|
|
49
|
+
/** True for a file a release may only merge into, never replace. */
|
|
50
|
+
export function isProjectData(file) {
|
|
51
|
+
return PROJECT_DATA.has(file);
|
|
52
|
+
}
|
|
53
|
+
/** How a workspace reaches the framework: `./node_modules/@asrunjs/asrun`, or a relative folder in the framework repo. */
|
|
54
|
+
export function frameworkPath(root) {
|
|
55
|
+
if (!isRepoCheckout()) {
|
|
56
|
+
// The standard install location, or a hoisted one in a monorepo.
|
|
57
|
+
for (let dir = path.resolve(root);; dir = path.dirname(dir)) {
|
|
58
|
+
const candidate = path.join(dir, 'node_modules', PACKAGE);
|
|
59
|
+
if (fs.existsSync(path.join(candidate, 'package.json'))) {
|
|
60
|
+
const relative = path.relative(root, candidate).split(path.sep).join('/');
|
|
61
|
+
return relative.startsWith('.') ? relative : `./${relative}`;
|
|
62
|
+
}
|
|
63
|
+
if (path.dirname(dir) === dir)
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
// Not installed yet (a scaffolding run): name the place the package will live.
|
|
67
|
+
return `./node_modules/${PACKAGE}`;
|
|
68
|
+
}
|
|
69
|
+
const relative = path.relative(root, FRAMEWORK_ROOT).split(path.sep).join('/');
|
|
70
|
+
if (relative === '')
|
|
71
|
+
return '.';
|
|
72
|
+
return relative.startsWith('.') ? relative : `./${relative}`;
|
|
73
|
+
}
|
|
74
|
+
/** True when this workspace has the package installed (or is the framework's own checkout). */
|
|
75
|
+
export function isInstalled(root) {
|
|
76
|
+
return fs.existsSync(path.join(path.resolve(root), frameworkPath(root), 'package.json'));
|
|
77
|
+
}
|
|
78
|
+
/** The range the workspace's `package.json` names for the framework: `^` plus the version that wrote the workspace. */
|
|
79
|
+
let asrunRange;
|
|
80
|
+
function asrunDependencyRange() {
|
|
81
|
+
asrunRange ??= `^${frameworkVersion().version}`;
|
|
82
|
+
return asrunRange;
|
|
83
|
+
}
|
|
84
|
+
function readTemplate(name) {
|
|
85
|
+
return fs.readFileSync(path.join(TEMPLATES_DIR, name), 'utf8');
|
|
86
|
+
}
|
|
87
|
+
/** Fills the `{{framework}}`, `{{playwright}}` and `{{asrun}}` placeholders of a template. */
|
|
88
|
+
export function renderTemplate(name, root) {
|
|
89
|
+
return readTemplate(name)
|
|
90
|
+
.replaceAll('{{framework}}', frameworkPath(root))
|
|
91
|
+
.replaceAll('{{playwright}}', playwrightBin(root))
|
|
92
|
+
.replaceAll('{{asrun}}', asrunDependencyRange());
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The Playwright CLI the MCP server runs. The workspace's own install is the normal place; a workspace that lives
|
|
96
|
+
* inside the framework repository (examples/demo) has no `node_modules` of its own, so it points at the framework's.
|
|
97
|
+
* Kept relative: the file is committed and must not name a machine.
|
|
98
|
+
*/
|
|
99
|
+
export function playwrightBin(root) {
|
|
100
|
+
if (fs.existsSync(path.join(root, 'node_modules', '.bin', 'playwright')))
|
|
101
|
+
return './node_modules/.bin/playwright';
|
|
102
|
+
const framework = frameworkPath(root);
|
|
103
|
+
return framework.startsWith('..') ? `${framework}/node_modules/.bin/playwright` : './node_modules/.bin/playwright';
|
|
104
|
+
}
|
|
105
|
+
/** The version and commit of the framework that is installed (or of this checkout). */
|
|
106
|
+
export function frameworkVersion() {
|
|
107
|
+
let version = '0.0.0';
|
|
108
|
+
try {
|
|
109
|
+
version = JSON.parse(fs.readFileSync(path.join(FRAMEWORK_ROOT, 'package.json'), 'utf8')).version ?? version;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// keep the default
|
|
113
|
+
}
|
|
114
|
+
let commit = 'unknown';
|
|
115
|
+
try {
|
|
116
|
+
commit = spawnSync('git', ['-C', FRAMEWORK_ROOT, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf8' }).stdout.trim() || 'unknown';
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// keep the default
|
|
120
|
+
}
|
|
121
|
+
return { version, commit };
|
|
122
|
+
}
|
|
123
|
+
/** A short, stable fingerprint of a rendered template: enough to tell "same" from "changed", never a secret. */
|
|
124
|
+
export function templateHash(text) {
|
|
125
|
+
// Line endings are not a change: a checkout on Windows may hold CRLF while init wrote LF.
|
|
126
|
+
return createHash('sha256').update(text.replace(/\r\n/g, '\n')).digest('hex').slice(0, 16);
|
|
127
|
+
}
|
|
128
|
+
/** The scaffold baseline of a marker, tolerant of an older marker without one (or a hand-edited entry). */
|
|
129
|
+
export function scaffoldRecords(marker) {
|
|
130
|
+
const raw = marker?.scaffold;
|
|
131
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
132
|
+
return {};
|
|
133
|
+
const records = {};
|
|
134
|
+
for (const [file, value] of Object.entries(raw)) {
|
|
135
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
136
|
+
continue;
|
|
137
|
+
const entry = value;
|
|
138
|
+
if (typeof entry.template !== 'string')
|
|
139
|
+
continue;
|
|
140
|
+
records[file] = { version: typeof entry.version === 'string' ? entry.version : 'unknown', template: entry.template };
|
|
141
|
+
}
|
|
142
|
+
return records;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The state of every once-only file against the installed release's templates. Read-only: it never writes the marker,
|
|
146
|
+
* because `doctor` and the prompt must be safe to run at any time.
|
|
147
|
+
*/
|
|
148
|
+
export function scaffoldDrift(root, marker = readMarker(root)) {
|
|
149
|
+
const records = scaffoldRecords(marker);
|
|
150
|
+
// The framework's own checkout (examples/demo) has no package-manager settings of its own, and init skips them
|
|
151
|
+
// there: reporting them as missing would nag every maintainer forever.
|
|
152
|
+
const checkout = frameworkPath(root).startsWith('..');
|
|
153
|
+
return ONCE.filter((entry) => !(checkout && (entry.file === 'pnpm-workspace.yaml' || entry.file === 'package.json'))).map((entry) => {
|
|
154
|
+
const file = path.join(root, entry.file);
|
|
155
|
+
const hash = templateHash(renderTemplate(entry.template, root));
|
|
156
|
+
const record = records[entry.file];
|
|
157
|
+
const projectData = isProjectData(entry.file);
|
|
158
|
+
const base = {
|
|
159
|
+
file: entry.file,
|
|
160
|
+
templateHash: hash,
|
|
161
|
+
recordedHash: record?.template ?? null,
|
|
162
|
+
projectData,
|
|
163
|
+
};
|
|
164
|
+
if (!fs.existsSync(file))
|
|
165
|
+
return { ...base, state: 'missing', edited: false };
|
|
166
|
+
const current = readFile(file);
|
|
167
|
+
if (current === null)
|
|
168
|
+
return { ...base, state: 'unknown-base', edited: false };
|
|
169
|
+
const currentHash = templateHash(current);
|
|
170
|
+
if (currentHash === hash)
|
|
171
|
+
return { ...base, state: 'current', edited: false };
|
|
172
|
+
if (record === undefined)
|
|
173
|
+
return { ...base, state: 'unknown-base', edited: true };
|
|
174
|
+
if (record.template === hash)
|
|
175
|
+
return { ...base, state: 'aligned', edited: true };
|
|
176
|
+
const edited = currentHash !== record.template;
|
|
177
|
+
return { ...base, state: edited ? 'merge' : 'replace', edited };
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/** The drift entries that still need somebody: everything except `current` and `aligned`. */
|
|
181
|
+
export function outstandingDrift(drift) {
|
|
182
|
+
return drift.filter((entry) => entry.state !== 'current' && entry.state !== 'aligned');
|
|
183
|
+
}
|
|
184
|
+
function readFile(file) {
|
|
185
|
+
try {
|
|
186
|
+
return fs.readFileSync(file, 'utf8');
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Records that these project files are aligned with the installed release's templates, without touching the files:
|
|
194
|
+
* what `init --ack <file>` and `migrate --ack <id>` (for the files its `files` names) do. A file that does not exist
|
|
195
|
+
* is skipped: `init --force <file>` is the command for a missing one.
|
|
196
|
+
*/
|
|
197
|
+
export function ackScaffold(root, files, version = frameworkVersion().version) {
|
|
198
|
+
const acknowledged = [];
|
|
199
|
+
updateMarker(root, (marker) => {
|
|
200
|
+
const records = scaffoldRecords(marker);
|
|
201
|
+
for (const relative of files) {
|
|
202
|
+
const entry = ONCE.find((candidate) => candidate.file === normalize(relative));
|
|
203
|
+
if (entry === undefined)
|
|
204
|
+
continue;
|
|
205
|
+
if (!fs.existsSync(path.join(root, entry.file)))
|
|
206
|
+
continue;
|
|
207
|
+
records[entry.file] = { version, template: templateHash(renderTemplate(entry.template, root)) };
|
|
208
|
+
acknowledged.push(entry.file);
|
|
209
|
+
}
|
|
210
|
+
marker.scaffold = records;
|
|
211
|
+
});
|
|
212
|
+
return acknowledged;
|
|
213
|
+
}
|
|
214
|
+
/** The once-only files among a list of paths: what a migration's `files` may name and what an ack can advance. */
|
|
215
|
+
export function scaffoldFiles(files) {
|
|
216
|
+
return files.map(normalize).filter((file) => ONCE.some((entry) => entry.file === file));
|
|
217
|
+
}
|
|
218
|
+
function normalize(file) {
|
|
219
|
+
return file.split('\\').join('/').replace(/^\.\//, '');
|
|
220
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal output shared by the commands: ANSI colors that respect `NO_COLOR` / `FORCE_COLOR`, and one card for a
|
|
3
|
+
* message that must not be missed (the "what is still to do" block of an upgrade).
|
|
4
|
+
*
|
|
5
|
+
* A card keeps its frame and its label when there is no color (a pipe, a CI log, the UI job log), so the same text
|
|
6
|
+
* stands out there too; the color only makes it louder on a terminal.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Whether ANSI colors belong on this stream: an interactive terminal, unless NO_COLOR or FORCE_COLOR says otherwise.
|
|
10
|
+
* `FORCE_COLOR` wins, so a smoke test can see the colored line in a captured log.
|
|
11
|
+
*/
|
|
12
|
+
export declare function useColor(stream: {
|
|
13
|
+
isTTY?: boolean;
|
|
14
|
+
}): boolean;
|
|
15
|
+
/** One ANSI-styled run, or the text untouched when colors are off. */
|
|
16
|
+
export declare function paint(text: string, code: string, color: boolean): string;
|
|
17
|
+
export interface CardRow {
|
|
18
|
+
/** The short left column: a file path, a migration id, a state. */
|
|
19
|
+
label: string;
|
|
20
|
+
/** The right column: what to do about it, or the command to run. Wrapped to the card. */
|
|
21
|
+
text: string;
|
|
22
|
+
}
|
|
23
|
+
export interface CardOptions {
|
|
24
|
+
/** The word in the badge, e.g. `NEXT`. Kept in English: it is a marker people learn to look for. */
|
|
25
|
+
label: string;
|
|
26
|
+
/** The first line of the body, in one sentence. */
|
|
27
|
+
title: string;
|
|
28
|
+
/** Two-column rows (file, then what to do), aligned on the left column. */
|
|
29
|
+
rows?: CardRow[];
|
|
30
|
+
/** Plain lines, indented, after the rows. */
|
|
31
|
+
lines?: string[];
|
|
32
|
+
/** ANSI colors on the stream the card goes to. */
|
|
33
|
+
color: boolean;
|
|
34
|
+
}
|
|
35
|
+
/** A framed block with a badge: the one thing a person must not scroll past. */
|
|
36
|
+
export declare function card(options: CardOptions, width?: number | undefined): string;
|
package/dist/lib/term.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal output shared by the commands: ANSI colors that respect `NO_COLOR` / `FORCE_COLOR`, and one card for a
|
|
3
|
+
* message that must not be missed (the "what is still to do" block of an upgrade).
|
|
4
|
+
*
|
|
5
|
+
* A card keeps its frame and its label when there is no color (a pipe, a CI log, the UI job log), so the same text
|
|
6
|
+
* stands out there too; the color only makes it louder on a terminal.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Whether ANSI colors belong on this stream: an interactive terminal, unless NO_COLOR or FORCE_COLOR says otherwise.
|
|
10
|
+
* `FORCE_COLOR` wins, so a smoke test can see the colored line in a captured log.
|
|
11
|
+
*/
|
|
12
|
+
export function useColor(stream) {
|
|
13
|
+
const force = (process.env.FORCE_COLOR ?? '').trim().toLowerCase();
|
|
14
|
+
if (force !== '' && force !== '0' && force !== 'false')
|
|
15
|
+
return true;
|
|
16
|
+
const noColor = (process.env.NO_COLOR ?? '').trim();
|
|
17
|
+
if (noColor !== '')
|
|
18
|
+
return false;
|
|
19
|
+
return stream.isTTY === true;
|
|
20
|
+
}
|
|
21
|
+
/** One ANSI-styled run, or the text untouched when colors are off. */
|
|
22
|
+
export function paint(text, code, color) {
|
|
23
|
+
return color ? `\u001b[${code}m${text}\u001b[0m` : text;
|
|
24
|
+
}
|
|
25
|
+
/** The width a card is drawn at: the terminal's, kept between 60 and 100 so it reads on a wide and a narrow window. */
|
|
26
|
+
function cardWidth(columns) {
|
|
27
|
+
const width = columns ?? 80;
|
|
28
|
+
return Math.max(60, Math.min(100, width));
|
|
29
|
+
}
|
|
30
|
+
/** A block of text wrapped to the card and indented, so a long path or sentence cannot break the frame. */
|
|
31
|
+
function wrap(text, room) {
|
|
32
|
+
const lines = [];
|
|
33
|
+
for (const paragraph of text.split('\n')) {
|
|
34
|
+
if (paragraph.trim() === '') {
|
|
35
|
+
lines.push('');
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
let current = '';
|
|
39
|
+
for (const word of paragraph.trim().split(/\s+/)) {
|
|
40
|
+
if (current === '')
|
|
41
|
+
current = word;
|
|
42
|
+
else if (current.length + 1 + word.length <= room)
|
|
43
|
+
current += ` ${word}`;
|
|
44
|
+
else {
|
|
45
|
+
lines.push(current);
|
|
46
|
+
current = word;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (current !== '')
|
|
50
|
+
lines.push(current);
|
|
51
|
+
}
|
|
52
|
+
return lines;
|
|
53
|
+
}
|
|
54
|
+
/** A framed block with a badge: the one thing a person must not scroll past. */
|
|
55
|
+
export function card(options, width = process.stdout.columns) {
|
|
56
|
+
const columns = cardWidth(width);
|
|
57
|
+
const rule = paint('─'.repeat(columns), '1;33', options.color);
|
|
58
|
+
const badge = paint(` ${options.label} `, '43;30;1', options.color);
|
|
59
|
+
const out = [rule, `${badge} ${paint(options.title, '1', options.color)}`];
|
|
60
|
+
const rows = options.rows ?? [];
|
|
61
|
+
if (rows.length > 0 || (options.lines ?? []).length > 0)
|
|
62
|
+
out.push('');
|
|
63
|
+
const labelWidth = rows.reduce((widest, row) => Math.max(widest, row.label.length), 0);
|
|
64
|
+
for (const row of rows) {
|
|
65
|
+
const head = ` ${row.label.padEnd(labelWidth)} `;
|
|
66
|
+
const [first, ...more] = wrap(row.text, columns - head.length);
|
|
67
|
+
out.push(`${head}${first ?? ''}`);
|
|
68
|
+
for (const line of more)
|
|
69
|
+
out.push(`${' '.repeat(head.length)}${line}`);
|
|
70
|
+
}
|
|
71
|
+
for (const line of options.lines ?? []) {
|
|
72
|
+
for (const wrapped of wrap(line, columns - 2))
|
|
73
|
+
out.push(wrapped === '' ? '' : ` ${wrapped}`);
|
|
74
|
+
}
|
|
75
|
+
out.push(rule);
|
|
76
|
+
return out.join('\n');
|
|
77
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { useColor } from './term.js';
|
|
1
2
|
export declare const CACHE_FILE: string;
|
|
2
3
|
/** Development only: a hand-written answer, so the notice can be seen without publishing a release. */
|
|
3
4
|
export declare const MOCK_FILE: string;
|
|
@@ -31,13 +32,6 @@ export declare function checkForUpdate(options?: {
|
|
|
31
32
|
}): UpdateStatus;
|
|
32
33
|
/** The command that moves this installation to the newest release. */
|
|
33
34
|
export declare function upgradeCommand(kind: InstallKind): string;
|
|
34
|
-
/**
|
|
35
|
-
* Whether ANSI colors belong on this stream: an interactive terminal, unless NO_COLOR or FORCE_COLOR says otherwise.
|
|
36
|
-
* `FORCE_COLOR` wins, so a smoke test can see the colored line in a captured log.
|
|
37
|
-
*/
|
|
38
|
-
export declare function useColor(stream: {
|
|
39
|
-
isTTY?: boolean;
|
|
40
|
-
}): boolean;
|
|
41
35
|
/**
|
|
42
36
|
* The one-line notice, or null when there is nothing to say. `color` adds a badge and highlights the version and the
|
|
43
37
|
* command, for a person looking at a terminal; the API and a piped log keep the plain sentence.
|
package/dist/lib/update-check.js
CHANGED
|
@@ -2,7 +2,10 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { compareVersions, isVersion } from './migrations.js';
|
|
4
4
|
import { FRAMEWORK_ROOT, PACKAGE, TMP_DIR, isRepoCheckout } from './paths.js';
|
|
5
|
+
import { paint, useColor } from './term.js';
|
|
5
6
|
import { releaseInfo } from './registry.js';
|
|
7
|
+
// Kept here for the callers that had it from this module before the terminal helpers moved to `term.ts`.
|
|
8
|
+
export { useColor } from './term.js';
|
|
6
9
|
/**
|
|
7
10
|
* Is a newer release of the framework on the registry? The command line asks before every command and the web UI asks
|
|
8
11
|
* once at startup and once a day; both read the same cache in `.tmp/`, so the registry is asked at most once a day per
|
|
@@ -173,21 +176,6 @@ export function checkForUpdate(options = {}) {
|
|
|
173
176
|
export function upgradeCommand(kind) {
|
|
174
177
|
return kind === 'checkout' ? 'git pull' : 'pnpm asrun upgrade';
|
|
175
178
|
}
|
|
176
|
-
/**
|
|
177
|
-
* Whether ANSI colors belong on this stream: an interactive terminal, unless NO_COLOR or FORCE_COLOR says otherwise.
|
|
178
|
-
* `FORCE_COLOR` wins, so a smoke test can see the colored line in a captured log.
|
|
179
|
-
*/
|
|
180
|
-
export function useColor(stream) {
|
|
181
|
-
const force = (process.env.FORCE_COLOR ?? '').trim().toLowerCase();
|
|
182
|
-
if (force !== '' && force !== '0' && force !== 'false')
|
|
183
|
-
return true;
|
|
184
|
-
const noColor = (process.env.NO_COLOR ?? '').trim();
|
|
185
|
-
if (noColor !== '')
|
|
186
|
-
return false;
|
|
187
|
-
return stream.isTTY === true;
|
|
188
|
-
}
|
|
189
|
-
/** One ANSI-styled run, or the text untouched when colors are off. */
|
|
190
|
-
const paint = (text, code, color) => (color ? `\u001b[${code}m${text}\u001b[0m` : text);
|
|
191
179
|
/**
|
|
192
180
|
* The one-line notice, or null when there is nothing to say. `color` adds a badge and highlights the version and the
|
|
193
181
|
* command, for a person looking at a terminal; the API and a piped log keep the plain sentence.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { type WorkspaceMarker } from './migrations.js';
|
|
2
|
+
import { type ScaffoldDrift } from './scaffold.js';
|
|
3
|
+
import { type CardRow } from './term.js';
|
|
4
|
+
/**
|
|
5
|
+
* What an upgrade leaves to a person or an AI, and the prompt that hands it over.
|
|
6
|
+
*
|
|
7
|
+
* One prompt, version-independent: the procedure (read the rules, merge the scaffold text, run the checks, never touch
|
|
8
|
+
* what a case asserts) is the same for every release, and the release-specific part is data — which migrations are
|
|
9
|
+
* outstanding and which project files the templates moved under. A workspace that has nothing to do gets a short
|
|
10
|
+
* prompt, so nobody spends an AI run on a patch release.
|
|
11
|
+
*/
|
|
12
|
+
/** One migration that still needs somebody, with the acknowledgement command that clears it. */
|
|
13
|
+
export interface OutstandingMigration {
|
|
14
|
+
id: string;
|
|
15
|
+
version: string;
|
|
16
|
+
summary: string;
|
|
17
|
+
details?: string;
|
|
18
|
+
manual: string[];
|
|
19
|
+
files: string[];
|
|
20
|
+
/** The command that records the manual steps as done; printed with the real id, never as `<id>`. */
|
|
21
|
+
ack: string;
|
|
22
|
+
}
|
|
23
|
+
/** A project file whose template changed (or which has no baseline yet), with the command that resolves it. */
|
|
24
|
+
export interface OutstandingFile {
|
|
25
|
+
file: string;
|
|
26
|
+
state: ScaffoldDrift['state'];
|
|
27
|
+
/** What to do, in one sentence. */
|
|
28
|
+
action: string;
|
|
29
|
+
/** The command to run once the action is done; null when only the project can decide. */
|
|
30
|
+
command: string | null;
|
|
31
|
+
projectData: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface UpgradeOutstanding {
|
|
34
|
+
drift: ScaffoldDrift[];
|
|
35
|
+
files: OutstandingFile[];
|
|
36
|
+
migrations: OutstandingMigration[];
|
|
37
|
+
/** Migrations this workspace has not applied yet (`pnpm asrun migrate` runs them). */
|
|
38
|
+
pending: number;
|
|
39
|
+
}
|
|
40
|
+
export interface UpgradePromptPayload {
|
|
41
|
+
workspace: string;
|
|
42
|
+
/** The framework as the workspace reaches it (a path relative to the workspace). */
|
|
43
|
+
framework: string;
|
|
44
|
+
/** `installed` for a package from the registry, `checkout` for the framework's own git checkout. */
|
|
45
|
+
kind: 'installed' | 'checkout';
|
|
46
|
+
/** The release the workspace came from, when `.asrun.json` still remembers it. */
|
|
47
|
+
from: string | null;
|
|
48
|
+
/** The release the workspace runs now. */
|
|
49
|
+
to: string;
|
|
50
|
+
drift: ScaffoldDrift[];
|
|
51
|
+
files: OutstandingFile[];
|
|
52
|
+
migrations: OutstandingMigration[];
|
|
53
|
+
pending: number;
|
|
54
|
+
/** The commands that end the work, in the order a person should run them. */
|
|
55
|
+
acks: Array<{
|
|
56
|
+
id: string;
|
|
57
|
+
command: string;
|
|
58
|
+
}>;
|
|
59
|
+
/** The checks the agent runs, in order; the same list in the prompt. */
|
|
60
|
+
checks: string[];
|
|
61
|
+
/** The prompt itself, ready to paste into an agent. */
|
|
62
|
+
prompt: string;
|
|
63
|
+
}
|
|
64
|
+
/** Everything an upgrade still leaves open, computed from the marker, the registry and the installed templates. */
|
|
65
|
+
export declare function upgradeOutstanding(root: string, marker?: WorkspaceMarker | null): UpgradeOutstanding;
|
|
66
|
+
/** True when an upgrade leaves nothing for a person or an AI to do. */
|
|
67
|
+
export declare function nothingToDo(outstanding: UpgradeOutstanding): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* The rows of the "NEXT" card: one per file or migration, each with the exact command. A person must be able to finish
|
|
70
|
+
* the upgrade by copying from here, without reading the prompt or the docs.
|
|
71
|
+
*/
|
|
72
|
+
export declare function nextStepRows(outstanding: UpgradeOutstanding): CardRow[];
|
|
73
|
+
/** One sentence for the title of the card, so the count and the work agree. */
|
|
74
|
+
export declare function nextStepTitle(outstanding: UpgradeOutstanding): string;
|
|
75
|
+
/** The payload and the prompt of one workspace, ready for the command line and for the web UI. */
|
|
76
|
+
export declare function upgradePrompt(root: string): UpgradePromptPayload;
|
|
77
|
+
/**
|
|
78
|
+
* The framed "what is left" block, or null when the workspace has nothing outstanding. It carries the exact commands and
|
|
79
|
+
* names the recommended way to have an agent do them, so a person never has to remember a flag or an id.
|
|
80
|
+
*/
|
|
81
|
+
export declare function nextStepsText(root: string, options: {
|
|
82
|
+
color: boolean;
|
|
83
|
+
width?: number;
|
|
84
|
+
}): string | null;
|