@asrunjs/asrun 0.7.0 → 0.9.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.
Files changed (73) hide show
  1. package/.claude/commands/explore-case.md +6 -2
  2. package/.claude/commands/gen-spec.md +4 -1
  3. package/.claude/commands/heal-spec.md +18 -7
  4. package/.claude/skills/exploring-cases/SKILL.md +19 -8
  5. package/.claude/skills/exploring-cases/references/open-questions.md +2 -2
  6. package/.claude/skills/generating-specs/SKILL.md +85 -30
  7. package/AGENTS.md +23 -12
  8. package/README.md +25 -14
  9. package/bin/asrun.mjs +18 -2
  10. package/dist/lib/ffmpeg.js +21 -10
  11. package/dist/lib/migrations.d.ts +106 -0
  12. package/dist/lib/migrations.js +106 -0
  13. package/dist/lib/paths.d.ts +9 -0
  14. package/dist/lib/paths.js +12 -1
  15. package/dist/lib/registry.d.ts +31 -0
  16. package/dist/lib/registry.js +66 -0
  17. package/dist/lib/runtime-deps.d.ts +59 -0
  18. package/dist/lib/runtime-deps.js +161 -0
  19. package/dist/lib/tar.d.ts +5 -0
  20. package/dist/lib/tar.js +79 -0
  21. package/dist/lib/update-check.d.ts +52 -0
  22. package/dist/lib/update-check.js +223 -0
  23. package/dist/migrations/index.d.ts +23 -0
  24. package/dist/migrations/index.js +22 -0
  25. package/dist/migrations/manifest.json +1 -0
  26. package/dist/scripts/build.js +7 -0
  27. package/dist/scripts/cli.js +22 -4
  28. package/dist/scripts/doctor.js +51 -9
  29. package/dist/scripts/go.mjs +7 -5
  30. package/dist/scripts/init.d.ts +3 -2
  31. package/dist/scripts/init.js +73 -11
  32. package/dist/scripts/migrate.d.ts +1 -0
  33. package/dist/scripts/migrate.js +259 -0
  34. package/dist/scripts/run.js +2 -1
  35. package/dist/scripts/upgrade.d.ts +1 -0
  36. package/dist/scripts/upgrade.js +256 -0
  37. package/dist/ui/server/actions.js +9 -4
  38. package/dist/ui/server/app.js +18 -0
  39. package/dist/ui/server/index.js +3 -0
  40. package/dist/ui/server/update.d.ts +18 -0
  41. package/dist/ui/server/update.js +87 -0
  42. package/dist/ui/shared/ai-commands.d.ts +6 -3
  43. package/dist/ui/shared/ai-commands.js +11 -7
  44. package/dist/ui/shared/types.d.ts +25 -1
  45. package/docs/agents/framework-dev.md +74 -4
  46. package/docs/ai-workflow.md +38 -11
  47. package/docs/commands.md +74 -4
  48. package/docs/install.md +52 -16
  49. package/docs/theme-guide.html +93 -5
  50. package/docs/troubleshooting.md +22 -10
  51. package/docs/web-ui.md +28 -6
  52. package/package.json +2 -4
  53. package/templates/.claude/commands/explore-case.md +7 -2
  54. package/templates/.claude/commands/gen-spec.md +4 -1
  55. package/templates/.claude/commands/heal-spec.md +22 -12
  56. package/templates/AGENTS.md +1 -0
  57. package/templates/pnpm-workspace.yaml +3 -3
  58. package/ui/dist/assets/{dist-DZ7RiqmA.js → dist-B7HeglDd.js} +1 -1
  59. package/ui/dist/assets/{dist-DEPJZ3Me.js → dist-B9_GXR2G.js} +1 -1
  60. package/ui/dist/assets/{dist-CO4heSzh.js → dist-Bb36rNUw.js} +1 -1
  61. package/ui/dist/assets/{dist-jPS6HCJ5.js → dist-DevASegc.js} +1 -1
  62. package/ui/dist/assets/{dist-C-eCSFja.js → dist-DfBpjgZT.js} +1 -1
  63. package/ui/dist/assets/{dist-O_RVi4tP.js → dist-Dq4Vnw5N.js} +1 -1
  64. package/ui/dist/assets/{dist-CPLhRzom.js → dist-Dqc_tfzC.js} +1 -1
  65. package/ui/dist/assets/{dist-LixzGniF.js → dist-G9KNeh6u.js} +1 -1
  66. package/ui/dist/assets/{dist-BudNAjOv.js → dist-GvRUDL-2.js} +1 -1
  67. package/ui/dist/assets/{dist-vmf3fnkM.js → dist-WvvCZeeu.js} +1 -1
  68. package/ui/dist/assets/index-BJ5D2mFK.js +346 -0
  69. package/ui/dist/assets/index-BXY-eKBn.css +2 -0
  70. package/ui/dist/help/case-explore.png +0 -0
  71. package/ui/dist/index.html +2 -2
  72. package/ui/dist/assets/index-BIGt8Hfz.js +0 -338
  73. package/ui/dist/assets/index-CTmnF0Wp.css +0 -2
@@ -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
- try {
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 optional ffmpeg-static package)';
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
+ }
@@ -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 = ['.asrun.json', 'profiles.yaml'];
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[];
@@ -0,0 +1,161 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawnTarget } from './command.js';
5
+ export const RUNTIME_EXTRAS = [
6
+ {
7
+ pkg: 'ffmpeg-static',
8
+ provides: 'joining the recordings of a test that opened more than one browser tab into one video',
9
+ probe: ['ffmpeg', '-version'],
10
+ builds: true,
11
+ },
12
+ ];
13
+ /** Every package whose install scripts pnpm may run: the `allowBuilds` block of `pnpm-workspace.yaml`. */
14
+ export const BUILD_APPROVALS = [
15
+ // esbuild comes in through tsx, which a Git dependency builds with (`prepare`).
16
+ ...new Set(['esbuild', ...RUNTIME_EXTRAS.filter((extra) => extra.builds).map((extra) => extra.pkg)]),
17
+ ];
18
+ const escapePattern = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
19
+ /** True when the machine already provides what that package would (its probe exits 0). */
20
+ export function hasCapability(extra) {
21
+ if (extra.probe.length === 0)
22
+ return false;
23
+ // `spawnTarget` resolves a Windows `.cmd` shim (ffmpeg installed through winget or a wrapper script) without a shell.
24
+ const target = spawnTarget(extra.probe[0], extra.probe.slice(1));
25
+ if (target === null)
26
+ return false;
27
+ const result = spawnSync(target.file, target.args, {
28
+ stdio: 'ignore',
29
+ ...(target.throughShell && { windowsVerbatimArguments: true }),
30
+ });
31
+ return result.status === 0;
32
+ }
33
+ /** True when the workspace itself has the package (installed on demand, so a devDependency). */
34
+ export function installedInWorkspace(root, pkg) {
35
+ return fs.existsSync(path.join(root, 'node_modules', pkg, 'package.json'));
36
+ }
37
+ /** A package name as a YAML mapping key: quoted when it starts with `@`, which YAML reads as a directive. */
38
+ const yamlKey = (pkg) => (pkg.startsWith('@') ? `'${pkg}'` : pkg);
39
+ /** The `allowBuilds` entry pattern of a package, for both quoted and plain keys. */
40
+ const approvalPattern = (pkg) => new RegExp(`^\\s+['"]?${escapePattern(pkg)}['"]?\\s*:`);
41
+ /**
42
+ * Adds `<pkg>: true` to the `allowBuilds` block of the workspace's `pnpm-workspace.yaml`, before the package is
43
+ * installed. Text-level on purpose: the file belongs to the project, comments and order included.
44
+ *
45
+ * Returns how it ended up: `present` (already approved), `added`, `created` (the file had no block), `inline` (the
46
+ * block is a one-line map, which only a person can extend) or `no-file` (the workspace has no `pnpm-workspace.yaml`).
47
+ */
48
+ export function approveBuild(root, pkg) {
49
+ const file = path.join(root, 'pnpm-workspace.yaml');
50
+ let text;
51
+ try {
52
+ text = fs.readFileSync(file, 'utf8');
53
+ }
54
+ catch {
55
+ return 'no-file';
56
+ }
57
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
58
+ const lines = text.split(/\r?\n/);
59
+ const start = lines.findIndex((line) => /^allowBuilds\s*:/.test(line));
60
+ if (start === -1) {
61
+ const body = text.replace(/\s*$/, '');
62
+ fs.writeFileSync(file, `${body === '' ? '' : `${body}${eol}${eol}`}allowBuilds:${eol} ${yamlKey(pkg)}: true${eol}`);
63
+ return 'created';
64
+ }
65
+ if (/^allowBuilds\s*:\s*\S/.test(lines[start]))
66
+ return 'inline';
67
+ let end = start + 1;
68
+ for (let i = start + 1; i < lines.length; i++) {
69
+ // The block stops at the first line that is not indented (a blank line included).
70
+ if (lines[i].trim() === '' || !/^\s/.test(lines[i]))
71
+ break;
72
+ if (approvalPattern(pkg).test(lines[i]))
73
+ return 'present';
74
+ end = i + 1;
75
+ }
76
+ lines.splice(end, 0, ` ${yamlKey(pkg)}: true`);
77
+ fs.writeFileSync(file, lines.join(eol));
78
+ return 'added';
79
+ }
80
+ /**
81
+ * Adds the package to `minimumReleaseAgeExclude` in the workspace's `pnpm-workspace.yaml`, so pnpm's release age does
82
+ * not hold a fresh release back while `pnpm asrun upgrade` installs it. Text-level, like `approveBuild`, because the
83
+ * file belongs to the project.
84
+ *
85
+ * Returns how it ended up: `present` (already listed), `added`, `created` (the file had no block), `inline` (the list
86
+ * is written inline, which only a person can extend) or `no-file` (the workspace has no `pnpm-workspace.yaml`).
87
+ */
88
+ export function excludeReleaseAge(root, pkg) {
89
+ const file = path.join(root, 'pnpm-workspace.yaml');
90
+ let text;
91
+ try {
92
+ text = fs.readFileSync(file, 'utf8');
93
+ }
94
+ catch {
95
+ return 'no-file';
96
+ }
97
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
98
+ const lines = text.split(/\r?\n/);
99
+ const start = lines.findIndex((line) => /^minimumReleaseAgeExclude\s*:/.test(line));
100
+ const entry = `'${pkg}'`;
101
+ if (start === -1) {
102
+ const body = text.replace(/\s*$/, '');
103
+ fs.writeFileSync(file, `${body === '' ? '' : `${body}${eol}${eol}`}minimumReleaseAgeExclude:${eol} - ${entry}${eol}`);
104
+ return 'created';
105
+ }
106
+ if (/^minimumReleaseAgeExclude\s*:\s*\S/.test(lines[start])) {
107
+ return lines[start].includes(entry) ? 'present' : 'inline';
108
+ }
109
+ let end = start + 1;
110
+ for (let i = start + 1; i < lines.length; i++) {
111
+ if (lines[i].trim() === '' || !/^\s/.test(lines[i]))
112
+ break;
113
+ if (lines[i].includes(entry))
114
+ return 'present';
115
+ end = i + 1;
116
+ }
117
+ lines.splice(end, 0, ` - ${entry}`);
118
+ fs.writeFileSync(file, lines.join(eol));
119
+ return 'added';
120
+ }
121
+ /**
122
+ * Installs every runtime extra the workspace still lacks, after approving its install script. The package becomes a
123
+ * devDependency of the workspace (`pnpm add -D`), which is what "on demand" means: a project that never needs the
124
+ * capability never downloads it. A failure is never fatal: the feature that needs the package degrades, and `init`
125
+ * tells the person.
126
+ */
127
+ export function ensureRuntimeExtras(root, onInstall = () => { }) {
128
+ const outcomes = [];
129
+ for (const extra of RUNTIME_EXTRAS) {
130
+ if (hasCapability(extra) || installedInWorkspace(root, extra.pkg)) {
131
+ outcomes.push({ extra, status: 'present' });
132
+ continue;
133
+ }
134
+ onInstall(extra);
135
+ // The approval has to be there first: pnpm 12 refuses an install script it has not approved.
136
+ approveBuild(root, extra.pkg);
137
+ const target = spawnTarget('pnpm', ['add', '-D', extra.pkg]);
138
+ const result = target === null
139
+ ? null
140
+ : spawnSync(target.file, target.args, {
141
+ cwd: root,
142
+ stdio: 'inherit',
143
+ ...(target.throughShell && { windowsVerbatimArguments: true }),
144
+ });
145
+ const error = result?.error ?? undefined;
146
+ outcomes.push(result === null
147
+ ? { extra, status: 'failed', detail: 'pnpm was not found on PATH' }
148
+ : result.status === 0
149
+ ? { extra, status: 'installed' }
150
+ : {
151
+ extra,
152
+ status: 'failed',
153
+ detail: error !== undefined
154
+ ? error.code === 'ENOENT'
155
+ ? 'pnpm was not found on PATH'
156
+ : error.message
157
+ : `pnpm add exited with code ${result.status}`,
158
+ });
159
+ }
160
+ return outcomes;
161
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The content of `wanted` inside a tarball (gzipped or not), or null when the archive has no such file. Only regular
3
+ * files are considered; a directory entry of the same name is skipped.
4
+ */
5
+ export declare function readFileFromTarball(archive: Buffer, wanted: string): Buffer | null;