@asrunjs/asrun 0.7.0 → 0.8.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/AGENTS.md +6 -3
- package/README.md +21 -11
- package/bin/asrun.mjs +18 -2
- package/dist/lib/ffmpeg.js +21 -10
- package/dist/lib/migrations.d.ts +106 -0
- package/dist/lib/migrations.js +106 -0
- package/dist/lib/paths.d.ts +9 -0
- package/dist/lib/paths.js +12 -1
- package/dist/lib/registry.d.ts +31 -0
- package/dist/lib/registry.js +66 -0
- package/dist/lib/runtime-deps.d.ts +59 -0
- package/dist/lib/runtime-deps.js +161 -0
- package/dist/lib/tar.d.ts +5 -0
- package/dist/lib/tar.js +79 -0
- package/dist/lib/update-check.d.ts +52 -0
- package/dist/lib/update-check.js +223 -0
- package/dist/migrations/index.d.ts +23 -0
- package/dist/migrations/index.js +22 -0
- package/dist/migrations/manifest.json +1 -0
- package/dist/scripts/build.js +7 -0
- package/dist/scripts/cli.js +22 -4
- package/dist/scripts/doctor.js +51 -9
- package/dist/scripts/go.mjs +7 -5
- package/dist/scripts/init.d.ts +3 -2
- package/dist/scripts/init.js +73 -11
- package/dist/scripts/migrate.d.ts +1 -0
- package/dist/scripts/migrate.js +259 -0
- package/dist/scripts/run.js +2 -1
- package/dist/scripts/upgrade.d.ts +1 -0
- package/dist/scripts/upgrade.js +256 -0
- package/dist/ui/server/app.js +18 -0
- package/dist/ui/server/index.js +3 -0
- package/dist/ui/server/update.d.ts +18 -0
- package/dist/ui/server/update.js +87 -0
- package/dist/ui/shared/types.d.ts +24 -0
- package/docs/agents/framework-dev.md +74 -4
- package/docs/commands.md +74 -4
- package/docs/install.md +52 -16
- package/docs/troubleshooting.md +15 -7
- package/package.json +2 -4
- package/templates/AGENTS.md +1 -0
- package/templates/pnpm-workspace.yaml +3 -3
- package/ui/dist/assets/{dist-vmf3fnkM.js → dist-BC_P1Gnf.js} +1 -1
- package/ui/dist/assets/{dist-O_RVi4tP.js → dist-BgPbx2GZ.js} +1 -1
- package/ui/dist/assets/{dist-jPS6HCJ5.js → dist-BpkTvixI.js} +1 -1
- package/ui/dist/assets/{dist-DEPJZ3Me.js → dist-CJiwxr1r.js} +1 -1
- package/ui/dist/assets/{dist-CPLhRzom.js → dist-CavmZIv7.js} +1 -1
- package/ui/dist/assets/{dist-DZ7RiqmA.js → dist-ChBHX4rh.js} +1 -1
- package/ui/dist/assets/{dist-BudNAjOv.js → dist-D6jmnDr-.js} +1 -1
- package/ui/dist/assets/{dist-C-eCSFja.js → dist-DoChb6QQ.js} +1 -1
- package/ui/dist/assets/{dist-LixzGniF.js → dist-Do_MhHKF.js} +1 -1
- package/ui/dist/assets/{dist-CO4heSzh.js → dist-N-o4QIaZ.js} +1 -1
- package/ui/dist/assets/index-Cy2l91qV.css +2 -0
- package/ui/dist/assets/{index-BIGt8Hfz.js → index-DGQ5logJ.js} +129 -129
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-CTmnF0Wp.css +0 -2
package/AGENTS.md
CHANGED
|
@@ -151,9 +151,12 @@ Three steps, each learns one thing: the intent, the facts and inputs, the implem
|
|
|
151
151
|
writes data and never creates baselines. A tagged step with no `- Recipe:` line (an old `## Inputs` line counts
|
|
152
152
|
until it is moved) stops with `needs-input`: run `/explore-case` first. Running specs is a separate step
|
|
153
153
|
(`pnpm asrun`, or the Run page).
|
|
154
|
-
Workspace maintenance is separate from the AI steps: `pnpm asrun
|
|
155
|
-
|
|
156
|
-
|
|
154
|
+
Workspace maintenance is separate from the AI steps: `pnpm asrun upgrade` installs the newest framework, refreshes the
|
|
155
|
+
generated files and applies the release's workspace migrations (it checks the target release for breaking changes
|
|
156
|
+
before installing), `pnpm asrun init [--update] [--force]` writes or refreshes the workspace files, `pnpm asrun
|
|
157
|
+
migrate` applies only the migrations (for a manual install), and `pnpm asrun reset` removes `node_modules` and the
|
|
158
|
+
files init wrote (edited files, cases, specs, `profiles.yaml` and `package.json` stay), so
|
|
159
|
+
`pnpm dlx @asrunjs/asrun init --install` can scaffold again.
|
|
157
160
|
Do not hand-write specs unless asked. Do not edit `lib/` to make a single spec pass.
|
|
158
161
|
Every case step is wrapped in `test.step('<n>. <case step text>', ...)` so reports and traces show the
|
|
159
162
|
case's own steps (see the skill's `references/conventions.md`).
|
package/README.md
CHANGED
|
@@ -31,8 +31,8 @@ AsRun is a devDependency, not a service. Your profiles, cases, specs and baselin
|
|
|
31
31
|
|
|
32
32
|
- **Node.js 22+** and **pnpm**.
|
|
33
33
|
- **ffmpeg** to join the recordings of one test into a video. `pnpm go` installs it with Homebrew or winget; on Linux
|
|
34
|
-
run `sudo apt install ffmpeg`. When it is missing,
|
|
35
|
-
lists the videos separately.
|
|
34
|
+
run `sudo apt install ffmpeg`. When it is missing, `pnpm go` / `pnpm asrun init` install the bundled `ffmpeg-static`
|
|
35
|
+
into the workspace; if neither works, the report lists the videos separately.
|
|
36
36
|
- **Claude Code** or **OpenCode** for the AI steps. Everything else works without them.
|
|
37
37
|
|
|
38
38
|
## Install
|
|
@@ -50,14 +50,18 @@ first time, `pnpm go` is enough.
|
|
|
50
50
|
To update:
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
|
-
pnpm
|
|
54
|
-
pnpm asrun init --update
|
|
55
|
-
pnpm asrun doctor
|
|
53
|
+
pnpm asrun upgrade
|
|
56
54
|
```
|
|
57
55
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
It checks the new release for breaking changes and migrations before installing it, installs it, refreshes the
|
|
57
|
+
generated files, applies the migrations and runs `doctor`. `--dry-run` shows the plan and changes nothing. A workspace
|
|
58
|
+
that predates `upgrade` updates once by hand: `pnpm add -D @asrunjs/asrun@latest`, `pnpm asrun init --update`,
|
|
59
|
+
`pnpm asrun migrate`, `pnpm asrun doctor`.
|
|
60
|
+
|
|
61
|
+
`init --update` alone refreshes only the generated files; it never overwrites `AGENTS.md`, `CLAUDE.md` or your skill
|
|
62
|
+
overrides. `doctor` checks profiles, generated files, the installed version, the migrations and the project rules.
|
|
63
|
+
`init --force` rewrites every file init writes (rules still need `--reset-rules`; `profiles.yaml` and `package.json`
|
|
64
|
+
stay yours).
|
|
61
65
|
|
|
62
66
|
To start over with fresh dependencies and a fresh scaffold:
|
|
63
67
|
|
|
@@ -79,9 +83,9 @@ pnpm asrun init
|
|
|
79
83
|
pnpm go
|
|
80
84
|
```
|
|
81
85
|
|
|
82
|
-
On pnpm 12, run `init` before adding the dependency: it writes the `allowBuilds` entry
|
|
83
|
-
|
|
84
|
-
pnpm's 24-hour release age, so a fresh release installs right away. Details and flags:
|
|
86
|
+
On pnpm 12, run `init` before adding the dependency: it writes the `allowBuilds` entry `esbuild` needs, and the entry
|
|
87
|
+
of each optional package it installs on demand, or `pnpm add` stops with `ERR_PNPM_IGNORED_BUILDS`. The same file
|
|
88
|
+
exempts `@asrunjs/asrun` from pnpm's 24-hour release age, so a fresh release installs right away. Details and flags:
|
|
85
89
|
[install.md](./docs/install.md).
|
|
86
90
|
|
|
87
91
|
</details>
|
|
@@ -147,6 +151,7 @@ Details: [ai-workflow.md](./docs/ai-workflow.md#customizing-the-prompts).
|
|
|
147
151
|
|
|
148
152
|
| I want to... | Command |
|
|
149
153
|
|---|---|
|
|
154
|
+
| Upgrade the framework and migrate the workspace | `pnpm asrun upgrade` |
|
|
150
155
|
| Check the workspace | `pnpm asrun doctor` |
|
|
151
156
|
| Start over with fresh dependencies and scaffold | `pnpm asrun reset [--yes] [--state] [--lockfile]` |
|
|
152
157
|
| See which profiles are logged in | `pnpm status [--env <env>]` |
|
|
@@ -175,6 +180,11 @@ editor, form panel, spec view, AI runs, notes-to-cases), **Run tests** (filters,
|
|
|
175
180
|
confirmation), **Reports** (failure first, then video, screenshots, trace, full Playwright report), **Jobs** (live log
|
|
176
181
|
of every command), **Help** and **AI guide**. See [web-ui.md](./docs/web-ui.md).
|
|
177
182
|
|
|
183
|
+
Every `asrun` command, and the web UI (at startup and once a day at 00:00 UTC), ask the registry whether a newer release
|
|
184
|
+
exists and, at most once a day and only if so, say which version and how to get it: `pnpm asrun upgrade`, or the badge in
|
|
185
|
+
the top bar. The check never blocks a command and stays quiet when the registry cannot be reached; set
|
|
186
|
+
`ASRUN_NO_UPDATE_CHECK=1` to switch it off.
|
|
187
|
+
|
|
178
188
|
## Safety
|
|
179
189
|
|
|
180
190
|
Tests run against real sites, so the defaults are strict:
|
package/bin/asrun.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import {spawn} from 'node:child_process';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import {fileURLToPath} from 'node:url';
|
|
6
|
+
import {fileURLToPath, pathToFileURL} from 'node:url';
|
|
7
7
|
|
|
8
8
|
const FRAMEWORK = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
9
|
const DIST = path.join(FRAMEWORK, 'dist');
|
|
@@ -14,6 +14,8 @@ const SCRIPTS = {
|
|
|
14
14
|
init: 'cli.js',
|
|
15
15
|
doctor: 'cli.js',
|
|
16
16
|
reset: 'cli.js',
|
|
17
|
+
upgrade: 'cli.js',
|
|
18
|
+
migrate: 'cli.js',
|
|
17
19
|
go: 'go.mjs',
|
|
18
20
|
ui: 'ui.js',
|
|
19
21
|
'ui:dev': 'ui-dev.js',
|
|
@@ -40,8 +42,22 @@ if (!fs.existsSync(script)) {
|
|
|
40
42
|
process.exit(1);
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
// `init`, `doctor` and `
|
|
45
|
+
// `init`, `doctor`, `reset`, `upgrade` and `migrate` are subcommands of cli.js; it reads them from its own arguments.
|
|
44
46
|
const cliArgs = scriptName === 'cli.js' && subcommand ? [subcommand, ...args] : args;
|
|
47
|
+
|
|
48
|
+
// A newer release on the registry gets one line on stderr before the command starts. The check reads `.tmp/` and asks
|
|
49
|
+
// the registry at most once a day; it is silent on any failure. The scripts of `cli.js` show the notice themselves (a
|
|
50
|
+
// framework checkout's `pnpm asrun` goes straight there), `upgrade` reads the registry itself, and both UI servers print
|
|
51
|
+
// the notice from their own process, so all of them are left alone. An older install has no such file: the import fails.
|
|
52
|
+
if (scriptName !== 'cli.js' && !['ui', 'ui:dev', 'help', '--help-init'].includes(subcommand ?? args[0] ?? '')) {
|
|
53
|
+
try {
|
|
54
|
+
const {announceUpdate} = await import(pathToFileURL(path.join(DIST, 'lib', 'update-check.js')).href);
|
|
55
|
+
announceUpdate();
|
|
56
|
+
} catch {
|
|
57
|
+
// Nothing to check with, or nothing to report.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
45
61
|
const child = spawn(process.execPath, [script, ...cliArgs], {cwd: process.cwd(), stdio: 'inherit'});
|
|
46
62
|
child.on('error', (error) => {
|
|
47
63
|
console.error(`Could not start asrun: ${error.message}`);
|
package/dist/lib/ffmpeg.js
CHANGED
|
@@ -3,22 +3,33 @@ import fs from 'node:fs';
|
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { ROOT } from './paths.js';
|
|
6
7
|
let cached;
|
|
8
|
+
/**
|
|
9
|
+
* The binary of `ffmpeg-static`: a devDependency of the workspace that `init`/`go` installed on demand. Resolved from
|
|
10
|
+
* the workspace first (where it lives), then from the framework's own install (a development checkout, or a hoisted
|
|
11
|
+
* dependency).
|
|
12
|
+
*/
|
|
13
|
+
function bundledFfmpeg() {
|
|
14
|
+
for (const anchor of [path.join(ROOT, 'package.json'), import.meta.url]) {
|
|
15
|
+
try {
|
|
16
|
+
const bundled = createRequire(anchor)('ffmpeg-static');
|
|
17
|
+
if (bundled && fs.existsSync(bundled))
|
|
18
|
+
return bundled;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Not installed here: try the next place, then fall back to no ffmpeg at all.
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
7
26
|
/** The system `ffmpeg` if there is one, else the binary of the optional `ffmpeg-static` package, else null. */
|
|
8
27
|
export function findFfmpeg() {
|
|
9
28
|
if (cached !== undefined)
|
|
10
29
|
return cached;
|
|
11
30
|
if (spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0)
|
|
12
31
|
return (cached = 'ffmpeg');
|
|
13
|
-
|
|
14
|
-
const bundled = createRequire(import.meta.url)('ffmpeg-static');
|
|
15
|
-
if (bundled && fs.existsSync(bundled))
|
|
16
|
-
return (cached = bundled);
|
|
17
|
-
}
|
|
18
|
-
catch {
|
|
19
|
-
// ffmpeg-static is optional and may not be installed.
|
|
20
|
-
}
|
|
21
|
-
return (cached = null);
|
|
32
|
+
return (cached = bundledFfmpeg());
|
|
22
33
|
}
|
|
23
34
|
/**
|
|
24
35
|
* Joins `inputs` (in order) into one webm at `output`. Re-encodes on purpose: Playwright's recordings have loose
|
|
@@ -28,7 +39,7 @@ export function findFfmpeg() {
|
|
|
28
39
|
export function concatVideos(inputs, output) {
|
|
29
40
|
const ffmpeg = findFfmpeg();
|
|
30
41
|
if (!ffmpeg)
|
|
31
|
-
return 'ffmpeg not found (install ffmpeg or the
|
|
42
|
+
return 'ffmpeg not found (install ffmpeg, or run "pnpm asrun init" to install the bundled ffmpeg-static)';
|
|
32
43
|
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
33
44
|
const listDir = fs.mkdtempSync(path.join(os.tmpdir(), 'asrun-concat-'));
|
|
34
45
|
const list = path.join(listDir, 'inputs.txt');
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace migrations: the changes an existing workspace must make when the framework moves it from an older
|
|
3
|
+
* release to a newer one (the registry is `migrations/index.ts`, the procedure is in `docs/agents/framework-dev.md`).
|
|
4
|
+
*
|
|
5
|
+
* The same entries are published as `dist/migrations/manifest.json`. A still-old `pnpm asrun upgrade` reads that file
|
|
6
|
+
* out of the target release *before* it installs it, so the manifest holds data only: never a function.
|
|
7
|
+
*/
|
|
8
|
+
/** What a migration receives while it runs, after the new release is installed. */
|
|
9
|
+
export interface MigrationContext {
|
|
10
|
+
/** the workspace root (the project that owns `profiles.yaml`) */
|
|
11
|
+
root: string;
|
|
12
|
+
/** the installed package folder of the framework */
|
|
13
|
+
frameworkRoot: string;
|
|
14
|
+
/** the version the workspace was last migrated from (`migrations.since` in `.asrun.json`) */
|
|
15
|
+
from: string;
|
|
16
|
+
/** the installed version the workspace is being migrated to */
|
|
17
|
+
to: string;
|
|
18
|
+
/** one line of progress for the person running the command */
|
|
19
|
+
log: (text: string) => void;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* One change an existing workspace must make. Append one to `MIGRATIONS`; never delete an entry, because a workspace
|
|
23
|
+
* on any older release may still need it.
|
|
24
|
+
*/
|
|
25
|
+
export interface Migration {
|
|
26
|
+
/** stable and unique: `<version>-<slug>`, for example `0.9.0-pnpm-release-age` */
|
|
27
|
+
id: string;
|
|
28
|
+
/** the release that introduced the change; workspaces older than this get the migration */
|
|
29
|
+
version: string;
|
|
30
|
+
/** one line for the person, in the release plan before the install and in the migration output after it */
|
|
31
|
+
summary: string;
|
|
32
|
+
/** true when the change can break an existing workspace, so `upgrade` asks before installing */
|
|
33
|
+
breaking?: boolean;
|
|
34
|
+
/** which workspaces the change matters for, in words, for the pre-install report */
|
|
35
|
+
appliesTo?: string;
|
|
36
|
+
/** what changes, and why, when the summary is not enough */
|
|
37
|
+
details?: string;
|
|
38
|
+
/** steps only a person can do; they are printed and remembered until they are acknowledged */
|
|
39
|
+
manual?: string[];
|
|
40
|
+
/** true when the migration runs without asking after the install; the command still prints what it did */
|
|
41
|
+
auto?: boolean;
|
|
42
|
+
/** true when the workspace already looks migrated; a false answer skips `apply` */
|
|
43
|
+
detect?: (context: MigrationContext) => boolean;
|
|
44
|
+
/** the change itself; must be idempotent and only touch what the migration describes */
|
|
45
|
+
apply?: (context: MigrationContext) => void | Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
/** A migration as it travels in `dist/migrations/manifest.json`: the same entry without its code. */
|
|
48
|
+
export type MigrationManifestEntry = Omit<Migration, 'detect' | 'apply'>;
|
|
49
|
+
/**
|
|
50
|
+
* The migration state of a workspace, recorded in `.asrun.json`. `since` is the release the workspace was last
|
|
51
|
+
* migrated from, so only newer entries apply. It survives `init --update`, which is what keeps a manual
|
|
52
|
+
* `pnpm add -D @asrunjs/asrun@latest` from erasing what still has to be done.
|
|
53
|
+
*/
|
|
54
|
+
export interface MigrationState {
|
|
55
|
+
since: string;
|
|
56
|
+
/** ids of migrations whose manual steps were reported and not acknowledged with `migrate --ack` yet */
|
|
57
|
+
manual: string[];
|
|
58
|
+
}
|
|
59
|
+
/** The part of `.asrun.json` this module reads. The rest belongs to `init`. */
|
|
60
|
+
export interface WorkspaceMarker {
|
|
61
|
+
version?: unknown;
|
|
62
|
+
migrations?: {
|
|
63
|
+
since?: unknown;
|
|
64
|
+
manual?: unknown;
|
|
65
|
+
};
|
|
66
|
+
[key: string]: unknown;
|
|
67
|
+
}
|
|
68
|
+
/** Where `pnpm build` writes the manifest, relative to the package root. */
|
|
69
|
+
export declare const MANIFEST_FILE = "dist/migrations/manifest.json";
|
|
70
|
+
/** The same file inside an npm tarball, which prefixes every path with `package/`. */
|
|
71
|
+
export declare const MANIFEST_ENTRY = "package/dist/migrations/manifest.json";
|
|
72
|
+
/** True when the text is a version a migration can be ordered by (`x.y.z`, an optional leading `v`, a prerelease). */
|
|
73
|
+
export declare function isVersion(value: string): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Orders two versions: negative when `left` is older, positive when it is newer, 0 when they are equal. A prerelease
|
|
76
|
+
* sorts before its release (`1.0.0-beta` < `1.0.0`). Throws on text that is not a version: a migration with a bad
|
|
77
|
+
* `version` is a mistake, not something to guess about.
|
|
78
|
+
*/
|
|
79
|
+
export declare function compareVersions(left: string, right: string): number;
|
|
80
|
+
/** Anything a migration list can be filtered by: a registry entry or a manifest entry. */
|
|
81
|
+
export interface VersionedMigration {
|
|
82
|
+
id: string;
|
|
83
|
+
version: string;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The entries that apply to a workspace moved from `since` to `to`: strictly newer than `since`, at or before `to`, in
|
|
87
|
+
* version order (id order for two entries of the same version). Both bounds are inclusive/exclusive on purpose:
|
|
88
|
+
* `since` is the release the workspace already matches, `to` is the release being installed.
|
|
89
|
+
*/
|
|
90
|
+
export declare function pendingMigrations<T extends VersionedMigration>(entries: readonly T[], since: string, to: string): T[];
|
|
91
|
+
/** The manifest of a registry: the same entries, without the code that cannot travel in JSON. */
|
|
92
|
+
export declare function toManifest(entries: readonly Migration[]): MigrationManifestEntry[];
|
|
93
|
+
/** The marker of a workspace, or null when the file is missing or not a JSON object. */
|
|
94
|
+
export declare function readMarker(root: string): WorkspaceMarker | null;
|
|
95
|
+
/**
|
|
96
|
+
* The migration state of a workspace. A marker written before migrations existed (or by a workspace that was never
|
|
97
|
+
* initialized) has no `migrations` block: its recorded `version` is then the baseline, and a workspace with neither
|
|
98
|
+
* starts at the installed version, so no historical entry runs on it.
|
|
99
|
+
*/
|
|
100
|
+
export declare function migrationState(marker: WorkspaceMarker | null, installedVersion: string): MigrationState;
|
|
101
|
+
/**
|
|
102
|
+
* Writes the migration state back into the marker and keeps every other field (init writes the rest). The workspace
|
|
103
|
+
* must already have a marker: `migrate` says so instead of creating one, because a workspace that was never
|
|
104
|
+
* initialized has no release to migrate from.
|
|
105
|
+
*/
|
|
106
|
+
export declare function writeMigrationState(root: string, state: MigrationState): void;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { MARKER } from './paths.js';
|
|
4
|
+
/** Where `pnpm build` writes the manifest, relative to the package root. */
|
|
5
|
+
export const MANIFEST_FILE = 'dist/migrations/manifest.json';
|
|
6
|
+
/** The same file inside an npm tarball, which prefixes every path with `package/`. */
|
|
7
|
+
export const MANIFEST_ENTRY = `package/${MANIFEST_FILE}`;
|
|
8
|
+
const VERSION = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?$/;
|
|
9
|
+
function parseVersion(value) {
|
|
10
|
+
const match = VERSION.exec(value.trim());
|
|
11
|
+
if (match === null)
|
|
12
|
+
return null;
|
|
13
|
+
return {
|
|
14
|
+
core: [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)],
|
|
15
|
+
pre: match[4] ?? '',
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** True when the text is a version a migration can be ordered by (`x.y.z`, an optional leading `v`, a prerelease). */
|
|
19
|
+
export function isVersion(value) {
|
|
20
|
+
return parseVersion(value) !== null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Orders two versions: negative when `left` is older, positive when it is newer, 0 when they are equal. A prerelease
|
|
24
|
+
* sorts before its release (`1.0.0-beta` < `1.0.0`). Throws on text that is not a version: a migration with a bad
|
|
25
|
+
* `version` is a mistake, not something to guess about.
|
|
26
|
+
*/
|
|
27
|
+
export function compareVersions(left, right) {
|
|
28
|
+
const a = parseVersion(left);
|
|
29
|
+
const b = parseVersion(right);
|
|
30
|
+
if (a === null)
|
|
31
|
+
throw new Error(`"${left}" is not a version`);
|
|
32
|
+
if (b === null)
|
|
33
|
+
throw new Error(`"${right}" is not a version`);
|
|
34
|
+
for (let index = 0; index < a.core.length; index++) {
|
|
35
|
+
if (a.core[index] !== b.core[index])
|
|
36
|
+
return a.core[index] - b.core[index];
|
|
37
|
+
}
|
|
38
|
+
if (a.pre === b.pre)
|
|
39
|
+
return 0;
|
|
40
|
+
if (a.pre === '')
|
|
41
|
+
return 1;
|
|
42
|
+
if (b.pre === '')
|
|
43
|
+
return -1;
|
|
44
|
+
return a.pre < b.pre ? -1 : 1;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The entries that apply to a workspace moved from `since` to `to`: strictly newer than `since`, at or before `to`, in
|
|
48
|
+
* version order (id order for two entries of the same version). Both bounds are inclusive/exclusive on purpose:
|
|
49
|
+
* `since` is the release the workspace already matches, `to` is the release being installed.
|
|
50
|
+
*/
|
|
51
|
+
export function pendingMigrations(entries, since, to) {
|
|
52
|
+
return entries
|
|
53
|
+
.filter((entry) => compareVersions(entry.version, since) > 0 && compareVersions(entry.version, to) <= 0)
|
|
54
|
+
.sort((a, b) => compareVersions(a.version, b.version) || a.id.localeCompare(b.id));
|
|
55
|
+
}
|
|
56
|
+
/** The manifest of a registry: the same entries, without the code that cannot travel in JSON. */
|
|
57
|
+
export function toManifest(entries) {
|
|
58
|
+
return entries.map(({ id, version, summary, breaking, appliesTo, details, manual, auto }) => ({
|
|
59
|
+
id,
|
|
60
|
+
version,
|
|
61
|
+
summary,
|
|
62
|
+
...(breaking !== undefined && { breaking }),
|
|
63
|
+
...(appliesTo !== undefined && { appliesTo }),
|
|
64
|
+
...(details !== undefined && { details }),
|
|
65
|
+
...(manual !== undefined && { manual }),
|
|
66
|
+
...(auto !== undefined && { auto }),
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
/** The marker of a workspace, or null when the file is missing or not a JSON object. */
|
|
70
|
+
export function readMarker(root) {
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(root, MARKER), 'utf8'));
|
|
73
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The migration state of a workspace. A marker written before migrations existed (or by a workspace that was never
|
|
81
|
+
* initialized) has no `migrations` block: its recorded `version` is then the baseline, and a workspace with neither
|
|
82
|
+
* starts at the installed version, so no historical entry runs on it.
|
|
83
|
+
*/
|
|
84
|
+
export function migrationState(marker, installedVersion) {
|
|
85
|
+
const recorded = marker?.migrations;
|
|
86
|
+
const since = typeof recorded?.since === 'string' && isVersion(recorded.since)
|
|
87
|
+
? recorded.since
|
|
88
|
+
: typeof marker?.version === 'string' && isVersion(marker.version)
|
|
89
|
+
? marker.version
|
|
90
|
+
: installedVersion;
|
|
91
|
+
const manual = Array.isArray(recorded?.manual)
|
|
92
|
+
? recorded.manual.filter((id) => typeof id === 'string')
|
|
93
|
+
: [];
|
|
94
|
+
return { since, manual: [...new Set(manual)] };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Writes the migration state back into the marker and keeps every other field (init writes the rest). The workspace
|
|
98
|
+
* must already have a marker: `migrate` says so instead of creating one, because a workspace that was never
|
|
99
|
+
* initialized has no release to migrate from.
|
|
100
|
+
*/
|
|
101
|
+
export function writeMigrationState(root, state) {
|
|
102
|
+
const file = path.join(root, MARKER);
|
|
103
|
+
const marker = readMarker(root) ?? {};
|
|
104
|
+
marker.migrations = { since: state.since, manual: [...new Set(state.manual)] };
|
|
105
|
+
fs.writeFileSync(file, JSON.stringify(marker, null, 2) + '\n');
|
|
106
|
+
}
|
package/dist/lib/paths.d.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
export declare const FRAMEWORK_ROOT: string;
|
|
2
|
+
/** The manifest `init` writes: framework version, migration baseline and the generated file list. */
|
|
3
|
+
export declare const MARKER = ".asrun.json";
|
|
4
|
+
/** The published framework package: the dependency a workspace installs and the name the registry is asked for. */
|
|
5
|
+
export declare const PACKAGE = "@asrunjs/asrun";
|
|
6
|
+
/**
|
|
7
|
+
* True in the framework's own repository, where the framework is a git checkout rather than an installed package. Such a
|
|
8
|
+
* checkout has no registry install to upgrade: `upgrade` refuses to run there and the release check suggests git.
|
|
9
|
+
*/
|
|
10
|
+
export declare function isRepoCheckout(): boolean;
|
|
2
11
|
/**
|
|
3
12
|
* The workspace root: the project that uses the framework. Everything the framework reads or writes for a project
|
|
4
13
|
* (`profiles.yaml`, `cases/`, `tests/`, `.auth/`, `reports/`, `.tmp/`) is relative to it.
|
package/dist/lib/paths.js
CHANGED
|
@@ -38,8 +38,19 @@ function loadEnvFile(file) {
|
|
|
38
38
|
loadEnvFile(path.join(process.cwd(), '.env'));
|
|
39
39
|
if (process.env.ASRUN_ROOT === undefined)
|
|
40
40
|
loadEnvFile(path.join(FRAMEWORK_ROOT, '.env'));
|
|
41
|
+
/** The manifest `init` writes: framework version, migration baseline and the generated file list. */
|
|
42
|
+
export const MARKER = '.asrun.json';
|
|
43
|
+
/** The published framework package: the dependency a workspace installs and the name the registry is asked for. */
|
|
44
|
+
export const PACKAGE = '@asrunjs/asrun';
|
|
45
|
+
/**
|
|
46
|
+
* True in the framework's own repository, where the framework is a git checkout rather than an installed package. Such a
|
|
47
|
+
* checkout has no registry install to upgrade: `upgrade` refuses to run there and the release check suggests git.
|
|
48
|
+
*/
|
|
49
|
+
export function isRepoCheckout() {
|
|
50
|
+
return !FRAMEWORK_ROOT.split(path.sep).includes('node_modules') && fs.existsSync(path.join(FRAMEWORK_ROOT, 'examples', 'demo'));
|
|
51
|
+
}
|
|
41
52
|
/** Files that mark the root of a workspace: the project that owns `profiles.yaml`, `cases/` and `tests/`. */
|
|
42
|
-
const WORKSPACE_MARKERS = [
|
|
53
|
+
const WORKSPACE_MARKERS = [MARKER, 'profiles.yaml'];
|
|
43
54
|
function findWorkspace(from) {
|
|
44
55
|
let dir = path.resolve(from);
|
|
45
56
|
for (;;) {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type MigrationManifestEntry } from './migrations.js';
|
|
2
|
+
/**
|
|
3
|
+
* How `pnpm asrun upgrade` learns what a release contains before it installs it: the registry gives the version and the
|
|
4
|
+
* tarball of a dist-tag, and the tarball carries the migration manifest (`dist/migrations/manifest.json`). Nothing here
|
|
5
|
+
* writes: it only reads the registry and the archive it serves.
|
|
6
|
+
*/
|
|
7
|
+
export interface ReleaseInfo {
|
|
8
|
+
/** the concrete version the spec resolves to */
|
|
9
|
+
version: string;
|
|
10
|
+
/** where the registry serves that release */
|
|
11
|
+
tarball: string;
|
|
12
|
+
}
|
|
13
|
+
/** A read of the registry that failed, with a message for the person; never thrown, so the caller can offer the manual path. */
|
|
14
|
+
export type ReleaseLookup = {
|
|
15
|
+
info: ReleaseInfo;
|
|
16
|
+
} | {
|
|
17
|
+
error: string;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Resolves `@<scope>/<name>@<spec>` (a version, a dist-tag such as `latest`) to its version and tarball.
|
|
21
|
+
* `pnpm view` is used on purpose: it reads the same registry and `.npmrc` the install will use.
|
|
22
|
+
* `timeoutMs` stops a slow or unreachable registry from holding a caller up; `upgrade` leaves it out, a release check
|
|
23
|
+
* always passes one.
|
|
24
|
+
*/
|
|
25
|
+
export declare function releaseInfo(pkg: string, spec: string, timeoutMs?: number): ReleaseLookup;
|
|
26
|
+
/**
|
|
27
|
+
* The migration manifest of a release, or null when it ships none (a release older than the manifest itself, which
|
|
28
|
+
* has nothing to report). Throws only when the download or the archive is broken, so the caller can say that the
|
|
29
|
+
* pre-install check could not be done instead of pretending the release has no migrations.
|
|
30
|
+
*/
|
|
31
|
+
export declare function releaseManifest(tarball: string, timeoutMs?: number): Promise<MigrationManifestEntry[] | null>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { spawnTarget } from './command.js';
|
|
3
|
+
import { MANIFEST_ENTRY } from './migrations.js';
|
|
4
|
+
import { readFileFromTarball } from './tar.js';
|
|
5
|
+
/**
|
|
6
|
+
* Resolves `@<scope>/<name>@<spec>` (a version, a dist-tag such as `latest`) to its version and tarball.
|
|
7
|
+
* `pnpm view` is used on purpose: it reads the same registry and `.npmrc` the install will use.
|
|
8
|
+
* `timeoutMs` stops a slow or unreachable registry from holding a caller up; `upgrade` leaves it out, a release check
|
|
9
|
+
* always passes one.
|
|
10
|
+
*/
|
|
11
|
+
export function releaseInfo(pkg, spec, timeoutMs) {
|
|
12
|
+
const target = spawnTarget('pnpm', ['view', `${pkg}@${spec}`, 'version', 'dist.tarball', '--json']);
|
|
13
|
+
if (target === null)
|
|
14
|
+
return { error: 'pnpm was not found on PATH' };
|
|
15
|
+
const result = spawnSync(target.file, target.args, {
|
|
16
|
+
encoding: 'utf8',
|
|
17
|
+
...(timeoutMs !== undefined && { timeout: timeoutMs }),
|
|
18
|
+
...(target.throughShell && { windowsVerbatimArguments: true }),
|
|
19
|
+
});
|
|
20
|
+
const error = result.error;
|
|
21
|
+
if (error !== undefined) {
|
|
22
|
+
return { error: error.code === 'ENOENT' ? 'pnpm was not found on PATH' : error.message };
|
|
23
|
+
}
|
|
24
|
+
if (result.status !== 0) {
|
|
25
|
+
const detail = `${result.stderr ?? ''}${result.stdout ?? ''}`.trim().split(/\r?\n/).filter(Boolean).pop();
|
|
26
|
+
return {
|
|
27
|
+
error: detail ?? `pnpm view exited with code ${result.status}`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(result.stdout);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
const detail = (result.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).slice(-1)[0];
|
|
36
|
+
return { error: `pnpm view did not answer with JSON${detail === undefined ? '' : `: ${detail}`}` };
|
|
37
|
+
}
|
|
38
|
+
const version = typeof parsed.version === 'string' ? parsed.version : undefined;
|
|
39
|
+
const tarball = typeof parsed['dist.tarball'] === 'string'
|
|
40
|
+
? parsed['dist.tarball']
|
|
41
|
+
: typeof parsed.dist?.tarball === 'string'
|
|
42
|
+
? parsed.dist.tarball
|
|
43
|
+
: undefined;
|
|
44
|
+
if (version === undefined || tarball === undefined) {
|
|
45
|
+
return { error: `the registry did not report a version and tarball for ${pkg}@${spec}` };
|
|
46
|
+
}
|
|
47
|
+
return { info: { version, tarball } };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The migration manifest of a release, or null when it ships none (a release older than the manifest itself, which
|
|
51
|
+
* has nothing to report). Throws only when the download or the archive is broken, so the caller can say that the
|
|
52
|
+
* pre-install check could not be done instead of pretending the release has no migrations.
|
|
53
|
+
*/
|
|
54
|
+
export async function releaseManifest(tarball, timeoutMs = 30_000) {
|
|
55
|
+
const response = await fetch(tarball, { signal: AbortSignal.timeout(timeoutMs) });
|
|
56
|
+
if (!response.ok)
|
|
57
|
+
throw new Error(`${response.status} ${response.statusText} for ${tarball}`);
|
|
58
|
+
const archive = Buffer.from(await response.arrayBuffer());
|
|
59
|
+
const entry = readFileFromTarball(archive, MANIFEST_ENTRY);
|
|
60
|
+
if (entry === null)
|
|
61
|
+
return null;
|
|
62
|
+
const parsed = JSON.parse(entry.toString('utf8'));
|
|
63
|
+
if (!Array.isArray(parsed))
|
|
64
|
+
throw new Error(`${MANIFEST_ENTRY} is not a JSON array`);
|
|
65
|
+
return parsed;
|
|
66
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Packages the framework installs on demand, instead of depending on them.
|
|
3
|
+
*
|
|
4
|
+
* A runtime dependency that runs an install script makes `pnpm dlx @asrunjs/asrun` stop at pnpm's build-approval
|
|
5
|
+
* picker: the dlx sandbox does not read the workspace's `allowBuilds` (that file belongs to the project, not to the
|
|
6
|
+
* sandbox), and in a pipeline nobody can answer the picker. So an optional package stays out of `dependencies` and
|
|
7
|
+
* `optionalDependencies`, and `init` (also through `go`) installs it into the workspace as a devDependency when the
|
|
8
|
+
* machine does not already provide what it gives.
|
|
9
|
+
*
|
|
10
|
+
* To add one, append it to `RUNTIME_EXTRAS`: its `allowBuilds` entry is then written by `init`/`go` before the
|
|
11
|
+
* install, `doctor` warns when it is missing, and `verify:pack` checks that it never becomes a runtime dependency.
|
|
12
|
+
*/
|
|
13
|
+
export interface RuntimeExtra {
|
|
14
|
+
/** the npm package, installed into the workspace on demand */
|
|
15
|
+
pkg: string;
|
|
16
|
+
/** one line for the person: what does not work without it */
|
|
17
|
+
provides: string;
|
|
18
|
+
/** a command that proves the machine already has the capability (exit code 0); empty means "never present" */
|
|
19
|
+
probe: string[];
|
|
20
|
+
/** the package runs an install script, so `pnpm-workspace.yaml` needs an `allowBuilds` entry for it */
|
|
21
|
+
builds: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare const RUNTIME_EXTRAS: RuntimeExtra[];
|
|
24
|
+
/** Every package whose install scripts pnpm may run: the `allowBuilds` block of `pnpm-workspace.yaml`. */
|
|
25
|
+
export declare const BUILD_APPROVALS: string[];
|
|
26
|
+
/** True when the machine already provides what that package would (its probe exits 0). */
|
|
27
|
+
export declare function hasCapability(extra: RuntimeExtra): boolean;
|
|
28
|
+
/** True when the workspace itself has the package (installed on demand, so a devDependency). */
|
|
29
|
+
export declare function installedInWorkspace(root: string, pkg: string): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Adds `<pkg>: true` to the `allowBuilds` block of the workspace's `pnpm-workspace.yaml`, before the package is
|
|
32
|
+
* installed. Text-level on purpose: the file belongs to the project, comments and order included.
|
|
33
|
+
*
|
|
34
|
+
* Returns how it ended up: `present` (already approved), `added`, `created` (the file had no block), `inline` (the
|
|
35
|
+
* block is a one-line map, which only a person can extend) or `no-file` (the workspace has no `pnpm-workspace.yaml`).
|
|
36
|
+
*/
|
|
37
|
+
export declare function approveBuild(root: string, pkg: string): 'present' | 'added' | 'created' | 'inline' | 'no-file';
|
|
38
|
+
/**
|
|
39
|
+
* Adds the package to `minimumReleaseAgeExclude` in the workspace's `pnpm-workspace.yaml`, so pnpm's release age does
|
|
40
|
+
* not hold a fresh release back while `pnpm asrun upgrade` installs it. Text-level, like `approveBuild`, because the
|
|
41
|
+
* file belongs to the project.
|
|
42
|
+
*
|
|
43
|
+
* Returns how it ended up: `present` (already listed), `added`, `created` (the file had no block), `inline` (the list
|
|
44
|
+
* is written inline, which only a person can extend) or `no-file` (the workspace has no `pnpm-workspace.yaml`).
|
|
45
|
+
*/
|
|
46
|
+
export declare function excludeReleaseAge(root: string, pkg: string): 'present' | 'added' | 'created' | 'inline' | 'no-file';
|
|
47
|
+
export interface ExtraOutcome {
|
|
48
|
+
extra: RuntimeExtra;
|
|
49
|
+
status: 'present' | 'installed' | 'failed';
|
|
50
|
+
/** why the install failed, for the message `init` prints */
|
|
51
|
+
detail?: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Installs every runtime extra the workspace still lacks, after approving its install script. The package becomes a
|
|
55
|
+
* devDependency of the workspace (`pnpm add -D`), which is what "on demand" means: a project that never needs the
|
|
56
|
+
* capability never downloads it. A failure is never fatal: the feature that needs the package degrades, and `init`
|
|
57
|
+
* tells the person.
|
|
58
|
+
*/
|
|
59
|
+
export declare function ensureRuntimeExtras(root: string, onInstall?: (extra: RuntimeExtra) => void): ExtraOutcome[];
|