screenci 0.0.62 → 0.0.64

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 (71) hide show
  1. package/README.md +34 -15
  2. package/bin/screenci.js +2 -2
  3. package/dist/cli.d.ts +46 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +463 -242
  6. package/dist/cli.js.map +1 -1
  7. package/dist/docs/manifest.d.ts +132 -70
  8. package/dist/docs/manifest.d.ts.map +1 -1
  9. package/dist/docs/manifest.js +46 -24
  10. package/dist/docs/manifest.js.map +1 -1
  11. package/dist/docs/videos.d.ts +1 -1
  12. package/dist/docs/videos.js +1 -1
  13. package/dist/docs/videos.js.map +1 -1
  14. package/dist/e2e/instrument.e2e.js +11 -11
  15. package/dist/e2e/instrument.e2e.js.map +1 -1
  16. package/dist/index.d.ts +6 -4
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +3 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/src/asset.d.ts +27 -0
  21. package/dist/src/asset.d.ts.map +1 -1
  22. package/dist/src/asset.js +46 -0
  23. package/dist/src/asset.js.map +1 -1
  24. package/dist/src/changeFocus.d.ts.map +1 -1
  25. package/dist/src/changeFocus.js +3 -3
  26. package/dist/src/changeFocus.js.map +1 -1
  27. package/dist/src/cue.d.ts +60 -13
  28. package/dist/src/cue.d.ts.map +1 -1
  29. package/dist/src/cue.js +153 -47
  30. package/dist/src/cue.js.map +1 -1
  31. package/dist/src/events.d.ts +56 -8
  32. package/dist/src/events.d.ts.map +1 -1
  33. package/dist/src/events.js +47 -1
  34. package/dist/src/events.js.map +1 -1
  35. package/dist/src/git.d.ts +15 -0
  36. package/dist/src/git.d.ts.map +1 -0
  37. package/dist/src/git.js +43 -0
  38. package/dist/src/git.js.map +1 -0
  39. package/dist/src/init.d.ts +9 -0
  40. package/dist/src/init.d.ts.map +1 -1
  41. package/dist/src/init.js +293 -113
  42. package/dist/src/init.js.map +1 -1
  43. package/dist/src/instrument.d.ts.map +1 -1
  44. package/dist/src/instrument.js +49 -125
  45. package/dist/src/instrument.js.map +1 -1
  46. package/dist/src/mouse.d.ts +1 -0
  47. package/dist/src/mouse.d.ts.map +1 -1
  48. package/dist/src/mouse.js +9 -3
  49. package/dist/src/mouse.js.map +1 -1
  50. package/dist/src/recording.d.ts +1 -1
  51. package/dist/src/recording.d.ts.map +1 -1
  52. package/dist/src/recordingData.d.ts +43 -1
  53. package/dist/src/recordingData.d.ts.map +1 -1
  54. package/dist/src/studio.d.ts +36 -0
  55. package/dist/src/studio.d.ts.map +1 -0
  56. package/dist/src/studio.js +39 -0
  57. package/dist/src/studio.js.map +1 -0
  58. package/dist/src/types.d.ts +141 -125
  59. package/dist/src/types.d.ts.map +1 -1
  60. package/dist/src/types.js +1 -0
  61. package/dist/src/types.js.map +1 -1
  62. package/dist/src/video.d.ts +2 -1
  63. package/dist/src/video.d.ts.map +1 -1
  64. package/dist/src/video.js.map +1 -1
  65. package/dist/src/voices.d.ts +3 -3
  66. package/dist/src/voices.d.ts.map +1 -1
  67. package/dist/tsconfig.tsbuildinfo +1 -1
  68. package/package.json +1 -1
  69. package/skills/screenci/SKILL.md +7 -8
  70. package/skills/screenci/references/init.md +1 -2
  71. package/skills/screenci/references/record.md +3 -9
package/dist/src/init.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'child_process';
2
- import { existsSync, readFileSync, realpathSync } from 'fs';
2
+ import { existsSync, readFileSync, realpathSync, rmSync } from 'fs';
3
3
  import { appendFile, mkdir, readFile, writeFile } from 'fs/promises';
4
- import { basename, delimiter, dirname, resolve } from 'path';
4
+ import { basename, delimiter, dirname, relative, resolve, sep } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { Command, CommanderError } from 'commander';
7
7
  import { input } from '@inquirer/prompts';
@@ -47,14 +47,22 @@ export function detectPackageManagerFromPackageJson(dir) {
47
47
  return null;
48
48
  }
49
49
  export function determinePackageManager(cwd) {
50
+ // 1. The package manager that spawned the process is explicit intent
51
+ // (`pnpm create screenci`, `yarn create screenci`, `npm init screenci`) and
52
+ // always wins — including npm, so `npm init` gives an npm island even in a
53
+ // pnpm/yarn repo. Check pnpm/yarn before npm because their user-agent
54
+ // strings also contain an `npm/...` segment.
50
55
  const userAgent = process.env.npm_config_user_agent;
51
56
  if (userAgent?.includes('pnpm'))
52
57
  return 'pnpm';
53
58
  if (userAgent?.includes('yarn'))
54
59
  return 'yarn';
55
- // Filesystem detection is only performed during init (when cwd is provided).
56
- // record/test commands rely solely on the user agent, which correctly reflects
57
- // how the CLI was invoked (e.g. "pnpm exec screenci record" sets the agent).
60
+ if (userAgent?.includes('npm'))
61
+ return 'npm';
62
+ // 2. No package-manager wrapper set a user agent (e.g. a global or direct
63
+ // `screenci init`). Fall back to the surrounding repo's toolchain so the
64
+ // island matches it, then to npm. Pass `--package-manager` to override.
65
+ // (record/test pass no cwd and rely solely on the user agent.)
58
66
  if (cwd !== undefined) {
59
67
  const fromLockfile = detectPackageManagerFromLockfile(cwd);
60
68
  if (fromLockfile)
@@ -77,15 +85,31 @@ export function detectPnpmWorkspace(cwd) {
77
85
  }
78
86
  return false;
79
87
  }
80
- function detectYarnWorkspace(cwd) {
81
- try {
82
- const raw = readFileSync(resolve(cwd, 'package.json'), 'utf-8');
83
- const pkg = JSON.parse(raw);
84
- return pkg.workspaces !== undefined;
85
- }
86
- catch {
87
- return false;
88
+ /**
89
+ * Locate the repository root by walking up from `startDir` until a `.git`
90
+ * directory is found. Used to place the GitHub Actions workflow (which GitHub
91
+ * only discovers at the repo root) and the agent skills. Falls back to
92
+ * `startDir` when no `.git` is found.
93
+ */
94
+ function findRepoRoot(startDir) {
95
+ let current = startDir;
96
+ while (true) {
97
+ if (existsSync(resolve(current, '.git')))
98
+ return current;
99
+ const parent = dirname(current);
100
+ if (parent === current)
101
+ break;
102
+ current = parent;
88
103
  }
104
+ return startDir;
105
+ }
106
+ /**
107
+ * Convert a filesystem-relative path to a POSIX-style path suitable for YAML
108
+ * `working-directory` / `cache-dependency-path` fields in the workflow.
109
+ */
110
+ function toWorkflowPath(relativePath) {
111
+ const normalized = relativePath.split(sep).join('/');
112
+ return normalized.length === 0 ? '.' : normalized;
89
113
  }
90
114
  function resolveSpawnSpec(cmd, args) {
91
115
  if (process.platform !== 'win32') {
@@ -314,7 +338,12 @@ function getPackageManagerCommand(packageManager, isWorkspace = false) {
314
338
  screenciRun: 'pnpm exec screenci',
315
339
  playwrightRun: 'pnpm exec playwright',
316
340
  installCommand: 'pnpm',
317
- installArgs: (pkg) => ['add', '--save-dev', ...workspaceFlag, pkg],
341
+ installArgs: (...pkgs) => [
342
+ 'add',
343
+ '--save-dev',
344
+ ...workspaceFlag,
345
+ ...pkgs,
346
+ ],
318
347
  screenciInstallArgs: (pkg) => [
319
348
  'add',
320
349
  '--save-dev',
@@ -343,7 +372,7 @@ function getPackageManagerCommand(packageManager, isWorkspace = false) {
343
372
  screenciRun: 'yarn screenci',
344
373
  playwrightRun: 'yarn playwright',
345
374
  installCommand: 'yarn',
346
- installArgs: (pkg) => ['add', '--dev', ...workspaceFlag, pkg],
375
+ installArgs: (...pkgs) => ['add', '--dev', ...workspaceFlag, ...pkgs],
347
376
  screenciInstallArgs: (pkg) => ['add', '--dev', ...workspaceFlag, pkg],
348
377
  skillsCommand: 'yarn',
349
378
  skillsArgs: (skills, agent) => [
@@ -364,7 +393,7 @@ function getPackageManagerCommand(packageManager, isWorkspace = false) {
364
393
  screenciRun: 'npx screenci',
365
394
  playwrightRun: 'npx playwright',
366
395
  installCommand: 'npm',
367
- installArgs: (pkg) => ['install', '--save-dev', pkg],
396
+ installArgs: (...pkgs) => ['install', '--save-dev', ...pkgs],
368
397
  screenciInstallArgs: (pkg) => ['install', '--save-dev', pkg],
369
398
  skillsCommand: 'npm',
370
399
  skillsArgs: (skills, agent) => [
@@ -405,21 +434,112 @@ function getSkillsManualCommand(packageManager, skills, agent) {
405
434
  .concat(['-y'])
406
435
  .join(' ');
407
436
  }
408
- function generateEmptyPackageJson() {
409
- return '{\n "type": "module"\n}\n';
437
+ /**
438
+ * Turn a human project name into a valid npm package name for the island's own
439
+ * package.json. We use the project name (the repository root directory name by
440
+ * default) directly. The name must NOT be `screenci` (a package cannot depend
441
+ * on a package with its own name), so we fall back to `screenci-videos` only
442
+ * when the slug is empty or would collide with `screenci`.
443
+ */
444
+ export function toIslandPackageName(projectName) {
445
+ const slug = projectName
446
+ .toLowerCase()
447
+ .replace(/[^a-z0-9]+/g, '-')
448
+ .replace(/^-+|-+$/g, '');
449
+ if (slug.length === 0 || slug === 'screenci') {
450
+ return 'screenci-videos';
451
+ }
452
+ return slug;
410
453
  }
411
- async function ensurePackageJsonTypeModule(packageJsonPath) {
412
- try {
413
- const raw = await readFile(packageJsonPath, 'utf-8');
414
- const pkg = JSON.parse(raw);
415
- if (pkg['type'] === 'module')
416
- return;
417
- pkg['type'] = 'module';
418
- await writeFile(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n');
454
+ function generateIslandPackageJson(projectName) {
455
+ return (JSON.stringify({
456
+ name: toIslandPackageName(projectName),
457
+ private: true,
458
+ type: 'module',
459
+ scripts: {
460
+ test: 'screenci test',
461
+ record: 'screenci record',
462
+ },
463
+ }, null, 2) + '\n');
464
+ }
465
+ function generateIslandTsconfig() {
466
+ // Minimal config so an editor type-checks the island as its own project
467
+ // instead of inheriting a surrounding repo's tsconfig or the legacy TS
468
+ // defaults. `module`/`moduleResolution` let TypeScript read screenci's ESM
469
+ // `exports` map; `target` gives the example's async/await a modern lib.
470
+ return (JSON.stringify({
471
+ compilerOptions: {
472
+ module: 'ESNext',
473
+ moduleResolution: 'bundler',
474
+ target: 'ESNext',
475
+ },
476
+ }, null, 2) + '\n');
477
+ }
478
+ /**
479
+ * Resolve the package-manager-specific command a user types to run the island's
480
+ * own `test` / `record` scripts. npm needs `run` for non-`test` scripts and `--`
481
+ * to forward flags; pnpm and yarn forward both implicitly.
482
+ */
483
+ function getIslandScriptInvocations(packageManager) {
484
+ if (packageManager === 'pnpm') {
485
+ return {
486
+ test: 'pnpm test',
487
+ testUi: 'pnpm test --ui',
488
+ record: 'pnpm record',
489
+ };
419
490
  }
420
- catch {
421
- // Malformed or unreadable package.json — leave it untouched.
491
+ if (packageManager === 'yarn') {
492
+ return {
493
+ test: 'yarn test',
494
+ testUi: 'yarn test --ui',
495
+ record: 'yarn record',
496
+ };
422
497
  }
498
+ return {
499
+ test: 'npm test',
500
+ testUi: 'npm test -- --ui',
501
+ record: 'npm run record',
502
+ };
503
+ }
504
+ export function generateIslandReadme(projectName, packageManager) {
505
+ const scripts = getIslandScriptInvocations(packageManager);
506
+ return `# ${projectName}
507
+
508
+ ScreenCI video scripts for this project. Edit the \`*.video.ts\` files in
509
+ \`videos/\` to script your recordings.
510
+
511
+ ## Commands
512
+
513
+ - \`${scripts.test}\` tests your video scripts fast locally.
514
+ - \`${scripts.testUi}\` tests your video scripts in interactive UI mode.
515
+ - \`${scripts.record}\` records and pauses for first-time setup if needed.
516
+
517
+ ## Learn more
518
+
519
+ Visit https://screenci.com/docs for the full documentation.
520
+ `;
521
+ }
522
+ function generatePnpmWorkspaceYaml(pnpmMajor) {
523
+ // A nested `pnpm-workspace.yaml` makes pnpm treat the island as its own
524
+ // workspace root, so a surrounding monorepo workspace does not absorb it (no
525
+ // hoisting, no `-w` install). It also pre-approves the ffmpeg-static build
526
+ // script so non-interactive installs (e.g. `pnpm install --frozen-lockfile`
527
+ // in CI) build the bundled binary without prompting.
528
+ //
529
+ // pnpm 10 and 11 spell this approval differently: pnpm 11 removed
530
+ // `onlyBuiltDependencies` in favour of the `allowBuilds` map. Emit the key
531
+ // that matches the installed pnpm so the approval is actually honoured.
532
+ const buildApproval = pnpmMajor >= 11
533
+ ? `allowBuilds:
534
+ ffmpeg-static: true
535
+ `
536
+ : `onlyBuiltDependencies:
537
+ - ffmpeg-static
538
+ `;
539
+ return `packages:
540
+ - '.'
541
+
542
+ ${buildApproval}`;
423
543
  }
424
544
  function parseSemverTriplet(version) {
425
545
  const match = version
@@ -522,6 +642,15 @@ async function ensureSupportedPnpmVersion(cwd) {
522
642
  if (!versionSupport.supported) {
523
643
  throw buildUnsupportedPnpmError(versionSupport);
524
644
  }
645
+ return versionSupport;
646
+ }
647
+ // A supported pnpm version always parses (the support check rejects malformed
648
+ // versions), so the major is reliable here; fall back to 10 defensively.
649
+ function pnpmMajorFromSupport(versionSupport) {
650
+ const parsed = versionSupport.detectedVersion
651
+ ? parseSemverTriplet(versionSupport.detectedVersion)
652
+ : null;
653
+ return parsed?.[0] ?? 10;
525
654
  }
526
655
  export function parseYarnVersionSupport(versionOutput) {
527
656
  const detectedVersion = versionOutput.trim();
@@ -642,26 +771,27 @@ async function writeInitGitignore(projectDir, packageManager) {
642
771
  await appendFile(gitignorePath, `${separator}${content}`);
643
772
  }
644
773
  async function installInitDependencies(projectDir, verbose, screenciDependency, includePlaywrightCli, commands) {
774
+ // Packages that share identical install flags are installed in a single
775
+ // command so the package manager resolves the dependency graph once instead
776
+ // of once per package. ScreenCI stays separate because on pnpm it needs an
777
+ // extra '--allow-build=ffmpeg-static' flag the others don't carry.
778
+ const sharedPackages = [
779
+ `@playwright/test@${PLAYWRIGHT_TEST_VERSION}`,
780
+ `@types/node@${NODE_TYPES_VERSION}`,
781
+ ...(includePlaywrightCli
782
+ ? [`@playwright/cli@${PLAYWRIGHT_CLI_VERSION}`]
783
+ : []),
784
+ ];
645
785
  const installSteps = [
646
786
  {
647
- message: 'Installing Playwright Test...',
648
- args: commands.installArgs(`@playwright/test@${PLAYWRIGHT_TEST_VERSION}`),
787
+ message: 'Installing dependencies...',
788
+ args: commands.installArgs(...sharedPackages),
649
789
  },
650
790
  {
651
791
  message: 'Installing ScreenCI...',
652
792
  args: commands.screenciInstallArgs(`screenci@${screenciDependency}`),
653
793
  },
654
- {
655
- message: 'Installing Node.js types...',
656
- args: commands.installArgs(`@types/node@${NODE_TYPES_VERSION}`),
657
- },
658
794
  ];
659
- if (includePlaywrightCli) {
660
- installSteps.push({
661
- message: 'Installing playwright-cli...',
662
- args: commands.installArgs(`@playwright/cli@${PLAYWRIGHT_CLI_VERSION}`),
663
- });
664
- }
665
795
  for (const step of installSteps) {
666
796
  if (verbose) {
667
797
  logger.info(`Running '${commands.installCommand} ${step.args.join(' ')}'...`);
@@ -680,12 +810,12 @@ async function installInitDependencies(projectDir, verbose, screenciDependency,
680
810
  }
681
811
  }
682
812
  }
683
- function printInitNextSteps(projectDir, packageManager) {
813
+ function printInitNextSteps(projectDir, islandDirName, packageManager) {
684
814
  const resolvedProjectDir = realpathSync(projectDir);
685
815
  const commands = getPackageManagerCommand(packageManager);
686
816
  logger.info(`${pc.green('✔ Success!')} Created a ScreenCI project at ${resolvedProjectDir}`);
687
817
  logger.info('');
688
- logger.info('Inside that directory, you can run several commands:');
818
+ logger.info('You can now run these commands:');
689
819
  logger.info('');
690
820
  logger.info(` ${pc.cyan(`${commands.screenciRun} test`)}`);
691
821
  logger.info(' Tests your video scripts fast locally.');
@@ -693,26 +823,28 @@ function printInitNextSteps(projectDir, packageManager) {
693
823
  logger.info(` ${pc.cyan(`${commands.screenciRun} test --ui`)}`);
694
824
  logger.info(' Tests your video scripts in interactive UI mode.');
695
825
  logger.info('');
696
- logger.info(` ${pc.cyan(`${commands.screenciRun} login`)}`);
697
- logger.info(' Saves SCREENCI_SECRET for uploads and remote rendering.');
698
- logger.info('');
699
826
  logger.info(` ${pc.cyan(`${commands.screenciRun} record`)}`);
700
- logger.info(' Records, uploads and renders final videos after login.');
827
+ logger.info(' Records locally and pauses for first-time ScreenCI setup if needed.');
701
828
  logger.info('');
702
829
  logger.info('We suggest that you begin by typing:');
703
830
  logger.info('');
831
+ logger.info(` ${pc.cyan(`cd ${islandDirName}`)}`);
704
832
  logger.info(` ${pc.cyan(`${commands.screenciRun} test`)}`);
705
833
  logger.info('');
706
834
  logger.info('And check out the following files:');
707
- logger.info(' - ./videos/example.video.ts - Example video script');
708
- logger.info(' - ./screenci.config.ts - ScreenCI configuration');
835
+ logger.info(` - ./${islandDirName}/videos/example.video.ts - Example video script`);
836
+ logger.info(` - ./${islandDirName}/screenci.config.ts - ScreenCI configuration`);
837
+ logger.info(` - ./${islandDirName}/README.md - Project commands and docs link`);
709
838
  logger.info('');
710
839
  logger.info(`Visit ${pc.cyan('https://screenci.com/docs')} for more information.`);
711
840
  logger.info('');
712
841
  logger.info('Happy hacking! 🎥');
713
842
  }
714
- function generateGithubAction(packageManager, isWorkspace = false) {
715
- const commands = getPackageManagerCommand(packageManager, isWorkspace);
843
+ function generateGithubAction(packageManager, islandWorkflowPath) {
844
+ const commands = getPackageManagerCommand(packageManager);
845
+ const cacheDependencyPath = islandWorkflowPath === '.'
846
+ ? commands.lockfileName
847
+ : `${islandWorkflowPath}/${commands.lockfileName}`;
716
848
  return `name: ScreenCI
717
849
 
718
850
  on:
@@ -742,22 +874,22 @@ jobs:
742
874
  with:
743
875
  node-version: 24
744
876
  cache: ${commands.cacheName}
745
- cache-dependency-path: ${commands.lockfileName}
877
+ cache-dependency-path: ${cacheDependencyPath}
746
878
 
747
879
  - name: Install dependencies
748
- working-directory: .
880
+ working-directory: ${islandWorkflowPath}
749
881
  env:
750
882
  HUSKY: 0
751
883
  npm_config_strict_dep_builds: false
752
884
  run: ${commands.frozenInstallCommand}
753
885
 
754
886
  - name: Install Chromium Headless Shell
755
- working-directory: .
887
+ working-directory: ${islandWorkflowPath}
756
888
  run: ${commands.playwrightRun} install --only-shell chromium
757
889
 
758
890
  - id: record
759
891
  name: Record
760
- working-directory: .
892
+ working-directory: ${islandWorkflowPath}
761
893
  env:
762
894
  SCREENCI_SECRET: \${{ secrets.SCREENCI_SECRET }}
763
895
  run: ${commands.screenciRun} record
@@ -857,12 +989,20 @@ function getInitScreenciDependencyOverride() {
857
989
  export async function runInit(projectNameArg, options) {
858
990
  const { verbose, yes, agent, packageManager } = options;
859
991
  const initCwd = getInitProjectRoot();
860
- const isWorkspace = packageManager === 'pnpm'
861
- ? detectPnpmWorkspace(initCwd)
862
- : packageManager === 'yarn'
863
- ? detectYarnWorkspace(initCwd)
864
- : false;
865
- const commands = getPackageManagerCommand(packageManager, isWorkspace);
992
+ const commands = getPackageManagerCommand(packageManager);
993
+ // ScreenCI scaffolds a self-contained `screenci/` island under the current
994
+ // directory: its own package.json + local install, deliberately NOT a member
995
+ // of any surrounding workspace. This keeps installation deterministic in
996
+ // monorepos. The GitHub workflow and agent skills, however, must live at the
997
+ // repository root (that is where GitHub and coding agents discover them).
998
+ const repoRoot = findRepoRoot(initCwd);
999
+ const islandDir = resolve(initCwd, 'screenci');
1000
+ const islandDirName = toWorkflowPath(relative(initCwd, islandDir));
1001
+ const islandWorkflowPath = toWorkflowPath(relative(repoRoot, islandDir));
1002
+ if (existsSync(islandDir)) {
1003
+ logger.error(`Error: ${islandDirName}/ already exists. Remove it (or run init in a different directory) and try again.`);
1004
+ process.exit(1);
1005
+ }
866
1006
  if (packageManager === 'yarn') {
867
1007
  await ensureSupportedYarnVersion(initCwd);
868
1008
  }
@@ -874,8 +1014,7 @@ export async function runInit(projectNameArg, options) {
874
1014
  logger.error('Error: Project name is required');
875
1015
  process.exit(1);
876
1016
  }
877
- const projectDir = initCwd;
878
- const githubWorkflowsDir = resolve(projectDir, '.github', 'workflows');
1017
+ const githubWorkflowsDir = resolve(repoRoot, '.github', 'workflows');
879
1018
  const githubActionPath = resolve(githubWorkflowsDir, 'screenci.yaml');
880
1019
  const shouldAddGithubActionWorkflow = yes
881
1020
  ? true
@@ -892,10 +1031,13 @@ export async function runInit(projectNameArg, options) {
892
1031
  const shouldInstallPlaywrightCli = yes
893
1032
  ? true
894
1033
  : await promptInitPlaywrightCliSkillForPackageManager(packageManager, agent);
895
- if (shouldAddGithubActionWorkflow && existsSync(githubActionPath)) {
896
- logger.error('Error: GitHub Actions workflow ".github/workflows/screenci.yaml" already exists');
897
- process.exit(1);
1034
+ // The workflow lives at the repo root. If one already exists, skip it (do not
1035
+ // overwrite, do not fail) so re-running init stays non-destructive.
1036
+ const workflowAlreadyExists = shouldAddGithubActionWorkflow && existsSync(githubActionPath);
1037
+ if (workflowAlreadyExists) {
1038
+ logger.info(`Skipping GitHub Actions workflow: ${toWorkflowPath(relative(repoRoot, githubActionPath))} already exists`);
898
1039
  }
1040
+ const shouldWriteGithubActionWorkflow = shouldAddGithubActionWorkflow && !workflowAlreadyExists;
899
1041
  const skills = [];
900
1042
  if (shouldInstallScreenCISkill) {
901
1043
  skills.push('screenci');
@@ -908,61 +1050,99 @@ export async function runInit(projectNameArg, options) {
908
1050
  ? null
909
1051
  : `${commands.skillsCommand} ${skillsArgs.join(' ')}`;
910
1052
  const screenciDependency = getInitScreenciDependencyOverride() ?? (await readCurrentScreenciVersion());
911
- const packageJsonPath = resolve(projectDir, 'package.json');
912
- const hasExistingPackageJson = existsSync(packageJsonPath);
913
- await mkdir(resolve(projectDir, 'videos'), { recursive: true });
914
- if (shouldAddGithubActionWorkflow) {
915
- await mkdir(githubWorkflowsDir, { recursive: true });
916
- }
917
- await writeFile(resolve(projectDir, 'screenci.config.ts'), generateConfig(projectName));
918
- if (!hasExistingPackageJson) {
919
- await writeFile(packageJsonPath, generateEmptyPackageJson());
920
- }
921
- else {
922
- await ensurePackageJsonTypeModule(packageJsonPath);
923
- }
924
- await writeInitGitignore(projectDir, packageManager);
925
- await writeFile(resolve(projectDir, 'videos', 'example.video.ts'), generateExampleVideo());
926
- if (shouldAddGithubActionWorkflow) {
927
- await writeFile(githubActionPath, generateGithubAction(packageManager, isWorkspace));
928
- }
929
- if (packageManager === 'pnpm') {
930
- await ensureSupportedPnpmVersion(projectDir);
931
- }
932
- if (packageManager === 'yarn') {
933
- await writeFile(resolve(projectDir, '.yarnrc.yml'), 'nodeLinker: node-modules\n');
934
- }
935
- if (skillsArgs !== null) {
936
- if (verbose) {
937
- logger.info(`Running '${skillsCommand}'...`);
938
- await spawnInherited(commands.skillsCommand, skillsArgs, projectDir, 'screenci init');
1053
+ // Everything below creates files / runs installs. If anything fails (or the
1054
+ // user interrupts), roll back the `screenci/` directory we created so the
1055
+ // next `init` run starts from a clean slate. Pre-existing repo-root files
1056
+ // (e.g. .github/, .claude/) are left untouched.
1057
+ let islandCreated = false;
1058
+ let scaffoldComplete = false;
1059
+ const removePartialIsland = () => {
1060
+ if (!islandCreated || scaffoldComplete)
1061
+ return;
1062
+ try {
1063
+ rmSync(islandDir, { recursive: true, force: true });
939
1064
  }
940
- else {
941
- const spinner = ora('Adding selected AI skills...').start();
942
- try {
943
- await spawnSilent(commands.skillsCommand, skillsArgs, projectDir);
944
- spinner.succeed('Installing selected AI skills');
1065
+ catch {
1066
+ // best-effort cleanup
1067
+ }
1068
+ };
1069
+ const onSigint = () => {
1070
+ removePartialIsland();
1071
+ process.exit(130);
1072
+ };
1073
+ const onSigterm = () => {
1074
+ removePartialIsland();
1075
+ process.exit(143);
1076
+ };
1077
+ process.on('SIGINT', onSigint);
1078
+ process.on('SIGTERM', onSigterm);
1079
+ process.on('exit', removePartialIsland);
1080
+ try {
1081
+ await mkdir(resolve(islandDir, 'videos'), { recursive: true });
1082
+ islandCreated = true;
1083
+ await writeFile(resolve(islandDir, 'screenci.config.ts'), generateConfig(projectName));
1084
+ await writeFile(resolve(islandDir, 'package.json'), generateIslandPackageJson(projectName));
1085
+ await writeFile(resolve(islandDir, 'tsconfig.json'), generateIslandTsconfig());
1086
+ await writeFile(resolve(islandDir, 'README.md'), generateIslandReadme(projectName, packageManager));
1087
+ await writeInitGitignore(islandDir, packageManager);
1088
+ await writeFile(resolve(islandDir, 'videos', 'example.video.ts'), generateExampleVideo());
1089
+ if (packageManager === 'pnpm') {
1090
+ // Resolve (and gate on) the pnpm version before writing the workspace
1091
+ // file so the build-approval key matches the installed pnpm.
1092
+ const pnpmVersionSupport = await ensureSupportedPnpmVersion(islandDir);
1093
+ await writeFile(resolve(islandDir, 'pnpm-workspace.yaml'), generatePnpmWorkspaceYaml(pnpmMajorFromSupport(pnpmVersionSupport)));
1094
+ }
1095
+ if (packageManager === 'yarn') {
1096
+ await writeFile(resolve(islandDir, '.yarnrc.yml'), 'nodeLinker: node-modules\n');
1097
+ }
1098
+ if (shouldWriteGithubActionWorkflow) {
1099
+ await mkdir(githubWorkflowsDir, { recursive: true });
1100
+ await writeFile(githubActionPath, generateGithubAction(packageManager, islandWorkflowPath));
1101
+ }
1102
+ // Install skills at the repo root so coding agents discover them when the
1103
+ // repository is opened as the workspace.
1104
+ if (skillsArgs !== null) {
1105
+ if (verbose) {
1106
+ logger.info(`Running '${skillsCommand}'...`);
1107
+ await spawnInherited(commands.skillsCommand, skillsArgs, repoRoot, 'screenci init');
945
1108
  }
946
- catch (err) {
947
- spinner.fail('AI skills install failed');
948
- throw err;
1109
+ else {
1110
+ const spinner = ora('Adding selected AI skills...').start();
1111
+ try {
1112
+ await spawnSilent(commands.skillsCommand, skillsArgs, repoRoot);
1113
+ spinner.succeed('Installing selected AI skills');
1114
+ }
1115
+ catch (err) {
1116
+ spinner.fail('AI skills install failed');
1117
+ throw err;
1118
+ }
949
1119
  }
950
1120
  }
1121
+ await installInitDependencies(islandDir, verbose, screenciDependency, shouldInstallPlaywrightCli, commands);
1122
+ if (shouldInstallPlaywrightBrowsers) {
1123
+ logger.info(`Installing Playwright Chromium headless shell with '${commands.playwrightRun} install --only-shell chromium'...`);
1124
+ const [browserCmd, ...browserArgs] = buildPlaywrightSpawnArgs(packageManager, 'install', '--only-shell', 'chromium');
1125
+ await spawnInherited(browserCmd, browserArgs, islandDir, 'screenci init');
1126
+ logger.info(`${pc.green('✔')} Playwright Chromium headless shell installed successfully`);
1127
+ }
1128
+ if (shouldInstallPlaywrightOsDependencies) {
1129
+ logger.info(`Installing Playwright operating system dependencies with '${commands.playwrightRun} install-deps chromium'...`);
1130
+ const [depsCmd, ...depsArgs] = buildPlaywrightSpawnArgs(packageManager, 'install-deps', 'chromium');
1131
+ await spawnInherited(depsCmd, depsArgs, islandDir, 'screenci init');
1132
+ logger.info(`${pc.green('✔')} Playwright operating system dependencies installed successfully`);
1133
+ }
1134
+ scaffoldComplete = true;
951
1135
  }
952
- await installInitDependencies(projectDir, verbose, screenciDependency, shouldInstallPlaywrightCli, commands);
953
- if (shouldInstallPlaywrightBrowsers) {
954
- logger.info(`Installing Playwright Chromium headless shell with '${commands.playwrightRun} install --only-shell chromium'...`);
955
- const [browserCmd, ...browserArgs] = buildPlaywrightSpawnArgs(packageManager, 'install', '--only-shell', 'chromium');
956
- await spawnInherited(browserCmd, browserArgs, projectDir, 'screenci init');
957
- logger.info(`${pc.green('✔')} Playwright Chromium headless shell installed successfully`);
1136
+ catch (err) {
1137
+ removePartialIsland();
1138
+ throw err;
958
1139
  }
959
- if (shouldInstallPlaywrightOsDependencies) {
960
- logger.info(`Installing Playwright operating system dependencies with '${commands.playwrightRun} install-deps chromium'...`);
961
- const [depsCmd, ...depsArgs] = buildPlaywrightSpawnArgs(packageManager, 'install-deps', 'chromium');
962
- await spawnInherited(depsCmd, depsArgs, projectDir, 'screenci init');
963
- logger.info(`${pc.green('✔')} Playwright operating system dependencies installed successfully`);
1140
+ finally {
1141
+ process.off('SIGINT', onSigint);
1142
+ process.off('SIGTERM', onSigterm);
1143
+ process.off('exit', removePartialIsland);
964
1144
  }
965
- printInitNextSteps(projectDir, packageManager);
1145
+ printInitNextSteps(islandDir, islandDirName, packageManager);
966
1146
  }
967
1147
  function handleCreateCommanderError(err) {
968
1148
  if (!(err instanceof CommanderError)) {