Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ MIT licensed — fork it, build on it, ship your own version.
- **Git & GitHub** — the branch chip in the title bar shows the current branch and dirty state. From its dropdown you can switch branches, create branches, commit, push, or publish a brand-new repo to GitHub (via the `gh` CLI).
- **Tell an AI what's selected** — **⇧⌘C** copies whatever the canvas has selected as the trail of `file:line-range` pointers that leads to it: the page, each component drilled into on the way down, then the node itself. Paste that into Claude Code or any AI chat that can read the project and it knows exactly which markup you mean.
- **Code fallback** — pages with markup too complex for the visual model open in a code editor instead, still with live preview.
- **New project** — "New Project…" scaffolds a minimal Astro starter (layout + 5 components + home page) and runs `npm install` for you.
- **New project** — "Start from Lumos" or "Start from Astro" creates a named site with the framework's own scaffolder and installs its dependencies for you.

## Running in development

Expand Down
2 changes: 1 addition & 1 deletion docs/codebase.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ lets you edit its pages visually:
assistant so it knows exactly which markup is meant. Making the project
legible to AI tools is a first-class goal of the design.
- Pages too complex for the visual model fall back to a code editor with live
preview; "New Project…" scaffolds a minimal Astro starter.
preview; "Start from Lumos" and "Start from Astro" create a new site.

The product constraint that shapes everything below: Stacki edits files that
are _also_ edited by hand and by AI assistants in real editors. The source of
Expand Down
157 changes: 89 additions & 68 deletions electron/handlers/projectHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
// The project IPC: opening and creating projects (from a dialog, the Astro
// template, a starter or a scaffold), and installing their dependencies.
// template or a starter), and installing their dependencies.

import { gitErrorDetail } from '../git/git';
import { toRecord } from '../../shared/core/record';
import { assert } from '../../shared/core/assert';
import * as path from 'path';
import * as fs from 'fs';
import { spawn } from 'child_process';
import { scaffoldProject } from '../project/scaffold';
import { createStarter, newSiteDirectory } from '../project/starter';
import { isWin, errorMessage } from '../lib/mainHelpers';
import * as os from 'os';
import {
createStarter,
newSiteDirectory,
runCreateCommand,
startSiteHistory,
STARTERS,
} from '../project/starter';
import { isWin } from '../lib/mainHelpers';
import { ensureToolPath, run } from '../lib/nodeTools';
import { isAstroProject } from '../project/projectFiles';
import { detectPackageManager, installArguments } from '../project/packageManager';
import { packageManagerLabel, type PackageManager } from '../../shared/properties/packageManager';
import type { MainHost } from './mainHost';

export function registerProjectHandlers(
host: Pick<MainHost, 'ipcMain' | 'send' | 'showOpenDialog'>,
): void {
registerProjectOpenDialogHandlers(host);
registerProjectCreateAstroHandlers(host);
registerProjectCreateStarterHandlers(host);
}

function registerProjectOpenDialogHandlers({
Expand Down Expand Up @@ -67,9 +74,13 @@ function registerProjectCreateAstroHandlers({
install = true,
git = true,
ai = false,
packageManager = 'npm',
} = options || {};
const directory = newSiteDirectory(parentPath, name);
ensureToolPath(); // npm is a Node shim — same PATH problem as astro
if (install) {
await requirePackageManager(packageManager);
}
fs.mkdirSync(directory);

const args = [
Expand All @@ -79,15 +90,21 @@ function registerProjectCreateAstroHandlers({
'--',
'--template',
template,
install ? '--install' : '--no-install',
git ? '--git' : '--no-git',
'--no-install', // Stacki installs, then makes the first commit (finishNewSite).
'--no-git',
...(ai ? [] : ['--no-ai']),
'--skip-houston',
'--yes', // accept defaults for anything not covered above
];

send('create:log', `> npm ${args.join(' ')}\n\n`);
await runCreateAstroInNewFolder({ send }, directory, args);
await finishNewSite({ send }, directory, {
install,
manager: packageManager,
history: git,
label: 'Astro',
});
return { ok: true as const, installed: install, projectPath: directory };
});

Expand All @@ -103,37 +120,44 @@ function registerProjectCreateAstroHandlers({
}
return { canceled: false as const, parentPath: result.filePaths[0] };
});
}

function registerProjectCreateStarterHandlers({
ipcMain,
send,
}: Pick<MainHost, 'ipcMain' | 'send'>): void {
// Starting from a starter. The scaffolder, the first commit and the package's
// name are in ./starter.js; what is here is the install that follows and the
// log the wizard reads.
ipcMain.handle(
'project:createStarter',
async (_event, { starter = 'lumos', parentPath, name }) => {
async (_event, { starter = 'lumos', parentPath, name, packageManager = 'npm' }) => {
ensureToolPath(); // npm and git are both on the PATH the Dock does not have
await requirePackageManager(packageManager);
const result = await createStarter({
starter,
parentPath,
name,
onLog: (text) => send('create:log', text),
});
send('create:log', '\n> installing dependencies\n');
await installDependencies({ send }, result.projectPath);
send('progress', PROGRESS_CLEARED);
const label = STARTERS[starter]?.label ?? starter;
await finishNewSite({ send }, result.projectPath, {
install: true,
manager: packageManager,
history: true,
label,
});
return result;
},
);

ipcMain.handle('project:scaffold', async (_event, { dir: directory, name }) => {
scaffoldProject(directory, name);
await installDependencies({ send }, directory);
return { ok: true as const };
});
}

export async function installDependencies({ send }: Pick<MainHost, 'send'>, directory: string) {
export async function installDependencies(
{ send }: Pick<MainHost, 'send'>,
directory: string,
pm: PackageManager = detectPackageManager(directory),
) {
ensureToolPath(); // npm/pnpm/yarn are Node shims — same PATH problem as astro
const pm = detectPackageManager(directory);
send('progress', { message: `Installing dependencies (${pm} install)…` });
try {
await run(isWin ? `${pm}.cmd` : pm, [...installArguments(pm)], directory, {
Expand All @@ -150,6 +174,46 @@ export async function installDependencies({ send }: Pick<MainHost, 'send'>, dire
}
}

// A new site is only started when its manager can install it, so a missing one fails before
// any folder exists. On Windows a missing command fails without ENOENT, so any failure counts.
async function requirePackageManager(manager: PackageManager): Promise<void> {
try {
await run(isWin ? `${manager}.cmd` : manager, ['--version'], os.homedir());
} catch {
throw new Error(`${packageManagerLabel(manager)} is not installed. Install it or choose npm.`);
}
}

// The install comes first, so the site's first commit includes the lockfile. The site gets
// its history even when the install fails; the install error still reaches the wizard.
async function finishNewSite(
{ send }: Pick<MainHost, 'send'>,
directory: string,
options: {
readonly install: boolean;
readonly manager: PackageManager;
readonly history: boolean;
readonly label: string;
},
): Promise<void> {
const onLog = (text: string): void => send('create:log', text);
try {
if (options.install) {
// A starter's npm lockfile would sit beside the chosen manager's own and misname it.
if (options.manager !== 'npm') {
fs.rmSync(path.join(directory, 'package-lock.json'), { force: true });
}
onLog(`\n> installing dependencies with ${packageManagerLabel(options.manager)}\n`);
await installDependencies({ send }, directory, options.manager);
}
} finally {
if (options.history) {
await startSiteHistory(directory, options.label, onLog);
}
}
send('progress', PROGRESS_CLEARED);
}

// Clears the progress line: no message (src/ipc/appBridge.ts, onAppProgress). IPC is
// a structured clone, which keeps the absent value.
export const PROGRESS_CLEARED = { message: undefined } as const;
Expand All @@ -174,57 +238,14 @@ async function runCreateAstroInNewFolder(

// The create-astro run itself, its output streamed to the wizard's log. It
// settles once the CLI exits and a package.json has landed in `directory`.
function runCreateAstro(
async function runCreateAstro(
{ send }: Pick<MainHost, 'send'>,
directory: string,
args: readonly string[],
): Promise<void> {
return new Promise((resolve, reject) => {
let proc;
try {
proc = spawn(isWin ? 'npm.cmd' : 'npm', [...args], {
cwd: directory,
shell: isWin,
env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1', CI: '1' },
});
} catch (error: unknown) {
reject(new Error(`Could not run npm: ${errorMessage(error)}`));
return;
}

let tail = '';
const onOut = (chunk: Buffer) => {
// create-astro animates with cursor moves and line clears; strip the
// escape codes so the log pane reads as plain text.
const text = chunk
.toString()
.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
.replace(/\r/g, '\n');
tail = (tail + text).slice(-4000);
send('create:log', text);
};
proc.stdout.on('data', onOut);
proc.stderr.on('data', onOut);
proc.on('error', (error) => {
reject(
new Error(
toRecord(error)?.['code'] === 'ENOENT'
? 'npm could not be found. Install Node.js (which includes npm) and try again.'
: `Could not run npm: ${errorMessage(error)}`,
),
);
});
proc.on('exit', (code) => {
if (code === 0) {
// Sanity-check that a project actually landed.
if (!fs.existsSync(path.join(directory, 'package.json'))) {
reject(new Error(`create-astro finished but no package.json appeared.\n\n${tail}`));
return;
}
resolve();
} else {
reject(new Error(`create-astro exited with code ${code}.\n\n${tail}`));
}
});
});
const npm = isWin ? 'npm.cmd' : 'npm';
await runCreateCommand(npm, args, directory, (text) => send('create:log', text));
if (!fs.existsSync(path.join(directory, 'package.json'))) {
throw new Error('create-astro finished but no package.json appeared.');
}
}
1 change: 0 additions & 1 deletion electron/preload/preloadBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ function projectBridge() {

// Project:
openProjectDialog: invoke('project:openDialog'),
scaffoldProject: invoke('project:scaffold'),
createAstroProject: invoke('project:createAstro'),
parentDialog: invoke('project:parentDialog'),
createStarter: invoke('project:createStarter'),
Expand Down
Loading
Loading