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
25 changes: 25 additions & 0 deletions .changeset/23858-uncreatable-directory-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@objectstack/cli': patch
'@objectstack/runtime': patch
---

fix(cli,runtime): a state, output or cache directory that can never be made is refused at once, naming the path and the operating system's code, instead of hanging

Clause-②: no

**What was wrong.** These commands and runtime paths created their directories with Node's recursive `mkdir`. Node's recursive mkdir never returns when a level answers `ENOENT` although its parent exists — what `/proc` and `/sys` answer for a name they do not have. It looped `mkdir("/proc/x")` and `mkdir("/proc")` without end, holding the event loop, so the command printed nothing more and no timeout could fire:

- `os start` with `--home` / `OS_HOME` on such a path hung after its header.
- `os serve` hung the same way on the empty-boot artifact stub under `OS_HOME`, and on the cache of an `http(s):` `OS_ARTIFACT_URL` under `OS_HOME`.
- `os create --dir`, `os init <name>`, `os compile --output`, `os generate` (`--dir`, and `--output` for types, client and migration), `os i18n extract --out` and `os plugin build --out` hung on an output path there.
- `os login` and `os cloud login` never finished writing credentials under a `HOME` there, and the package enable / disable state the packages routes write under `OS_HOME` held the event loop of the host serving them.

**What an operator sees now.** The command exits non-zero at once with the operating system's own error, which names the level that cannot be made:

```text
✗ Cannot create home directory at /proc/os-x: ENOENT: no such file or directory, mkdir '/proc/os-x'
```

Not covered yet: the runtime state file `os serve` writes into `OS_HOME` once it is listening.

Nothing else changes. Every directory these paths made before is still made, every missing level of it; every path that was refused before is still refused, with the operating system's code. The one difference is the path that was never answered at all.
5 changes: 4 additions & 1 deletion packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { loadConfig, namedExportRejectionHints } from '../utils/config.js';
import { refuseUnbuiltStack } from '../utils/stack-provenance-refusal.js';
import { isExtractionRefusal, lowerCallables } from '../utils/lower-callables.js';
import { authoringRuleUnionStack } from '../utils/stack-collections.js';
import { makeDirectorySync } from '../utils/make-directory.js';
import { artifactPackages, runPerPackageAuthoringRules } from '../utils/artifact-packages.js';
import { stackFilterJudge } from '../utils/authoring-filter-judge.js';
import { buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
Expand Down Expand Up @@ -989,7 +990,9 @@ export default class Compile extends Command {
const artifactDir = path.dirname(artifactPath);

if (!fs.existsSync(artifactDir)) {
fs.mkdirSync(artifactDir, { recursive: true });
// Bounded (#23858): an `--output` directory that can never be made is
// refused at once with the OS's code and the path, never a loop.
makeDirectorySync(artifactDir);
}

const finalBundle: Record<string, unknown> = { ...(result.data as Record<string, unknown>) };
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ import { Args, Command, Flags } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import { makeDirectorySync } from '../utils/make-directory.js';
import {
getCliVersion,
NPM_PACKAGE_NAME_MAX_LENGTH,
Expand Down Expand Up @@ -726,8 +727,9 @@ export default class Create extends Command {
console.log('');

try {
// Create directory
fs.mkdirSync(targetDir, { recursive: true });
// Create directory. Bounded (#23858): a `--dir` that can never be made
// is refused at once with the OS's code and the path, never a loop.
makeDirectorySync(targetDir);

// Create files from template
for (const [filePath, contentFn] of Object.entries(template.filesFor(placement))) {
Expand Down
15 changes: 9 additions & 6 deletions packages/cli/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import chalk from 'chalk';
import { createHash } from 'node:crypto';
import fs from 'fs';
import path from 'path';
import { makeDirectorySync } from '../utils/make-directory.js';

// [#16319] `FieldType` is now imported as a VALUE below, beside the other
// `@objectstack/spec/data` helpers — the enum object carries the type of the
Expand Down Expand Up @@ -1800,9 +1801,11 @@ async function runMetadataGeneration(
// never prints a `Created` line for a file that is no longer there.
const written: string[] = [];
try {
// `mkdirSync` answers the first directory it created, or `undefined`
// when the whole path already existed.
createdDir = fs.mkdirSync(fullDir, { recursive: true }) ?? undefined;
// `makeDirectorySync` answers the first directory it created, or
// `undefined` when the whole path already existed — what the recursive
// `mkdirSync` it replaces answered, bounded (#23858): a `--dir` that can
// never be made is refused at once instead of looping.
createdDir = makeDirectorySync(fullDir);

// Write file — the same `content` the parse check above accepted, ⛔ not
// a re-render: a second call to `generator.generate` would make the
Expand Down Expand Up @@ -2224,7 +2227,7 @@ async function runTypesGeneration(configPath: string | undefined, flags: { outpu
const outPath = path.resolve(process.cwd(), flags.output);
const outDir = path.dirname(outPath);
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
makeDirectorySync(outDir);
}
fs.writeFileSync(outPath, content);
printSuccess(`Generated types at ${flags.output}`);
Expand Down Expand Up @@ -2371,7 +2374,7 @@ async function runClientGeneration(configPath: string | undefined, flags: { outp
const outPath = path.resolve(process.cwd(), flags.output);
const outDir = path.dirname(outPath);
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
makeDirectorySync(outDir);
}
fs.writeFileSync(outPath, content);
printSuccess(`Generated client SDK at ${flags.output} (${timer.display()})`);
Expand Down Expand Up @@ -4003,7 +4006,7 @@ async function runMigrationGeneration(configPath: string | undefined, flags: { o
const outPath = path.resolve(process.cwd(), output);
const outDir = path.dirname(outPath);
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
makeDirectorySync(outDir);
}
fs.writeFileSync(outPath, content);
printSuccess(`Generated migration at ${output} (${timer.display()})`);
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/commands/i18n/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Args, Command, Flags } from '@oclif/core';
import chalk from 'chalk';
import { normalizeStackInput } from '@objectstack/spec';
import { loadConfig } from '../../utils/config.js';
import { makeDirectorySync } from '../../utils/make-directory.js';
import {
printHeader,
printSuccess,
Expand Down Expand Up @@ -796,7 +797,9 @@ export default class I18nExtract extends Command {
process.exit(1);
}

fs.mkdirSync(resolvedOutDir, { recursive: true });
// Bounded (#23858): an `--out` that can never be made is refused at
// once with the OS's code and the path, never a loop.
makeDirectorySync(resolvedOutDir);
let written = 0;
for (const { file, content, keys } of emitted) {
fs.writeFileSync(file, content, 'utf8');
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
isExitSignal,
} from '../utils/format.js';
import { validateScaffold } from '../utils/scaffold-validate.js';
import { makeDirectorySync } from '../utils/make-directory.js';
import { summarizeTree, describeEntry } from 'create-objectstack/created-summary';
import { GENERATOR_SCAFFOLD_TARGETS } from './generate.js';

Expand Down Expand Up @@ -1262,7 +1263,10 @@ export default class Init extends Command {
this.exit(2);
}
} else {
fs.mkdirSync(targetDir, { recursive: true });
// Bounded (#23858): a target that can never be made — this command
// run inside `/proc` — is refused at once with the OS's code and the
// path. Node's recursive mkdir looped on it, printing nothing.
makeDirectorySync(targetDir);
}
} else {
targetDir = startCwd;
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/commands/plugin/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
* unsigned artifact whose `SIGNATURE` is a placeholder.
*/

import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
import { readFile, readdir, stat, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, relative, resolve as resolvePath, sep as PATH_SEP } from 'node:path';
import { Args, Command, Flags } from '@oclif/core';
Expand All @@ -43,6 +43,7 @@ import {
createTarGz,
sha256Hex,
} from '../../utils/osplugin.js';
import { makeDirectorySync } from '../../utils/make-directory.js';

const ENTRY_CANDIDATES = ['src/index.ts', 'src/index.tsx', 'src/index.mjs', 'src/index.js'];

Expand Down Expand Up @@ -218,7 +219,10 @@ export default class PluginBuild extends Command {
// 4. Pack the artifact. ─────────────────────────────────────────────
const blob = createTarGz(files);
const outPath = resolvePath(cwd, flags.out ?? `${id}-${version}${OSPLUGIN_EXT}`);
await mkdir(resolvePath(outPath, '..'), { recursive: true });
// Bounded (#23858): an `--out` directory that can never be made is refused
// at once with the OS's code and the path. Node's recursive mkdir, async
// as here too, never settled on it.
makeDirectorySync(resolvePath(outPath, '..'));
await writeFile(outPath, blob);

printSuccess('Plugin built');
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { printHeader, printKV, printStep, printError } from '../utils/format.js';
import { makeDirectorySync } from '../utils/make-directory.js';
import { redirectStdoutToStderr } from '../utils/json-stdout.js';
import { redactConnectionUrl } from '../utils/connection-display.js';
import { databaseDriverFlag } from '../utils/database-driver-flag.js';
Expand Down Expand Up @@ -186,7 +187,10 @@ export default class Start extends Command {
// > ~/.objectstack (global mode)
const homeDir = resolveHome(flags.home, { hasProjectConfig, cwd });
try {
fs.mkdirSync(path.join(homeDir, 'data'), { recursive: true });
// Bounded (#23858): a home that can never be made — a name under
// `/proc` — is refused here, at once, with the OS's code and the path.
// Node's recursive mkdir looped on it in this process, printing nothing.
makeDirectorySync(path.join(homeDir, 'data'));
} catch (err: any) {
printError(`Cannot create home directory at ${homeDir}: ${err?.message ?? err}`);
process.exit(1);
Expand Down Expand Up @@ -655,7 +659,7 @@ function readOrCreateAuthSecret(homeDir: string): string {
}
const secret = crypto.randomBytes(32).toString('hex');
try {
fs.mkdirSync(homeDir, { recursive: true });
makeDirectorySync(homeDir);
fs.writeFileSync(secretPath, secret + '\n', { mode: 0o600 });
} catch {
// best-effort persist; secret is still returned for this process
Expand Down
9 changes: 6 additions & 3 deletions packages/cli/src/utils/auth-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import { homedir } from 'node:os';
import { join } from 'node:path';
import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
import { readFile, writeFile, chmod } from 'node:fs/promises';
import { makeDirectorySync } from './make-directory.js';

/**
* Authentication configuration stored in ~/.objectstack/credentials.json
Expand Down Expand Up @@ -70,8 +71,10 @@ export async function writeAuthConfig(config: AuthConfig): Promise<void> {
const path = getCredentialsPath();
const dir = join(homedir(), '.objectstack');

// Ensure directory exists
await mkdir(dir, { recursive: true });
// Ensure directory exists. Bounded (#23858): a `HOME` under which it can
// never be made is refused at once with the OS's code and the path; Node's
// recursive mkdir never settled on it.
makeDirectorySync(dir);

// Write credentials file
await writeFile(path, JSON.stringify(config, null, 2), { mode: 0o600 });
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/utils/cloud-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@
* family this file's token goes only to this file's `url`.
*/

import { chmod, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import { chmod, readFile, unlink, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { makeDirectorySync } from './make-directory.js';

/** Canonical hosted control plane. */
export const DEFAULT_CLOUD_URL = 'https://cloud.objectos.ai';
Expand Down Expand Up @@ -91,7 +92,8 @@ export async function writeCloudConfig(config: CloudConfig): Promise<void> {
const path = getCloudCredentialsPath();
const dir = join(homedir(), '.objectstack');

await mkdir(dir, { recursive: true });
// Bounded (#23858): see `writeAuthConfig`.
makeDirectorySync(dir);
await writeFile(path, JSON.stringify(config, null, 2), { mode: 0o600 });
try {
await chmod(path, 0o600);
Expand Down
Loading
Loading