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
15 changes: 9 additions & 6 deletions docs/contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -702,12 +702,15 @@ smaller patches retain binary/rename metadata and validated hunk line numbers fo

## Project publishing

`publish:account` reports `installing` while Cloudflare sign-in installs a missing project Wrangler
with `npm install --save-dev wrangler`. Only an explicit sign-in start can trigger installation;
discovery and status polling cannot. The same session continues to browser sign-in after a successful
install. Cancellation, a ten-minute installation timeout, or excessive output stops the process tree
and prevents login. Installer output is discarded, and failures expose only sanitized messages.
Login retains its separate five-minute timeout. Existing project/workspace dependencies skip setup.
`publish:account` reports `installing` while Cloudflare sign-in adds a missing project Wrangler as
a development dependency with the site's package manager (package.json's `packageManager` field,
else its lockfile, else npm). Only an explicit sign-in start can trigger installation; discovery
and status polling cannot. The same session continues to browser sign-in after a successful
install. Cancelling skips login but lets the package manager finish, so node_modules is never left
half written; another sign-in is refused until it does. A ten-minute installation timeout or
excessive output stops the process tree and prevents login. Installer output is discarded, and
failures expose only sanitized messages. Login retains its separate five-minute timeout. Existing
project/workspace dependencies skip setup.

`.stacki/component-sources.json` registers bounded, read-only installed component roots for both
hosts. `shared/properties/componentSources.ts` parses package names, relative directories and
Expand Down
10 changes: 5 additions & 5 deletions docs/publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,11 @@ to Studio or committed to the project.

Cloudflare Pages needs an existing Pages project, account ID, production branch, and build
output directory (usually `dist`). Choosing **Add account** for Cloudflare in Desktop or local
Studio installs a missing project Wrangler with `npm install --save-dev wrangler`, then starts
browser sign-in automatically. The dialog shows installation progress and Cancel stops the
installer. This updates the site's package manifest and lockfile so future deployments can use
the same dependency. Existing project or workspace installations are reused. Installation failures
can be retried; account discovery and deployment never install packages implicitly.
Studio installs a missing project Wrangler with the site's package manager, then starts browser
sign-in automatically. This updates the site's package manifest and lockfile so future
deployments can use the same dependency. Cancel during installation skips sign-in but lets the
installation finish. Existing project or workspace installations are reused. Installation
failures can be retried; account discovery and deployment never install packages implicitly.
Alternatively, install Wrangler and run `wrangler login` once locally, or provide
`CLOUDFLARE_API_TOKEN` in the server environment. The account ID is explicitly supplied on every
deployment, so another project's account choice does not change this project's target.
Expand Down
24 changes: 4 additions & 20 deletions electron/handlers/projectHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ import { scaffoldProject } from '../project/scaffold';
import { createStarter, newSiteDirectory } from '../project/starter';
import { isWin, errorMessage } from '../lib/mainHelpers';
import { ensureToolPath, run } from '../lib/nodeTools';
import { isAstroProject, detectPackageManager } from '../project/projectFiles';
import { isAstroProject } from '../project/projectFiles';
import { detectPackageManager, installArguments } from '../project/packageManager';
import type { MainHost } from './mainHost';

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

function registerProjectOpenDialogHandlers({
Expand Down Expand Up @@ -131,35 +131,19 @@ function registerProjectCreateAstroHandlers({
});
}

function registerProjectHasNodeModulesHandlers({
ipcMain,
send,
}: Pick<MainHost, 'ipcMain' | 'send'>): void {
ipcMain.handle('project:hasNodeModules', async (_event, projectPath) => {
return fs.existsSync(path.join(projectPath, 'node_modules'));
});

ipcMain.handle('project:install', async (_event, projectPath) => {
await installDependencies({ send }, projectPath);
return { ok: true as const };
});
}

export async function installDependencies({ send }: Pick<MainHost, 'send'>, directory: string) {
ensureToolPath(); // npm/pnpm/yarn are Node shims — same PATH problem as astro
const pm = detectPackageManager(directory);
send('progress', { message: `Installing dependencies (${pm} install)…` });
const args = pm === 'npm' ? ['install', '--no-audit', '--no-fund'] : ['install'];
try {
await run(isWin ? `${pm}.cmd` : pm, args, directory, {
await run(isWin ? `${pm}.cmd` : pm, [...installArguments(pm)], directory, {
timeout: 10 * 60 * 1000,
shell: isWin,
});
} catch (error: unknown) {
if (toRecord(error)?.['code'] === 'ENOENT') {
throw new Error(
`This project uses ${pm} (found its lockfile), but ${pm} is not installed. ` +
'Install it and try again.',
`This project uses ${pm}, but ${pm} is not installed. Install it and try again.`,
);
}
throw new Error(`${pm} install failed: ${gitErrorDetail(error).slice(-400)}`);
Expand Down
2 changes: 0 additions & 2 deletions electron/preload/preloadBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,6 @@ function projectBridge() {
createAstroProject: invoke('project:createAstro'),
parentDialog: invoke('project:parentDialog'),
createStarter: invoke('project:createStarter'),
hasNodeModules: invoke('project:hasNodeModules'),
installDeps: invoke('project:install'),
scanProject: invoke('project:scan'),
listProjectClasses: invoke('project:classes'),
watchProject: invoke('watch:start'),
Expand Down
66 changes: 66 additions & 0 deletions electron/project/packageManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Which package manager a project uses. Kept apart from projectFiles so Studio's
// publishing services can share it without the project scan.

import * as fs from 'fs';
import * as path from 'path';
import { readJson, parseRecord } from '../lib/mainHelpers';
import {
parsePackageManagerField,
type PackageManager,
} from '../../shared/properties/packageManager';

// By default `pnpm` blocks dependency build scripts and, since v11, exits with an error when it
// does, though everything else is installed. Keep them blocked; just don't fail the install.
export const PNPM_TOLERATE_IGNORED_BUILDS = '--config.strict-dep-builds=false';

// Installs a project's dependencies with its own manager.
export function installArguments(manager: PackageManager): readonly string[] {
switch (manager) {
case 'npm':
return ['install', '--no-audit', '--no-fund'];
case 'pnpm':
return ['install', PNPM_TOLERATE_IGNORED_BUILDS];
case 'yarn':
case 'bun':
return ['install'];
default: {
const exhaustive: never = manager;
return exhaustive;
}
}
}

// The packageManager field in package.json names it outright; otherwise the
// lockfile shows which one last installed; else npm.
export function detectPackageManager(directory: string): PackageManager {
const declared = declaredPackageManager(directory);
if (declared !== undefined) {
return declared;
}
if (fs.existsSync(path.join(directory, 'pnpm-lock.yaml'))) {
return 'pnpm';
}
if (fs.existsSync(path.join(directory, 'yarn.lock'))) {
return 'yarn';
}
if (
fs.existsSync(path.join(directory, 'bun.lockb')) ||
fs.existsSync(path.join(directory, 'bun.lock'))
) {
return 'bun';
}
return 'npm';
}

function declaredPackageManager(directory: string): PackageManager | undefined {
const manifest = path.join(directory, 'package.json');
if (!fs.existsSync(manifest)) {
return undefined;
}
try {
return parsePackageManagerField(parseRecord(readJson(manifest))['packageManager']);
} catch {
// An unreadable package.json falls back to the lockfile; the install reports it.
return undefined;
}
}
20 changes: 1 addition & 19 deletions electron/project/projectFiles.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// What a folder holds as an Astro project: its page, layout and component
// files, the route a page serves, and whether its dependencies are installed
// and with which package manager.
// files, the route a page serves, and whether its dependencies are installed.

import { MAIN_LIMITS, directoryBudget } from '../lib/mainLimits';
import { toRecord } from '../../shared/core/record';
Expand Down Expand Up @@ -75,20 +74,3 @@ export function hasDependencies(projectPath: string) {
const binName = isWin ? 'astro.cmd' : 'astro';
return fs.existsSync(path.join(projectPath, 'node_modules', '.bin', binName));
}

// Detects the project's package manager from its lockfile.
export function detectPackageManager(directory: string) {
if (fs.existsSync(path.join(directory, 'pnpm-lock.yaml'))) {
return 'pnpm';
}
if (fs.existsSync(path.join(directory, 'yarn.lock'))) {
return 'yarn';
}
if (
fs.existsSync(path.join(directory, 'bun.lockb')) ||
fs.existsSync(path.join(directory, 'bun.lock'))
) {
return 'bun';
}
return 'npm';
}
Loading
Loading