@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/dist/scripts/doctor.js
CHANGED
|
@@ -3,7 +3,10 @@ import path from 'node:path';
|
|
|
3
3
|
import { loadProfiles, listEnvs, PROFILES_FILE } from '../lib/profiles.js';
|
|
4
4
|
import { FRAMEWORK_ROOT, ROOT, SKILLS_DIR } from '../lib/paths.js';
|
|
5
5
|
import { spawnTarget } from '../lib/command.js';
|
|
6
|
+
import { migrationState, pendingMigrations } from '../lib/migrations.js';
|
|
7
|
+
import { MIGRATIONS } from '../migrations/index.js';
|
|
6
8
|
import { GENERATED, frameworkPath, frameworkVersion, PACKAGE, renderTemplate } from './init.js';
|
|
9
|
+
import { BUILD_APPROVALS, RUNTIME_EXTRAS, hasCapability, installedInWorkspace } from '../lib/runtime-deps.js';
|
|
7
10
|
import { builtFile, COPIED_SCRIPTS } from '../lib/build-artifacts.js';
|
|
8
11
|
/**
|
|
9
12
|
* Checks that a workspace is complete and consistent with the framework it points at. It never writes anything.
|
|
@@ -87,9 +90,11 @@ export async function main(args) {
|
|
|
87
90
|
errors.push(`${PACKAGE} is not installed in this workspace: run "pnpm add -D ${PACKAGE}", then "pnpm asrun init". ` +
|
|
88
91
|
'Every script that calls "asrun" fails until then.');
|
|
89
92
|
}
|
|
90
|
-
// The manifest, written by init: it names where the workspace reaches the framework
|
|
93
|
+
// The manifest, written by init: it names where the workspace reaches the framework, and since when the workspace
|
|
94
|
+
// has been migrated.
|
|
91
95
|
const manifest = read(path.join(ROOT, '.asrun.json'));
|
|
92
96
|
let recordedPath;
|
|
97
|
+
let marker = null;
|
|
93
98
|
const installedVersion = frameworkVersion();
|
|
94
99
|
if (manifest === null) {
|
|
95
100
|
errors.push('No .asrun.json: this workspace was never initialized, or "init" stopped before it finished (for example the ' +
|
|
@@ -98,10 +103,12 @@ export async function main(args) {
|
|
|
98
103
|
else {
|
|
99
104
|
try {
|
|
100
105
|
const parsed = JSON.parse(manifest);
|
|
106
|
+
marker = parsed;
|
|
101
107
|
recordedPath = parsed.frameworkPath;
|
|
102
|
-
facts.push(`version: asrun ${parsed.version
|
|
103
|
-
if (parsed.version
|
|
104
|
-
warnings.push(`asrun is installed at ${installedVersion.version}, but this workspace was set up with ${parsed.version}:
|
|
108
|
+
facts.push(`version: asrun ${typeof parsed.version === 'string' ? parsed.version : '?'} (${parsed.commit ?? '?'})`);
|
|
109
|
+
if (typeof parsed.version === 'string' && parsed.version !== installedVersion.version) {
|
|
110
|
+
warnings.push(`asrun is installed at ${installedVersion.version}, but this workspace was set up with ${parsed.version}: ` +
|
|
111
|
+
'run "pnpm asrun upgrade" (or reinstall the version this workspace needs).');
|
|
105
112
|
}
|
|
106
113
|
if (recordedPath !== undefined && !fs.existsSync(path.resolve(ROOT, recordedPath))) {
|
|
107
114
|
errors.push(`.asrun.json points at "${recordedPath}", which does not exist. Run "pnpm add -D @asrunjs/asrun" and then "pnpm asrun init".`);
|
|
@@ -112,6 +119,27 @@ export async function main(args) {
|
|
|
112
119
|
}
|
|
113
120
|
}
|
|
114
121
|
const framework = recordedPath ?? actualPath;
|
|
122
|
+
// Workspace migrations: `migrations.since` is the release the workspace matches, so newer ones are still to do. A
|
|
123
|
+
// workspace upgraded by hand (pnpm add + init --update) keeps the old baseline, which is what makes this check work.
|
|
124
|
+
if (marker !== null) {
|
|
125
|
+
try {
|
|
126
|
+
const state = migrationState(marker, installedVersion.version);
|
|
127
|
+
const outstanding = state.manual.length;
|
|
128
|
+
facts.push(`migrations: since ${state.since}${outstanding > 0 ? `, ${outstanding} manual step(s) outstanding` : ''}`);
|
|
129
|
+
const pending = pendingMigrations(MIGRATIONS, state.since, installedVersion.version);
|
|
130
|
+
if (pending.length > 0) {
|
|
131
|
+
warnings.push(`${pending.length} workspace migration(s) apply between ${state.since} and ${installedVersion.version} ` +
|
|
132
|
+
`(${pending.map((migration) => migration.id).join(', ')}): run "pnpm asrun migrate".`);
|
|
133
|
+
}
|
|
134
|
+
if (outstanding > 0) {
|
|
135
|
+
warnings.push(`Manual migration steps are still open (${state.manual.join(', ')}): run "pnpm asrun migrate --list" to see ` +
|
|
136
|
+
'them, then "pnpm asrun migrate --ack <id>" once they are done.');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
errors.push(`The migration registry is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
115
143
|
// The framework repository compiles itself into `dist/`, and `templates/playwright.config.ts` makes every workspace
|
|
116
144
|
// load the config from there. A build older than the sources therefore keeps runs on the previous code.
|
|
117
145
|
const stale = staleDistFiles();
|
|
@@ -231,13 +259,27 @@ export async function main(args) {
|
|
|
231
259
|
// The framework is outside the workspace (examples/demo): the framework repository manages itself.
|
|
232
260
|
}
|
|
233
261
|
else if (workspaceFile === null) {
|
|
234
|
-
warnings.push(
|
|
262
|
+
warnings.push(`pnpm-workspace.yaml is missing: run "pnpm asrun init" (it writes the "allowBuilds" entries for ${BUILD_APPROVALS.join(', ')}), or pnpm skips those install scripts.`);
|
|
235
263
|
}
|
|
236
|
-
else
|
|
237
|
-
|
|
264
|
+
else {
|
|
265
|
+
const missing = BUILD_APPROVALS.filter((pkg) => {
|
|
266
|
+
const entry = new RegExp(`^\\s+['"]?${pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]?\\s*:`, 'm');
|
|
267
|
+
return !entry.test(workspaceFile);
|
|
268
|
+
});
|
|
269
|
+
if (missing.length > 0) {
|
|
270
|
+
warnings.push(`pnpm-workspace.yaml has no "allowBuilds" entry for ${missing.join(', ')}: run "pnpm asrun init" (it adds ` +
|
|
271
|
+
'the entry before installing), or pnpm skips that install script.');
|
|
272
|
+
}
|
|
273
|
+
if (/^packages\s*:/m.test(workspaceFile)) {
|
|
274
|
+
warnings.push('pnpm-workspace.yaml lists "packages"; asrun is a dependency now, so that list can go.');
|
|
275
|
+
}
|
|
238
276
|
}
|
|
239
|
-
|
|
240
|
-
|
|
277
|
+
// Optional packages the workspace installs on demand, when this machine does not provide the capability itself.
|
|
278
|
+
for (const extra of RUNTIME_EXTRAS) {
|
|
279
|
+
if (hasCapability(extra) || installedInWorkspace(ROOT, extra.pkg) || installedInWorkspace(FRAMEWORK_ROOT, extra.pkg))
|
|
280
|
+
continue;
|
|
281
|
+
warnings.push(`${extra.pkg} is not installed and this machine does not provide it: ${extra.provides} is skipped. Run ` +
|
|
282
|
+
'"pnpm asrun init" (or "pnpm go") to install it, or install the tool yourself and run doctor again.');
|
|
241
283
|
}
|
|
242
284
|
let scripts = null;
|
|
243
285
|
try {
|
package/dist/scripts/go.mjs
CHANGED
|
@@ -111,9 +111,10 @@ if (dryRun) {
|
|
|
111
111
|
function ensurePnpmWorkspace() {
|
|
112
112
|
if (frameworkRelative.startsWith('..')) return 'skipped';
|
|
113
113
|
const file = path.join(ROOT, 'pnpm-workspace.yaml');
|
|
114
|
-
|
|
114
|
+
// `init` (step 3) adds the entry of each optional package it installs on demand, e.g. ffmpeg-static.
|
|
115
|
+
const block = 'allowBuilds:\n esbuild: true\n';
|
|
115
116
|
if (!fs.existsSync(file)) {
|
|
116
|
-
fs.writeFileSync(file, `# asrun's dependencies (esbuild through tsx
|
|
117
|
+
fs.writeFileSync(file, `# asrun's dependencies (esbuild through tsx) compile a binary on install.\n${block}`);
|
|
117
118
|
return 'created';
|
|
118
119
|
}
|
|
119
120
|
const text = fs.readFileSync(file, 'utf8');
|
|
@@ -140,7 +141,8 @@ function open(target) {
|
|
|
140
141
|
spawn(command, args, {stdio: 'ignore', detached: true}).on('error', () => {}).unref();
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
// ffmpeg joins the videos of a test's browser tabs into one. Never fatal:
|
|
144
|
+
// ffmpeg joins the videos of a test's browser tabs into one. Never fatal: `init` (step 3) installs the bundled
|
|
145
|
+
// ffmpeg-static when this machine has no ffmpeg.
|
|
144
146
|
function ensureFfmpeg() {
|
|
145
147
|
// ffmpeg does not know `--version`, only `-version`.
|
|
146
148
|
if (spawnSync('ffmpeg', ['-version'], {stdio: 'ignore', shell}).status === 0) return console.log('ffmpeg: ok');
|
|
@@ -151,11 +153,11 @@ function ensureFfmpeg() {
|
|
|
151
153
|
? ['winget', ['install', '--id', 'Gyan.FFmpeg', '-e', '--accept-source-agreements', '--accept-package-agreements']]
|
|
152
154
|
: null;
|
|
153
155
|
if (!installer) {
|
|
154
|
-
return warn('ffmpeg not found; the bundled ffmpeg-static is
|
|
156
|
+
return warn('ffmpeg not found; the bundled ffmpeg-static is installed when the workspace is prepared, in step 3. To install ffmpeg yourself: brew install ffmpeg | winget install Gyan.FFmpeg | sudo apt install ffmpeg');
|
|
155
157
|
}
|
|
156
158
|
console.log(`ffmpeg not found, installing it: ${installer[0]} ${installer[1].join(' ')}`);
|
|
157
159
|
if (run(...installer)) console.log('ffmpeg: installed');
|
|
158
|
-
else warn('Could not install ffmpeg; the bundled ffmpeg-static is
|
|
160
|
+
else warn('Could not install ffmpeg; the bundled ffmpeg-static is installed when the workspace is prepared, in step 3.');
|
|
159
161
|
}
|
|
160
162
|
|
|
161
163
|
step(1, 'Checking this machine');
|
package/dist/scripts/init.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { MARKER, PACKAGE } from '../lib/paths.js';
|
|
1
2
|
/** Files `init` owns: `--update` rewrites exactly these, and a project never edits them. */
|
|
2
3
|
export declare const GENERATED: {
|
|
3
4
|
file: string;
|
|
@@ -8,9 +9,9 @@ export declare const ONCE: {
|
|
|
8
9
|
file: string;
|
|
9
10
|
template: string;
|
|
10
11
|
}[];
|
|
11
|
-
export
|
|
12
|
+
export { MARKER };
|
|
13
|
+
export { PACKAGE };
|
|
12
14
|
export declare const DOTENV_HEADER = "# asrun: the workspace these commands run against (relative to this file).";
|
|
13
|
-
export declare const PACKAGE = "@asrunjs/asrun";
|
|
14
15
|
/** How the workspace reaches the framework: `./node_modules/@asrunjs/asrun`, or a relative folder in the framework repo. */
|
|
15
16
|
export declare function frameworkPath(root: string): string;
|
|
16
17
|
/** True when this workspace has the package installed (or is the framework's own checkout). */
|
package/dist/scripts/init.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { FRAMEWORK_ROOT, TEMPLATES_DIR } from '../lib/paths.js';
|
|
4
|
+
import { FRAMEWORK_ROOT, MARKER, PACKAGE, TEMPLATES_DIR, isRepoCheckout } from '../lib/paths.js';
|
|
5
|
+
import { migrationState, pendingMigrations } from '../lib/migrations.js';
|
|
6
|
+
import { MIGRATIONS } from '../migrations/index.js';
|
|
7
|
+
import { BUILD_APPROVALS, RUNTIME_EXTRAS, approveBuild, ensureRuntimeExtras, hasCapability, installedInWorkspace, } from '../lib/runtime-deps.js';
|
|
5
8
|
/**
|
|
6
9
|
* Creates the files a workspace needs. It is safe to run many times: it never overwrites a file the project owns,
|
|
7
10
|
* `--update` refreshes only the files generated from the framework templates, and `--force` rewrites every file it
|
|
8
11
|
* writes (except the project's own data and, without `--reset-rules`, its rules).
|
|
9
12
|
*/
|
|
10
|
-
const USAGE = `Usage: pnpm asrun init [--root <dir>] [--update] [--force [<path>]] [--reset-rules] [--no-env] [--install [spec]]
|
|
13
|
+
const USAGE = `Usage: pnpm asrun init [--root <dir>] [--update] [--force [<path>]] [--reset-rules] [--no-env] [--no-extras] [--install [spec]]
|
|
11
14
|
|
|
12
15
|
--root <dir> the workspace to initialize (default: the current folder)
|
|
13
16
|
--update refresh the generated files (playwright.config.ts, tests/seed.spec.ts, .claude/commands/*,
|
|
@@ -17,6 +20,7 @@ const USAGE = `Usage: pnpm asrun init [--root <dir>] [--update] [--force [<path>
|
|
|
17
20
|
--force <path> overwrite one file this tool writes, e.g. --force playwright.config.ts (may be repeated)
|
|
18
21
|
--reset-rules allow --force on AGENTS.md, CLAUDE.md or .claude/skills/** (project-owned rules)
|
|
19
22
|
--no-env do not write .env (ASRUN_ROOT)
|
|
23
|
+
--no-extras do not install the optional packages this machine lacks (ffmpeg-static), which "pnpm go" does
|
|
20
24
|
--install [spec] also add the dependency (default "@asrunjs/asrun"; e.g. --install github:<owner>/<repo>#<tag>)`;
|
|
21
25
|
/** Files `init` owns: `--update` rewrites exactly these, and a project never edits them. */
|
|
22
26
|
export const GENERATED = [
|
|
@@ -47,16 +51,13 @@ export const ONCE = [
|
|
|
47
51
|
];
|
|
48
52
|
// npm never packs a `.gitignore`, so the template is named `gitignore`; init writes it as `.gitignore`.
|
|
49
53
|
const GITIGNORE_BLOCK = fs.readFileSync(path.join(TEMPLATES_DIR, 'gitignore'), 'utf8').trimEnd();
|
|
50
|
-
|
|
54
|
+
// Re-exported: `doctor` and `reset` import the marker name from here, and `lib/paths.ts` is the one definition.
|
|
55
|
+
export { MARKER };
|
|
56
|
+
export { PACKAGE };
|
|
51
57
|
export const DOTENV_HEADER = '# asrun: the workspace these commands run against (relative to this file).';
|
|
52
|
-
export const PACKAGE = '@asrunjs/asrun';
|
|
53
58
|
function readTemplate(name) {
|
|
54
59
|
return fs.readFileSync(path.join(TEMPLATES_DIR, name), 'utf8');
|
|
55
60
|
}
|
|
56
|
-
/** True in the framework's own repository (not an installed package). */
|
|
57
|
-
function isRepoCheckout() {
|
|
58
|
-
return !FRAMEWORK_ROOT.split(path.sep).includes('node_modules') && fs.existsSync(path.join(FRAMEWORK_ROOT, 'examples', 'demo'));
|
|
59
|
-
}
|
|
60
61
|
/** How the workspace reaches the framework: `./node_modules/@asrunjs/asrun`, or a relative folder in the framework repo. */
|
|
61
62
|
export function frameworkPath(root) {
|
|
62
63
|
if (!isRepoCheckout()) {
|
|
@@ -240,6 +241,7 @@ export async function main(args) {
|
|
|
240
241
|
}
|
|
241
242
|
const resetRules = args.includes('--reset-rules');
|
|
242
243
|
const skipEnv = args.includes('--no-env');
|
|
244
|
+
const skipExtras = args.includes('--no-extras');
|
|
243
245
|
if (path.resolve(root) === path.resolve(FRAMEWORK_ROOT)) {
|
|
244
246
|
console.error('This folder is the framework itself, not a workspace. Run "pnpm asrun init" inside the project that uses ' +
|
|
245
247
|
'asrun, or pass --root <dir> (the framework develops against examples/demo).');
|
|
@@ -298,6 +300,25 @@ export async function main(args) {
|
|
|
298
300
|
const gitignore = ensureGitignore(root, update || forceAll);
|
|
299
301
|
const merged = mergePackageJson(root);
|
|
300
302
|
const dotenv = skipEnv ? 'skipped' : ensureDotEnv(process.cwd(), root);
|
|
303
|
+
// pnpm runs only the install scripts that pnpm-workspace.yaml approves. A file written before a package joined
|
|
304
|
+
// BUILD_APPROVALS (or one a project edited by hand) gets the missing entries here, before anything is installed:
|
|
305
|
+
// a note would be too late, the install that follows would already have skipped the script.
|
|
306
|
+
const approved = [];
|
|
307
|
+
const approvalsBroken = [];
|
|
308
|
+
if (!framework.startsWith('..')) {
|
|
309
|
+
for (const pkg of BUILD_APPROVALS) {
|
|
310
|
+
const result = approveBuild(root, pkg);
|
|
311
|
+
if (result === 'added' || result === 'created')
|
|
312
|
+
approved.push(pkg);
|
|
313
|
+
if (result === 'inline' || result === 'no-file')
|
|
314
|
+
approvalsBroken.push(pkg);
|
|
315
|
+
}
|
|
316
|
+
if (approvalsBroken.length > 0) {
|
|
317
|
+
notes.push(`pnpm-workspace.yaml has no "allowBuilds" block to extend: add ${approvalsBroken
|
|
318
|
+
.map((pkg) => `"${pkg}: true"`)
|
|
319
|
+
.join(', ')} under "allowBuilds" by hand, or pnpm skips its install script.`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
301
322
|
if (fs.existsSync(path.join(root, 'tsconfig.json')) && !written.includes('tsconfig.json')) {
|
|
302
323
|
const tsconfig = fs.readFileSync(path.join(root, 'tsconfig.json'), 'utf8');
|
|
303
324
|
if (tsconfig.includes('@asrun/')) {
|
|
@@ -306,9 +327,6 @@ export async function main(args) {
|
|
|
306
327
|
}
|
|
307
328
|
if (fs.existsSync(path.join(root, 'pnpm-workspace.yaml')) && !written.includes('pnpm-workspace.yaml')) {
|
|
308
329
|
const workspace = fs.readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8');
|
|
309
|
-
if (!workspace.includes('allowBuilds')) {
|
|
310
|
-
notes.push('Add "allowBuilds: {esbuild: true, ffmpeg-static: true}" to pnpm-workspace.yaml, or pnpm skips their build scripts.');
|
|
311
|
-
}
|
|
312
330
|
if (!workspace.includes('minimumReleaseAgeExclude')) {
|
|
313
331
|
notes.push('Add an "@asrunjs/asrun" entry to "minimumReleaseAgeExclude" in pnpm-workspace.yaml, or pnpm installs the ' +
|
|
314
332
|
'previous release for 24 hours after a new one.');
|
|
@@ -373,15 +391,53 @@ export async function main(args) {
|
|
|
373
391
|
return 1;
|
|
374
392
|
}
|
|
375
393
|
}
|
|
394
|
+
// Optional packages stay out of the package's dependency graph (a runtime dependency with an install script makes
|
|
395
|
+
// "pnpm dlx @asrunjs/asrun" stop at pnpm's build-approval picker, which nobody can answer in a pipeline), so the
|
|
396
|
+
// workspace installs one here, only when this machine lacks what it provides. A failure never stops init.
|
|
397
|
+
const extrasInstalled = [];
|
|
398
|
+
const extrasSkipped = [];
|
|
399
|
+
if (!framework.startsWith('..') && isInstalled(root)) {
|
|
400
|
+
if (skipExtras) {
|
|
401
|
+
for (const extra of RUNTIME_EXTRAS) {
|
|
402
|
+
if (!hasCapability(extra) && !installedInWorkspace(root, extra.pkg))
|
|
403
|
+
extrasSkipped.push(extra.pkg);
|
|
404
|
+
}
|
|
405
|
+
if (extrasSkipped.length > 0) {
|
|
406
|
+
notes.push(`Optional packages were not installed (--no-extras): ${extrasSkipped.join(', ')}. Run "pnpm asrun init" ` +
|
|
407
|
+
'without --no-extras, or "pnpm go", when the machine needs them.');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
for (const outcome of ensureRuntimeExtras(root, (extra) => console.log(`\n Installing ${extra.pkg} (${extra.provides})`))) {
|
|
412
|
+
if (outcome.status === 'installed')
|
|
413
|
+
extrasInstalled.push(outcome.extra.pkg);
|
|
414
|
+
else if (outcome.status === 'failed') {
|
|
415
|
+
notes.push(`${outcome.extra.pkg} was not installed (${outcome.detail}): ${outcome.extra.provides} is skipped until ` +
|
|
416
|
+
`it is. Run "pnpm add -D ${outcome.extra.pkg}" in ${root}, then "pnpm asrun init" again.`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
376
421
|
const version = frameworkVersion();
|
|
422
|
+
const previous = readManifest(root);
|
|
423
|
+
// The marker records the installed version, but the migration baseline is the release the workspace was last
|
|
424
|
+
// migrated *from*. It only moves when a migration succeeds, so a manual "pnpm add" followed by "init --update"
|
|
425
|
+
// cannot erase what still has to be done.
|
|
426
|
+
const migrations = migrationState(previous, version.version);
|
|
377
427
|
fs.writeFileSync(path.join(root, MARKER), JSON.stringify({
|
|
378
428
|
framework: PACKAGE,
|
|
379
429
|
frameworkPath: framework,
|
|
380
430
|
version: version.version,
|
|
381
431
|
commit: version.commit,
|
|
432
|
+
migrations,
|
|
382
433
|
generated: GENERATED.map((entry) => entry.file),
|
|
383
434
|
updatedAt: new Date().toISOString(),
|
|
384
435
|
}, null, 2) + '\n');
|
|
436
|
+
const pending = pendingMigrations(MIGRATIONS, migrations.since, version.version);
|
|
437
|
+
if (pending.length > 0) {
|
|
438
|
+
notes.push(`${pending.length} workspace migration(s) apply (${pending.map((migration) => migration.id).join(', ')}): ` +
|
|
439
|
+
'run "pnpm asrun migrate".');
|
|
440
|
+
}
|
|
385
441
|
const label = firstRun ? 'Initialized' : forceAll ? 'Forced' : update ? 'Refreshed' : 'Checked';
|
|
386
442
|
console.log(`${label} workspace: ${root}`);
|
|
387
443
|
console.log(` framework: ${PACKAGE} ${version.version} at ${framework}`);
|
|
@@ -391,6 +447,12 @@ export async function main(args) {
|
|
|
391
447
|
console.log(` kept: ${kept.join(', ')}`);
|
|
392
448
|
if (merged.length > 0)
|
|
393
449
|
console.log(` merged: package.json -> ${merged.join(', ')}`);
|
|
450
|
+
// A file init just wrote already has the entries; only an existing one is worth reporting as changed.
|
|
451
|
+
if (approved.length > 0 && !written.includes('pnpm-workspace.yaml')) {
|
|
452
|
+
console.log(` allowBuilds: added ${approved.join(', ')} to pnpm-workspace.yaml`);
|
|
453
|
+
}
|
|
454
|
+
if (extrasInstalled.length > 0)
|
|
455
|
+
console.log(` extras: installed ${extrasInstalled.join(', ')}`);
|
|
394
456
|
if (gitignore !== 'kept')
|
|
395
457
|
console.log(` .gitignore: ${gitignore}`);
|
|
396
458
|
if (dotenv !== 'skipped')
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function main(args: string[]): Promise<number>;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import readline from 'node:readline/promises';
|
|
4
|
+
import { FRAMEWORK_ROOT, MARKER, ROOT } from '../lib/paths.js';
|
|
5
|
+
import { compareVersions, isVersion, migrationState, pendingMigrations, readMarker, writeMigrationState, } from '../lib/migrations.js';
|
|
6
|
+
import { MIGRATIONS } from '../migrations/index.js';
|
|
7
|
+
/**
|
|
8
|
+
* `pnpm asrun migrate`: brings an existing workspace up to the release that is installed, by running the migrations
|
|
9
|
+
* recorded in `migrations/index.ts`. `pnpm asrun upgrade` runs it after the install; it is also the command a person
|
|
10
|
+
* runs after a manual `pnpm add -D @asrunjs/asrun@latest`, or to see what is still open.
|
|
11
|
+
*
|
|
12
|
+
* It writes only what a migration describes, plus the migration state in `.asrun.json`. It never installs anything:
|
|
13
|
+
* a migration whose release is newer than the installed package does not run.
|
|
14
|
+
*/
|
|
15
|
+
const USAGE = `Usage: pnpm asrun migrate [--root <dir>] [--list] [--dry-run] [--yes] [--from <version>] [--to <version>]
|
|
16
|
+
[--ack <id> ...|--ack-all]
|
|
17
|
+
|
|
18
|
+
--root <dir> the workspace to migrate (default: the current workspace)
|
|
19
|
+
--list list every migration of the installed release and its state, and change nothing
|
|
20
|
+
--dry-run show the migrations that would run, and change nothing
|
|
21
|
+
--yes apply breaking migrations without the confirmation prompt (for unattended runs)
|
|
22
|
+
--from <ver> pretend the workspace is at that release (default: the recorded migrations.since)
|
|
23
|
+
--to <ver> migrate up to that release (default: the installed one; never beyond it)
|
|
24
|
+
--ack <id> mark the manual steps of that migration as done (repeatable)
|
|
25
|
+
--ack-all mark every outstanding manual step as done
|
|
26
|
+
|
|
27
|
+
The migration baseline lives in .asrun.json ("migrations.since"). It only advances when a migration succeeds, so an
|
|
28
|
+
interrupted or failed run can simply be repeated.`;
|
|
29
|
+
/** The version of the package this command runs from: never beyond it, because its code is what applies migrations. */
|
|
30
|
+
function installedVersion() {
|
|
31
|
+
try {
|
|
32
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(FRAMEWORK_ROOT, 'package.json'), 'utf8'));
|
|
33
|
+
return typeof manifest.version === 'string' ? manifest.version : '0.0.0';
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return '0.0.0';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** The value of a flag that takes one; undefined when the flag is absent or has no value. */
|
|
40
|
+
function flagValue(args, name) {
|
|
41
|
+
const index = args.indexOf(name);
|
|
42
|
+
return index >= 0 && args[index + 1] !== undefined && !args[index + 1].startsWith('-') ? args[index + 1] : undefined;
|
|
43
|
+
}
|
|
44
|
+
/** Every `--ack <id>` of the command line. */
|
|
45
|
+
function acknowledged(args) {
|
|
46
|
+
const ids = [];
|
|
47
|
+
for (let index = 0; index < args.length; index++) {
|
|
48
|
+
if (args[index] === '--ack' && args[index + 1] !== undefined && !args[index + 1].startsWith('-')) {
|
|
49
|
+
ids.push(args[index + 1]);
|
|
50
|
+
index++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return ids;
|
|
54
|
+
}
|
|
55
|
+
function describe(migration) {
|
|
56
|
+
return `${migration.version} ${migration.id} ${migration.summary}`;
|
|
57
|
+
}
|
|
58
|
+
/** Asks a person to confirm; without a terminal it refuses instead of assuming an answer. */
|
|
59
|
+
async function confirm(question) {
|
|
60
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
61
|
+
return false;
|
|
62
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
63
|
+
try {
|
|
64
|
+
return (await rl.question(question)).trim().toLowerCase() === 'migrate';
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
rl.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function printManual(ids) {
|
|
71
|
+
if (ids.length === 0)
|
|
72
|
+
return;
|
|
73
|
+
console.log('\nManual steps left (do them, then mark them done with "pnpm asrun migrate --ack <id>"):');
|
|
74
|
+
for (const id of ids) {
|
|
75
|
+
const migration = MIGRATIONS.find((entry) => entry.id === id);
|
|
76
|
+
if (migration === undefined) {
|
|
77
|
+
console.log(` ${id}`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
console.log(` ${migration.version} ${migration.id}: ${migration.summary}`);
|
|
81
|
+
for (const line of migration.manual ?? [])
|
|
82
|
+
console.log(` - ${line}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export async function main(args) {
|
|
86
|
+
if (args.includes('--help')) {
|
|
87
|
+
console.log(USAGE);
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
const root = path.resolve(flagValue(args, '--root') ?? ROOT);
|
|
91
|
+
const dryRun = args.includes('--dry-run');
|
|
92
|
+
const yes = args.includes('--yes');
|
|
93
|
+
if (path.resolve(root) === path.resolve(FRAMEWORK_ROOT)) {
|
|
94
|
+
console.error('This folder is the framework itself, not a workspace. Run "pnpm asrun migrate" in the project that uses ' +
|
|
95
|
+
'asrun, or pass --root <dir>.');
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
const marker = readMarker(root);
|
|
99
|
+
if (marker === null) {
|
|
100
|
+
console.error(`No ${MARKER} in ${root}: this is not an initialized asrun workspace. Run "pnpm asrun init" first.`);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
const installed = installedVersion();
|
|
104
|
+
const state = migrationState(marker, installed);
|
|
105
|
+
// `--list` answers "what does this release contain, and where is this workspace", without touching anything.
|
|
106
|
+
if (args.includes('--list')) {
|
|
107
|
+
console.log(`Workspace: ${root}`);
|
|
108
|
+
console.log(`asrun: ${installed} installed, migrations since ${state.since}`);
|
|
109
|
+
if (MIGRATIONS.length === 0) {
|
|
110
|
+
console.log('\nThis release has no migrations.');
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
console.log(`\n${MIGRATIONS.length} migration(s) known to this release:`);
|
|
114
|
+
for (const migration of MIGRATIONS) {
|
|
115
|
+
const applied = compareVersions(migration.version, state.since) <= 0;
|
|
116
|
+
const mark = applied ? 'ok ' : migration.manual !== undefined && migration.manual.length > 0 ? '-> ' : ' ';
|
|
117
|
+
console.log(` ${mark}${describe(migration)}`);
|
|
118
|
+
console.log(` ${applied ? 'already at or before the baseline' : 'pending'}${migration.breaking ? ', breaking' : ''}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
printManual(state.manual);
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
const ackAll = args.includes('--ack-all');
|
|
125
|
+
const ackIds = acknowledged(args);
|
|
126
|
+
if (ackAll || ackIds.length > 0) {
|
|
127
|
+
const unknown = ackIds.filter((id) => !state.manual.includes(id));
|
|
128
|
+
if (unknown.length > 0) {
|
|
129
|
+
console.error(`Not outstanding: ${unknown.join(', ')}. Run "pnpm asrun migrate --list" to see what is.`);
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
const manual = ackAll ? [] : state.manual.filter((id) => !ackIds.includes(id));
|
|
133
|
+
writeMigrationState(root, { since: state.since, manual });
|
|
134
|
+
console.log(ackAll
|
|
135
|
+
? `Acknowledged every outstanding manual step (${state.manual.length}).`
|
|
136
|
+
: `Acknowledged: ${ackIds.join(', ')}.`);
|
|
137
|
+
printManual(manual);
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
const from = flagValue(args, '--from') ?? state.since;
|
|
141
|
+
const to = flagValue(args, '--to') ?? installed;
|
|
142
|
+
if (!isVersion(from) || !isVersion(to)) {
|
|
143
|
+
console.error(`Not a version: ${!isVersion(from) ? from : to}`);
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
if (compareVersions(to, installed) > 0) {
|
|
147
|
+
console.error(`Refusing to migrate to ${to}: the installed package is ${installed}. The code that applies a migration is the ` +
|
|
148
|
+
'installed one, so run "pnpm asrun upgrade" (or "pnpm add -D @asrunjs/asrun@latest") first.');
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
// A baseline recorded further than the installed release means the workspace was set up by a newer package (or the
|
|
152
|
+
// install went backwards). Nothing to apply; say so instead of silently moving the baseline back.
|
|
153
|
+
if (compareVersions(to, state.since) <= 0) {
|
|
154
|
+
console.log(`Workspace: ${root}`);
|
|
155
|
+
console.log(`Migrations: none — the baseline is ${state.since}, the installed release is ${installed}.`);
|
|
156
|
+
printManual(state.manual);
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
// `--from` may only move the start forward: migrations the workspace's baseline already covers never run again.
|
|
160
|
+
const start = compareVersions(from, state.since) < 0 ? state.since : from;
|
|
161
|
+
const pending = pendingMigrations(MIGRATIONS, start, to);
|
|
162
|
+
console.log(`Workspace: ${root}`);
|
|
163
|
+
console.log(`asrun: ${installed}${installed === to ? '' : ` (migrating to ${to})`}`);
|
|
164
|
+
if (pending.length === 0) {
|
|
165
|
+
console.log(`Migrations: none between ${start} and ${to}.`);
|
|
166
|
+
// Nothing was skipped, so the baseline moves up to the release that was asked for: a workspace with an empty
|
|
167
|
+
// registry (or a release without migrations) then stops reporting the old baseline in `doctor`.
|
|
168
|
+
if (!dryRun)
|
|
169
|
+
writeMigrationState(root, { since: to, manual: state.manual });
|
|
170
|
+
printManual(state.manual);
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
console.log(`Migrations: ${pending.length} between ${start} and ${to}`);
|
|
174
|
+
for (const migration of pending) {
|
|
175
|
+
console.log(` ${describe(migration)}${migration.auto === true ? ' (automatic)' : ''}${migration.breaking ? ' (breaking)' : ''}`);
|
|
176
|
+
if (migration.appliesTo !== undefined)
|
|
177
|
+
console.log(` affects: ${migration.appliesTo}`);
|
|
178
|
+
for (const line of migration.manual ?? [])
|
|
179
|
+
console.log(` - ${line}`);
|
|
180
|
+
}
|
|
181
|
+
if (dryRun) {
|
|
182
|
+
console.log('\nNothing was applied (--dry-run).');
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
const automatic = pending.filter((migration) => migration.auto === true && typeof migration.apply === 'function');
|
|
186
|
+
const breaking = automatic.filter((migration) => migration.breaking === true);
|
|
187
|
+
if (breaking.length > 0 && !yes) {
|
|
188
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
189
|
+
console.error(`\nRefusing to apply ${breaking.length} breaking migration(s): there is no terminal to confirm in. ` +
|
|
190
|
+
'A person adds --yes to do it.');
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
const answer = await confirm(`\nType "migrate" to apply ${breaking.length} breaking migration(s): `);
|
|
194
|
+
if (!answer) {
|
|
195
|
+
console.error('Cancelled. Nothing was applied.');
|
|
196
|
+
return 1;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const context = {
|
|
200
|
+
root,
|
|
201
|
+
frameworkRoot: FRAMEWORK_ROOT,
|
|
202
|
+
from: start,
|
|
203
|
+
to,
|
|
204
|
+
log: (text) => console.log(` ${text}`),
|
|
205
|
+
};
|
|
206
|
+
const state2 = { since: state.since, manual: [...state.manual] };
|
|
207
|
+
// The baseline moves one *release* at a time: several migrations may come with the same version, and a failure in
|
|
208
|
+
// the middle of one release must leave the whole release pending, so a retry repeats it instead of skipping the rest.
|
|
209
|
+
let done = state.since;
|
|
210
|
+
let applied = 0;
|
|
211
|
+
let skipped = 0;
|
|
212
|
+
for (let index = 0; index < pending.length; index++) {
|
|
213
|
+
const migration = pending[index];
|
|
214
|
+
const label = `${migration.version} ${migration.id}`;
|
|
215
|
+
const lastOfRelease = index === pending.length - 1 || pending[index + 1].version !== migration.version;
|
|
216
|
+
if (migration.auto !== true || typeof migration.apply !== 'function') {
|
|
217
|
+
console.log(` -> ${label}: a person has to do this`);
|
|
218
|
+
for (const line of migration.manual ?? [])
|
|
219
|
+
console.log(` - ${line}`);
|
|
220
|
+
if ((migration.manual ?? []).length > 0)
|
|
221
|
+
state2.manual.push(migration.id);
|
|
222
|
+
if (lastOfRelease)
|
|
223
|
+
done = migration.version;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (migration.detect !== undefined && !migration.detect(context)) {
|
|
227
|
+
console.log(` ok ${label}: already in place`);
|
|
228
|
+
skipped++;
|
|
229
|
+
if (lastOfRelease)
|
|
230
|
+
done = migration.version;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
console.log(` .. ${label}`);
|
|
234
|
+
try {
|
|
235
|
+
await migration.apply(context);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
// The baseline stops at the last release that finished, so fixing the cause and running the command again
|
|
239
|
+
// repeats this release (its migrations must be idempotent) and nothing later.
|
|
240
|
+
writeMigrationState(root, { since: done, manual: [...new Set(state2.manual)] });
|
|
241
|
+
console.error(`\n ! ${label} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
242
|
+
console.error(` The workspace is migrated up to ${done}. Fix the cause and run "pnpm asrun migrate" again.`);
|
|
243
|
+
return 1;
|
|
244
|
+
}
|
|
245
|
+
console.log(` ok ${label}`);
|
|
246
|
+
applied++;
|
|
247
|
+
if (lastOfRelease)
|
|
248
|
+
done = migration.version;
|
|
249
|
+
}
|
|
250
|
+
if (compareVersions(to, done) > 0)
|
|
251
|
+
done = to;
|
|
252
|
+
state2.since = done;
|
|
253
|
+
const manual = [...new Set(state2.manual)];
|
|
254
|
+
writeMigrationState(root, { since: state2.since, manual });
|
|
255
|
+
console.log(`\nApplied ${applied} migration(s)${skipped > 0 ? `, ${skipped} already in place` : ''}. ` +
|
|
256
|
+
`Baseline: ${state2.since}.`);
|
|
257
|
+
printManual(manual);
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
package/dist/scripts/run.js
CHANGED
|
@@ -147,7 +147,8 @@ const knownEnvs = listEnvs(allProfiles);
|
|
|
147
147
|
const listOnly = passthrough.includes('--list');
|
|
148
148
|
if (!env && !listOnly) {
|
|
149
149
|
console.error(`Choose the environment to test: --env <env> (${knownEnvs.join(' | ')}). There is no default.\n` +
|
|
150
|
-
`Example: pnpm asrun --env ${knownEnvs.includes('staging') ? 'staging' : (knownEnvs[0] ?? '<env>')}`
|
|
150
|
+
`Example: pnpm asrun --env ${knownEnvs.includes('staging') ? 'staging' : (knownEnvs[0] ?? '<env>')}\n` +
|
|
151
|
+
'All options: pnpm asrun --help. To check the installation instead: pnpm asrun doctor');
|
|
151
152
|
process.exit(1);
|
|
152
153
|
}
|
|
153
154
|
if (env && !knownEnvs.includes(env)) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function main(args: string[]): Promise<number>;
|