Skip to content
Closed
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
62 changes: 61 additions & 1 deletion packages/cli/src/commands/ship.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, it, expect } from 'vitest';
import { shipCmd } from './ship.js';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { readTargetSummary, shipCmd } from './ship.js';

describe('shipCmd', () => {
it('is registered as a top-level command named "ship"', () => {
Expand Down Expand Up @@ -34,4 +37,61 @@ describe('shipCmd', () => {
expect(optNames).toContain('--dry-run');
expect(optNames).toContain('--skip-lint');
});

it('ignores targets examples inside comments when reading configured targets', () => {
const cwd = mkdtempSync(join(tmpdir(), 'sh1pt-targets-'));
writeFileSync(
join(cwd, 'sh1pt.config.ts'),
`
// Example only: targets: { fake: { use: 'wrong' } }
export default {
targets: {
web: { use: 'next', enabled: false }
}
};
`,
);

expect(readTargetSummary(cwd, 'sh1pt.config.ts')).toEqual([
{ id: 'web', use: 'next', enabled: false },
]);
});

it('reads string values that contain the opposite quote delimiter', () => {
const cwd = mkdtempSync(join(tmpdir(), 'sh1pt-targets-'));
writeFileSync(
join(cwd, 'sh1pt.config.ts'),
`
export default {
targets: {
web: { use: "foo'adapter" }
}
};
`,
);

expect(readTargetSummary(cwd, 'sh1pt.config.ts')).toEqual([
{ id: 'web', use: "foo'adapter", enabled: true },
]);
});

it('closes strings after an even run of backslashes before a quote', () => {
const cwd = mkdtempSync(join(tmpdir(), 'sh1pt-targets-'));
writeFileSync(
join(cwd, 'sh1pt.config.ts'),
`
export default {
targets: {
web: { use: "path\\\\", enabled: true },
api: { use: 'node' }
}
};
`,
);

expect(readTargetSummary(cwd, 'sh1pt.config.ts')).toEqual([
{ id: 'web', use: 'path\\\\', enabled: true },
{ id: 'api', use: 'node', enabled: true },
]);
});
});
184 changes: 182 additions & 2 deletions packages/cli/src/commands/ship.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Command } from 'commander';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import kleur from 'kleur';
import { lint } from '@profullstack/sh1pt-policy';
Expand All @@ -10,6 +11,27 @@ async function loadManifest(): Promise<Manifest> {
return { name: 'stub', version: '0.0.0', channels: ['stable', 'beta', 'canary'], targets: {} };
}

type TargetSummary = {
id: string;
use: string;
enabled: boolean;
};

export function readTargetSummary(cwd: string, configPath: string): TargetSummary[] {
const path = configPath.startsWith('/') ? configPath : join(cwd, configPath);
if (!existsSync(path)) {
throw new Error(`No ${configPath} found. Run sh1pt ship init first or pass --config <path>.`);
}
const source = readFileSync(path, 'utf8');
const targets = readObjectBody(source, 'targets');
if (!targets) return [];
return readTopLevelObjectEntries(targets).map(({ key, body }) => ({
id: key,
use: readStringProperty(body, 'use') ?? key,
enabled: readBooleanProperty(body, 'enabled') ?? true,
}));
}

export const shipCmd = new Command('ship')
.description('Publish built artifacts to their target stores and registries')
.option('-t, --target <id...>', 'target ids to ship (default: all enabled)')
Expand Down Expand Up @@ -120,8 +142,29 @@ targetSubCmd
targetSubCmd
.command('list')
.description('List enabled targets for this project')
.action(() => {
console.log(kleur.dim('[stub] target list — read sh1pt.config.ts'));
.option('--json', 'print machine-readable output')
.option('--config <path>', 'config file to read', 'sh1pt.config.ts')
.action((opts: { json?: boolean; config: string }) => {
try {
const targets = readTargetSummary(process.cwd(), opts.config);
if (opts.json) {
console.log(JSON.stringify({ targets }, null, 2));
return;
}
if (targets.length === 0) {
console.log(kleur.dim('No targets configured.'));
return;
}
console.log(kleur.bold('Targets'));
for (const target of targets) {
const icon = target.enabled ? kleur.green('●') : kleur.gray('○');
const status = target.enabled ? kleur.green('enabled') : kleur.gray('disabled');
console.log(` ${icon} ${kleur.bold(target.id)} ${kleur.dim(target.use)} ${status}`);
}
} catch (err) {
console.error(kleur.red(err instanceof Error ? err.message : String(err)));
process.exit(1);
}
});

targetSubCmd
Expand All @@ -130,3 +173,140 @@ targetSubCmd
.action(() => {
console.log(kleur.dim('[stub] target available — fetch from registry'));
});

function readObjectBody(source: string, property: string): string | undefined {
source = stripComments(source);
const match = new RegExp(`(?:^|[,{\\s])${escapeRegExp(property)}\\s*:`).exec(source);
if (!match) return undefined;
const open = source.indexOf('{', match.index + match[0].length);
if (open === -1) return undefined;
const close = findMatchingBrace(source, open);
return close === -1 ? undefined : source.slice(open + 1, close);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

function readTopLevelObjectEntries(source: string): Array<{ key: string; body: string }> {
source = stripComments(source);
const entries: Array<{ key: string; body: string }> = [];
const keyRe = /(?:^|,)\s*(['"]?[A-Za-z0-9_-]+['"]?)\s*:/g;
let match: RegExpExecArray | null;
while ((match = keyRe.exec(source))) {
const rawKey = match[1];
if (!rawKey) continue;
const open = source.indexOf('{', keyRe.lastIndex);
if (open === -1) continue;
const between = source.slice(keyRe.lastIndex, open).trim();
if (between.length > 0) continue;
const close = findMatchingBrace(source, open);
if (close === -1) continue;
entries.push({ key: rawKey.replace(/^['"]|['"]$/g, ''), body: source.slice(open + 1, close) });
keyRe.lastIndex = close + 1;
}
return entries;
}

function stripComments(source: string): string {
let result = '';
let quote: '"' | "'" | '`' | undefined;
let lineComment = false;
let blockComment = false;
for (let i = 0; i < source.length; i += 1) {
const ch = source[i];
const prev = source[i - 1];
const next = source[i + 1];
if (lineComment) {
if (ch === '\n') {
lineComment = false;
result += ch;
}
continue;
}
if (blockComment) {
if (prev === '*' && ch === '/') blockComment = false;
continue;
}
if (quote) {
result += ch;
if (ch === quote && !isEscaped(source, i)) quote = undefined;
continue;
}
if (ch === '/' && next === '/') {
lineComment = true;
i += 1;
continue;
}
if (ch === '/' && next === '*') {
blockComment = true;
i += 1;
continue;
}
if (ch === '"' || ch === "'" || ch === '`') quote = ch;
result += ch;
}
return result;
}

function findMatchingBrace(source: string, open: number): number {
let depth = 0;
let quote: '"' | "'" | '`' | undefined;
let lineComment = false;
let blockComment = false;
for (let i = open; i < source.length; i += 1) {
const ch = source[i];
const prev = source[i - 1];
const next = source[i + 1];
if (lineComment) {
if (ch === '\n') lineComment = false;
continue;
}
if (blockComment) {
if (prev === '*' && ch === '/') blockComment = false;
continue;
}
if (quote) {
if (ch === quote && !isEscaped(source, i)) quote = undefined;
continue;
}
if (ch === '/' && next === '/') {
lineComment = true;
i += 1;
continue;
}
if (ch === '/' && next === '*') {
blockComment = true;
i += 1;
continue;
}
if (ch === '"' || ch === "'" || ch === '`') {
quote = ch;
continue;
}
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) return i;
}
}
return -1;
}

function readStringProperty(source: string, key: string): string | undefined {
const match = new RegExp(`${escapeRegExp(key)}\\s*:\\s*(?:'([^']*)'|"([^"]*)")`).exec(source);
return match?.[1] ?? match?.[2];
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

function readBooleanProperty(source: string, key: string): boolean | undefined {
const match = new RegExp(`${escapeRegExp(key)}\\s*:\\s*(true|false)`).exec(source);
return match?.[1] === undefined ? undefined : match[1] === 'true';
}
Comment on lines +292 to +300
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 readBooleanProperty runs its regex against the raw per-entry body, which still contains string values from sibling properties. If a target's use value happens to embed a token matching enabled\s*:\s*(true|false) — e.g. use: "my enabled: false adapter" — the regex matches that substring first and returns the wrong boolean, silently overriding whatever the real enabled: field says. The same exposure exists for readStringProperty matching an enabled: pattern inside a longer use value. Stripping string-literal contents before running the property regexes would close this gap.

Suggested change
function readStringProperty(source: string, key: string): string | undefined {
const match = new RegExp(`${escapeRegExp(key)}\\s*:\\s*(?:'([^']*)'|"([^"]*)")`).exec(source);
return match?.[1] ?? match?.[2];
}
function readBooleanProperty(source: string, key: string): boolean | undefined {
const match = new RegExp(`${escapeRegExp(key)}\\s*:\\s*(true|false)`).exec(source);
return match?.[1] === undefined ? undefined : match[1] === 'true';
}
function readStringProperty(source: string, key: string): string | undefined {
const stripped = stripStringContents(source);
const mStripped = new RegExp(`${escapeRegExp(key)}\\s*:`).exec(stripped);
if (!mStripped) return undefined;
const match = new RegExp(`${escapeRegExp(key)}\\s*:\\s*(?:'([^']*)'|"([^"]*)")`).exec(source);
return match?.[1] ?? match?.[2];
}
function readBooleanProperty(source: string, key: string): boolean | undefined {
const stripped = stripStringContents(source);
const mStripped = new RegExp(`${escapeRegExp(key)}\\s*:`).exec(stripped);
if (!mStripped) return undefined;
const match = new RegExp(`${escapeRegExp(key)}\\s*:\\s*(true|false)`).exec(
source.slice(mStripped.index),
);
return match?.[1] === undefined ? undefined : match[1] === 'true';
}
function stripStringContents(source: string): string {
let result = '';
let quote: '"' | "'" | undefined;
for (let i = 0; i < source.length; i += 1) {
const ch = source[i];
if (quote) {
result += ch === quote && !isEscaped(source, i) ? ((quote = undefined), ch) : ' ';
} else {
if (ch === '"' || ch === "'") quote = ch as '"' | "'";
result += ch;
}
}
return result;
}


function escapeRegExp(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function isEscaped(source: string, index: number): boolean {
let slashCount = 0;
for (let i = index - 1; i >= 0 && source[i] === '\\'; i -= 1) {
slashCount += 1;
}
return slashCount % 2 === 1;
}