@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
|
@@ -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;
|
package/dist/lib/tar.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import zlib from 'node:zlib';
|
|
2
|
+
/**
|
|
3
|
+
* Reads one file out of a `.tgz`/`.tar`, which is how `pnpm asrun upgrade` reads the migration manifest of the release
|
|
4
|
+
* it is about to install: the registry serves the package as a tarball, and the manifest has to be looked at before
|
|
5
|
+
* anything is installed. No external `tar` binary and no dependency: npm writes plain (portable) ustar entries.
|
|
6
|
+
*/
|
|
7
|
+
const BLOCK = 512;
|
|
8
|
+
function text(header, offset, length) {
|
|
9
|
+
const end = header.indexOf(0, offset);
|
|
10
|
+
const stop = end === -1 || end > offset + length ? offset + length : end;
|
|
11
|
+
return header.subarray(offset, stop).toString('utf8');
|
|
12
|
+
}
|
|
13
|
+
/** A numeric header field: octal digits, NUL/space padded. */
|
|
14
|
+
function octal(header, offset, length) {
|
|
15
|
+
const raw = text(header, offset, length).trim();
|
|
16
|
+
return raw === '' ? 0 : parseInt(raw, 8);
|
|
17
|
+
}
|
|
18
|
+
function isZeroBlock(block) {
|
|
19
|
+
for (const byte of block)
|
|
20
|
+
if (byte !== 0)
|
|
21
|
+
return false;
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
/** The path of a PAX extended header (`LEN key=value\n` records), or null when it carries none. */
|
|
25
|
+
function paxPath(body) {
|
|
26
|
+
let offset = 0;
|
|
27
|
+
while (offset < body.length) {
|
|
28
|
+
const space = body.indexOf(0x20, offset); // the length field ends at the first space
|
|
29
|
+
if (space === -1)
|
|
30
|
+
break;
|
|
31
|
+
const length = Number(body.subarray(offset, space).toString('utf8'));
|
|
32
|
+
if (!Number.isFinite(length) || length <= 0)
|
|
33
|
+
break;
|
|
34
|
+
const record = body.subarray(space + 1, offset + length - 1).toString('utf8'); // without the trailing newline
|
|
35
|
+
const equals = record.indexOf('=');
|
|
36
|
+
if (equals > 0 && record.slice(0, equals).replace(/^\w+\./, '') === 'path')
|
|
37
|
+
return record.slice(equals + 1);
|
|
38
|
+
offset += length;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The content of `wanted` inside a tarball (gzipped or not), or null when the archive has no such file. Only regular
|
|
44
|
+
* files are considered; a directory entry of the same name is skipped.
|
|
45
|
+
*/
|
|
46
|
+
export function readFileFromTarball(archive, wanted) {
|
|
47
|
+
const data = archive.length > 1 && archive[0] === 0x1f && archive[1] === 0x8b ? zlib.gunzipSync(archive) : archive;
|
|
48
|
+
// A PAX or GNU long-name entry names the file of the *next* header; npm tarballs use one for long paths.
|
|
49
|
+
let pendingName;
|
|
50
|
+
for (let offset = 0; offset + BLOCK <= data.length;) {
|
|
51
|
+
const header = data.subarray(offset, offset + BLOCK);
|
|
52
|
+
if (isZeroBlock(header))
|
|
53
|
+
break;
|
|
54
|
+
const size = octal(header, 124, 12);
|
|
55
|
+
const type = String.fromCharCode(header[156] || 0x30);
|
|
56
|
+
const body = data.subarray(offset + BLOCK, offset + BLOCK + size);
|
|
57
|
+
offset += BLOCK + Math.ceil(size / BLOCK) * BLOCK;
|
|
58
|
+
if (type === 'x') {
|
|
59
|
+
pendingName = paxPath(body) ?? pendingName;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (type === 'L') {
|
|
63
|
+
pendingName = text(body, 0, body.length);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
// A global header (`g`) and the link-name header (`K`) say nothing about the next file's path.
|
|
67
|
+
if (type === 'g' || type === 'K')
|
|
68
|
+
continue;
|
|
69
|
+
const name = text(header, 0, 100);
|
|
70
|
+
const prefix = text(header, 345, 155);
|
|
71
|
+
const fullName = pendingName ?? (prefix === '' ? name : `${prefix}/${name}`);
|
|
72
|
+
pendingName = undefined;
|
|
73
|
+
if (type !== '0' && type !== '\0' && type !== '7')
|
|
74
|
+
continue;
|
|
75
|
+
if (fullName === wanted)
|
|
76
|
+
return body;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export declare const CACHE_FILE: string;
|
|
2
|
+
/** Development only: a hand-written answer, so the notice can be seen without publishing a release. */
|
|
3
|
+
export declare const MOCK_FILE: string;
|
|
4
|
+
/** `installed` is a package from the registry; `checkout` is the framework's own git repository. */
|
|
5
|
+
export type InstallKind = 'installed' | 'checkout';
|
|
6
|
+
/** What the last check found. `latest` stays null when the registry could not be read (`error` says why). */
|
|
7
|
+
export interface UpdateStatus {
|
|
8
|
+
/** The version in the framework's own `package.json`. */
|
|
9
|
+
current: string;
|
|
10
|
+
latest: string | null;
|
|
11
|
+
/** True when `latest` is a version newer than `current`. */
|
|
12
|
+
available: boolean;
|
|
13
|
+
/** When the answer was fetched (ISO); the cached answer keeps the time of the fetch. */
|
|
14
|
+
checkedAt: string;
|
|
15
|
+
kind: InstallKind;
|
|
16
|
+
/** True when ASRUN_NO_UPDATE_CHECK turned the check off; nothing was fetched. */
|
|
17
|
+
disabled: boolean;
|
|
18
|
+
/** True when a development mock supplied the version or the install kind instead of the registry. */
|
|
19
|
+
mocked: boolean;
|
|
20
|
+
error: string | null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The newest release on the registry, compared with what this process runs. `force` asks the registry again even when
|
|
24
|
+
* the cache is fresh (the web UI's daily check does that); every other caller shares the cache.
|
|
25
|
+
*
|
|
26
|
+
* A development mock (ASRUN_UPDATE_MOCK_* or `.tmp/update-mock.json`) answers instead of the registry and is never
|
|
27
|
+
* cached, so removing it brings the real check back at once.
|
|
28
|
+
*/
|
|
29
|
+
export declare function checkForUpdate(options?: {
|
|
30
|
+
force?: boolean;
|
|
31
|
+
}): UpdateStatus;
|
|
32
|
+
/** The command that moves this installation to the newest release. */
|
|
33
|
+
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
|
+
/**
|
|
42
|
+
* The one-line notice, or null when there is nothing to say. `color` adds a badge and highlights the version and the
|
|
43
|
+
* command, for a person looking at a terminal; the API and a piped log keep the plain sentence.
|
|
44
|
+
*/
|
|
45
|
+
export declare function formatNotice(status: UpdateStatus, options: {
|
|
46
|
+
color?: boolean;
|
|
47
|
+
}): string | null;
|
|
48
|
+
/**
|
|
49
|
+
* Prints the notice on stderr, before a command does its own work. stderr keeps stdout clean for pipelines, and any
|
|
50
|
+
* failure here is swallowed: a command's own result never depends on the registry answering.
|
|
51
|
+
*/
|
|
52
|
+
export declare function announceUpdate(): void;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { compareVersions, isVersion } from './migrations.js';
|
|
4
|
+
import { FRAMEWORK_ROOT, PACKAGE, TMP_DIR, isRepoCheckout } from './paths.js';
|
|
5
|
+
import { releaseInfo } from './registry.js';
|
|
6
|
+
/**
|
|
7
|
+
* Is a newer release of the framework on the registry? The command line asks before every command and the web UI asks
|
|
8
|
+
* 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
|
|
9
|
+
* machine. A failed read is silent: a check that cannot run must never get in the way of a real command.
|
|
10
|
+
*/
|
|
11
|
+
/** The dist-tag the check compares against. */
|
|
12
|
+
const DIST_TAG = 'latest';
|
|
13
|
+
/** How long a successful answer is reused. */
|
|
14
|
+
const FRESH_MS = 24 * 60 * 60 * 1000;
|
|
15
|
+
/** A failed read is retried much sooner, so an offline minute does not hide the next real answer for a day. */
|
|
16
|
+
const RETRY_MS = 60 * 60 * 1000;
|
|
17
|
+
/** The registry is a side question: it never holds a command up for longer than this. */
|
|
18
|
+
const TIMEOUT_MS = 5_000;
|
|
19
|
+
export const CACHE_FILE = path.join(TMP_DIR, 'update-check.json');
|
|
20
|
+
/** Development only: a hand-written answer, so the notice can be seen without publishing a release. */
|
|
21
|
+
export const MOCK_FILE = path.join(TMP_DIR, 'update-mock.json');
|
|
22
|
+
/** ASRUN_NO_UPDATE_CHECK=1 (or true/yes) turns the check off, for a pipeline or a machine without a registry. */
|
|
23
|
+
function checksDisabled() {
|
|
24
|
+
const value = (process.env.ASRUN_NO_UPDATE_CHECK ?? '').trim().toLowerCase();
|
|
25
|
+
return value !== '' && value !== '0' && value !== 'false' && value !== 'no';
|
|
26
|
+
}
|
|
27
|
+
function mockKind(value) {
|
|
28
|
+
return value === 'installed' || value === 'checkout' ? value : undefined;
|
|
29
|
+
}
|
|
30
|
+
/** The mock the environment names: ASRUN_UPDATE_MOCK_LATEST / ASRUN_UPDATE_MOCK_KIND, both optional. */
|
|
31
|
+
function mockFromEnv() {
|
|
32
|
+
const latest = (process.env.ASRUN_UPDATE_MOCK_LATEST ?? '').trim();
|
|
33
|
+
const kind = mockKind((process.env.ASRUN_UPDATE_MOCK_KIND ?? '').trim().toLowerCase());
|
|
34
|
+
const validLatest = isVersion(latest) ? latest : undefined;
|
|
35
|
+
if (validLatest === undefined && kind === undefined)
|
|
36
|
+
return null;
|
|
37
|
+
return { ...(validLatest !== undefined && { latest: validLatest }), ...(kind !== undefined && { kind }) };
|
|
38
|
+
}
|
|
39
|
+
/** The mock in `.tmp/update-mock.json`, written for a live demo of the notice; malformed fields are ignored. */
|
|
40
|
+
function mockFromFile() {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(fs.readFileSync(MOCK_FILE, 'utf8'));
|
|
43
|
+
const latest = typeof parsed.latest === 'string' && isVersion(parsed.latest.trim()) ? parsed.latest.trim() : undefined;
|
|
44
|
+
const kind = mockKind(parsed.kind);
|
|
45
|
+
if (latest === undefined && kind === undefined)
|
|
46
|
+
return null;
|
|
47
|
+
return { ...(latest !== undefined && { latest }), ...(kind !== undefined && { kind }) };
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** The development mock, field by field: the environment wins over the file, so a shell variable can pin one field. */
|
|
54
|
+
function mockConfig() {
|
|
55
|
+
const env = mockFromEnv();
|
|
56
|
+
const file = mockFromFile();
|
|
57
|
+
if (env === null && file === null)
|
|
58
|
+
return null;
|
|
59
|
+
const latest = env?.latest ?? file?.latest;
|
|
60
|
+
const kind = env?.kind ?? file?.kind;
|
|
61
|
+
return { ...(latest !== undefined && { latest }), ...(kind !== undefined && { kind }) };
|
|
62
|
+
}
|
|
63
|
+
/** The version of the framework this process runs, or null when its manifest cannot be read. */
|
|
64
|
+
function installedVersion() {
|
|
65
|
+
try {
|
|
66
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(FRAMEWORK_ROOT, 'package.json'), 'utf8'));
|
|
67
|
+
return typeof manifest.version === 'string' ? manifest.version : null;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function readCache() {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
|
|
76
|
+
if (typeof parsed.checkedAt !== 'number' || typeof parsed.current !== 'string')
|
|
77
|
+
return null;
|
|
78
|
+
return {
|
|
79
|
+
checkedAt: parsed.checkedAt,
|
|
80
|
+
current: parsed.current,
|
|
81
|
+
latest: typeof parsed.latest === 'string' ? parsed.latest : null,
|
|
82
|
+
error: typeof parsed.error === 'string' ? parsed.error : null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Written next to the reports and the other local state; a read-only workspace simply keeps no cache. */
|
|
90
|
+
function writeCache(entry) {
|
|
91
|
+
try {
|
|
92
|
+
fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
|
|
93
|
+
fs.writeFileSync(CACHE_FILE, `${JSON.stringify(entry, null, 2)}\n`, 'utf8');
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// No cache is only a slower check next time.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function statusFrom(entry, kind, disabled, mocked = false) {
|
|
100
|
+
const available = entry.latest !== null &&
|
|
101
|
+
isVersion(entry.latest) &&
|
|
102
|
+
isVersion(entry.current) &&
|
|
103
|
+
compareVersions(entry.latest, entry.current) > 0;
|
|
104
|
+
return {
|
|
105
|
+
current: entry.current,
|
|
106
|
+
latest: entry.latest,
|
|
107
|
+
available,
|
|
108
|
+
checkedAt: new Date(entry.checkedAt).toISOString(),
|
|
109
|
+
kind,
|
|
110
|
+
disabled,
|
|
111
|
+
mocked,
|
|
112
|
+
error: entry.error,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The newest release on the registry, compared with what this process runs. `force` asks the registry again even when
|
|
117
|
+
* the cache is fresh (the web UI's daily check does that); every other caller shares the cache.
|
|
118
|
+
*
|
|
119
|
+
* A development mock (ASRUN_UPDATE_MOCK_* or `.tmp/update-mock.json`) answers instead of the registry and is never
|
|
120
|
+
* cached, so removing it brings the real check back at once.
|
|
121
|
+
*/
|
|
122
|
+
export function checkForUpdate(options = {}) {
|
|
123
|
+
const mock = mockConfig();
|
|
124
|
+
const kind = mock?.kind ?? (isRepoCheckout() ? 'checkout' : 'installed');
|
|
125
|
+
const current = installedVersion();
|
|
126
|
+
if (current === null) {
|
|
127
|
+
return {
|
|
128
|
+
current: 'unknown',
|
|
129
|
+
latest: null,
|
|
130
|
+
available: false,
|
|
131
|
+
checkedAt: new Date().toISOString(),
|
|
132
|
+
kind,
|
|
133
|
+
disabled: checksDisabled(),
|
|
134
|
+
mocked: mock !== null,
|
|
135
|
+
error: `could not read ${path.join(FRAMEWORK_ROOT, 'package.json')}`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (checksDisabled()) {
|
|
139
|
+
return {
|
|
140
|
+
current,
|
|
141
|
+
latest: null,
|
|
142
|
+
available: false,
|
|
143
|
+
checkedAt: new Date().toISOString(),
|
|
144
|
+
kind,
|
|
145
|
+
disabled: true,
|
|
146
|
+
mocked: mock !== null,
|
|
147
|
+
error: null,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (mock?.latest !== undefined) {
|
|
151
|
+
return statusFrom({ checkedAt: Date.now(), current, latest: mock.latest, error: null }, kind, false, true);
|
|
152
|
+
}
|
|
153
|
+
const cached = readCache();
|
|
154
|
+
if (options.force !== true && cached !== null && cached.current === current) {
|
|
155
|
+
const age = Date.now() - cached.checkedAt;
|
|
156
|
+
const ttl = cached.latest === null ? RETRY_MS : FRESH_MS;
|
|
157
|
+
if (age >= 0 && age < ttl)
|
|
158
|
+
return statusFrom(cached, kind, false, mock !== null);
|
|
159
|
+
}
|
|
160
|
+
const lookup = releaseInfo(PACKAGE, DIST_TAG, TIMEOUT_MS);
|
|
161
|
+
const entry = 'info' in lookup && isVersion(lookup.info.version)
|
|
162
|
+
? { checkedAt: Date.now(), current, latest: lookup.info.version, error: null }
|
|
163
|
+
: {
|
|
164
|
+
checkedAt: Date.now(),
|
|
165
|
+
current,
|
|
166
|
+
latest: null,
|
|
167
|
+
error: 'error' in lookup ? lookup.error : `the registry answered "${lookup.info.version}", which is not a version`,
|
|
168
|
+
};
|
|
169
|
+
writeCache(entry);
|
|
170
|
+
return statusFrom(entry, kind, false, mock !== null);
|
|
171
|
+
}
|
|
172
|
+
/** The command that moves this installation to the newest release. */
|
|
173
|
+
export function upgradeCommand(kind) {
|
|
174
|
+
return kind === 'checkout' ? 'git pull' : 'pnpm asrun upgrade';
|
|
175
|
+
}
|
|
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
|
+
/**
|
|
192
|
+
* The one-line notice, or null when there is nothing to say. `color` adds a badge and highlights the version and the
|
|
193
|
+
* command, for a person looking at a terminal; the API and a piped log keep the plain sentence.
|
|
194
|
+
*/
|
|
195
|
+
export function formatNotice(status, options) {
|
|
196
|
+
if (!status.available || status.latest === null)
|
|
197
|
+
return null;
|
|
198
|
+
const color = options.color === true;
|
|
199
|
+
// The plain line reads as a sentence; the badge only exists to catch the eye on a terminal.
|
|
200
|
+
const badge = color ? `${paint(' UPDATE ', '43;30;1', true)} ` : '';
|
|
201
|
+
const release = paint(`${PACKAGE} ${status.latest}`, '1;33', color);
|
|
202
|
+
const command = paint(upgradeCommand(status.kind), '1;36', color);
|
|
203
|
+
const tail = status.kind === 'checkout'
|
|
204
|
+
? `is available (this checkout is ${status.current}). Update the framework checkout: ${command}`
|
|
205
|
+
: `is available (installed ${status.current}). Upgrade: ${command}`;
|
|
206
|
+
return `${badge}${release} ${tail}`;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Prints the notice on stderr, before a command does its own work. stderr keeps stdout clean for pipelines, and any
|
|
210
|
+
* failure here is swallowed: a command's own result never depends on the registry answering.
|
|
211
|
+
*/
|
|
212
|
+
export function announceUpdate() {
|
|
213
|
+
if (process.argv.includes('--help'))
|
|
214
|
+
return;
|
|
215
|
+
try {
|
|
216
|
+
const notice = formatNotice(checkForUpdate(), { color: useColor(process.stderr) });
|
|
217
|
+
if (notice !== null)
|
|
218
|
+
console.error(notice);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// A check is a courtesy; a broken one is not a command failure.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Migration } from '../lib/migrations.js';
|
|
2
|
+
/**
|
|
3
|
+
* The workspace migrations of this framework, in the order they were introduced.
|
|
4
|
+
*
|
|
5
|
+
* A migration is a change an *existing* workspace has to make when the framework moves it from an older release to a
|
|
6
|
+
* newer one: a config entry that moved, a file whose shape changed, a dependency range that has to follow the peer
|
|
7
|
+
* dependency. Anything `init --update` refreshes by itself is not a migration (that is what the generated files are
|
|
8
|
+
* for), and anything that only concerns brand-new workspaces belongs in `templates/`.
|
|
9
|
+
*
|
|
10
|
+
* Adding one:
|
|
11
|
+
* 1. Append an entry with an id of `<version>-<slug>` and the version of the release that *introduces* the change
|
|
12
|
+
* (`version` in `package.json` after the release PR is merged, which is the next version at the time of writing).
|
|
13
|
+
* 2. Explain it for the person: `summary` is one line, `appliesTo` says which workspaces it matters for, `details`
|
|
14
|
+
* adds what the summary cannot, and `manual` lists what only a person can do.
|
|
15
|
+
* 3. `auto: true` only when the change is idempotent and safe to run without asking (`apply` decides, `detect` skips a
|
|
16
|
+
* workspace that is already fine). A change to a file the project owns (`AGENTS.md`, `profiles.yaml`, a project
|
|
17
|
+
* config) is a `manual` step, never an automatic edit.
|
|
18
|
+
* 4. Set `breaking: true` when an untouched workspace can stop working. `pnpm asrun upgrade` then asks before it
|
|
19
|
+
* installs anything, using the manifest `pnpm build` writes (`dist/migrations/manifest.json`).
|
|
20
|
+
* 5. Never delete or renumber an entry: a workspace on any older release may still need it. The interface is documented
|
|
21
|
+
* in `docs/agents/framework-dev.md`.
|
|
22
|
+
*/
|
|
23
|
+
export declare const MIGRATIONS: Migration[];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace migrations of this framework, in the order they were introduced.
|
|
3
|
+
*
|
|
4
|
+
* A migration is a change an *existing* workspace has to make when the framework moves it from an older release to a
|
|
5
|
+
* newer one: a config entry that moved, a file whose shape changed, a dependency range that has to follow the peer
|
|
6
|
+
* dependency. Anything `init --update` refreshes by itself is not a migration (that is what the generated files are
|
|
7
|
+
* for), and anything that only concerns brand-new workspaces belongs in `templates/`.
|
|
8
|
+
*
|
|
9
|
+
* Adding one:
|
|
10
|
+
* 1. Append an entry with an id of `<version>-<slug>` and the version of the release that *introduces* the change
|
|
11
|
+
* (`version` in `package.json` after the release PR is merged, which is the next version at the time of writing).
|
|
12
|
+
* 2. Explain it for the person: `summary` is one line, `appliesTo` says which workspaces it matters for, `details`
|
|
13
|
+
* adds what the summary cannot, and `manual` lists what only a person can do.
|
|
14
|
+
* 3. `auto: true` only when the change is idempotent and safe to run without asking (`apply` decides, `detect` skips a
|
|
15
|
+
* workspace that is already fine). A change to a file the project owns (`AGENTS.md`, `profiles.yaml`, a project
|
|
16
|
+
* config) is a `manual` step, never an automatic edit.
|
|
17
|
+
* 4. Set `breaking: true` when an untouched workspace can stop working. `pnpm asrun upgrade` then asks before it
|
|
18
|
+
* installs anything, using the manifest `pnpm build` writes (`dist/migrations/manifest.json`).
|
|
19
|
+
* 5. Never delete or renumber an entry: a workspace on any older release may still need it. The interface is documented
|
|
20
|
+
* in `docs/agents/framework-dev.md`.
|
|
21
|
+
*/
|
|
22
|
+
export const MIGRATIONS = [];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
package/dist/scripts/build.js
CHANGED
|
@@ -2,8 +2,10 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
4
|
import { COPIED_SCRIPTS } from '../lib/build-artifacts.js';
|
|
5
|
+
import { MANIFEST_FILE, toManifest } from '../lib/migrations.js';
|
|
5
6
|
import { commandScript } from '../lib/node-cli.js';
|
|
6
7
|
import { FRAMEWORK_ROOT } from '../lib/paths.js';
|
|
8
|
+
import { MIGRATIONS } from '../migrations/index.js';
|
|
7
9
|
// Compiles the framework to `dist/`: JavaScript and type declarations that Node and Playwright can load from a
|
|
8
10
|
// node_modules folder (Node refuses to strip types there). The plain `.mjs` tools are copied as they are.
|
|
9
11
|
const tsc = commandScript('typescript', 'tsc');
|
|
@@ -19,3 +21,8 @@ for (const file of COPIED_SCRIPTS) {
|
|
|
19
21
|
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
20
22
|
fs.copyFileSync(from, to);
|
|
21
23
|
}
|
|
24
|
+
// The migration manifest of the published package. A still-old `pnpm asrun upgrade` reads it out of the *target*
|
|
25
|
+
// tarball before installing, so it has to be in `dist/`, next to the compiled registry the installed command uses.
|
|
26
|
+
const manifestFile = path.join(FRAMEWORK_ROOT, MANIFEST_FILE);
|
|
27
|
+
fs.mkdirSync(path.dirname(manifestFile), { recursive: true });
|
|
28
|
+
fs.writeFileSync(manifestFile, JSON.stringify(toManifest(MIGRATIONS), null, 2) + '\n');
|
package/dist/scripts/cli.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// `pnpm asrun <command>`: a thin dispatcher so every subcommand keeps one entry point.
|
|
2
2
|
// Anything that is not a subcommand is passed to the run command unchanged.
|
|
3
|
+
import { announceUpdate } from '../lib/update-check.js';
|
|
3
4
|
const [command, ...rest] = process.argv.slice(2);
|
|
5
|
+
// The same courtesy the installed `asrun` command shows: one line on stderr when the registry has a newer release.
|
|
6
|
+
// `upgrade` reads the registry itself, and `help` has no work to do.
|
|
7
|
+
if (!['upgrade', 'help', '--help-init'].includes(command ?? '') && !process.argv.includes('--help'))
|
|
8
|
+
announceUpdate();
|
|
4
9
|
async function main() {
|
|
5
10
|
if (command === 'init') {
|
|
6
11
|
const { main: init } = await import('./init.js');
|
|
@@ -17,20 +22,33 @@ async function main() {
|
|
|
17
22
|
process.exitCode = await reset(rest);
|
|
18
23
|
return;
|
|
19
24
|
}
|
|
25
|
+
if (command === 'upgrade') {
|
|
26
|
+
const { main: upgrade } = await import('./upgrade.js');
|
|
27
|
+
process.exitCode = await upgrade(rest);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (command === 'migrate') {
|
|
31
|
+
const { main: migrate } = await import('./migrate.js');
|
|
32
|
+
process.exitCode = await migrate(rest);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
20
35
|
if (command === 'help' || command === '--help-init') {
|
|
21
36
|
console.log(USAGE);
|
|
22
37
|
return;
|
|
23
38
|
}
|
|
24
39
|
await import('./run.js');
|
|
25
40
|
}
|
|
26
|
-
const USAGE = `Usage: pnpm asrun [init|doctor|reset] [options]
|
|
41
|
+
const USAGE = `Usage: pnpm asrun [init|doctor|reset|upgrade|migrate] [options]
|
|
27
42
|
|
|
28
43
|
(no subcommand) Run the tests of one environment. See "pnpm asrun --help".
|
|
29
44
|
init Create the files a workspace needs (never overwrites; --update refreshes generated files,
|
|
30
45
|
--root <dir> targets another folder, --force overwrites every file init writes,
|
|
31
46
|
--force <path> overwrites one file).
|
|
32
|
-
doctor Check the workspace: profiles, tsconfig alias, generated files, skill overrides.
|
|
47
|
+
doctor Check the workspace: profiles, tsconfig alias, generated files, skill overrides, migrations.
|
|
33
48
|
reset Remove node_modules and the files init writes, for a clean reinstall (keeps cases, specs,
|
|
34
|
-
profiles.yaml, package.json and edited scaffold files; see "pnpm asrun reset --help")
|
|
49
|
+
profiles.yaml, package.json and edited scaffold files; see "pnpm asrun reset --help").
|
|
50
|
+
upgrade Install the newest asrun, refresh the workspace files and apply its migrations, after checking the
|
|
51
|
+
target release for breaking changes (see "pnpm asrun upgrade --help").
|
|
52
|
+
migrate Apply the workspace migrations of the installed release, or list them (see "pnpm asrun migrate
|
|
53
|
+
--help"). "upgrade" runs it; use it after a manual install.`;
|
|
35
54
|
await main();
|
|
36
|
-
export {};
|