Skip to content
Open
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
175 changes: 173 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;
};

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,131 @@ targetSubCmd
.action(() => {
console.log(kleur.dim('[stub] target available — fetch from registry'));
});

function readObjectBody(source: string, property: string): string | undefined {
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 on lines +177 to +184
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 readObjectBody can match targets: inside a comment

readObjectBody runs its regex against the raw, unstripped source, so any comment containing the word targets: — such as // Example: targets: { ios: { use: '…' } } or a JSDoc block — will be matched first. The function then calls source.indexOf('{', …) on the raw source, which finds the { belonging to the comment's inline object. findMatchingBrace then faithfully walks that comment's braces and returns the comment's body as the targets block. The downstream readTopLevelObjectEntries call strips comments from THAT body, but it's already operating on the wrong slice of text, so the real targets are never surfaced. The fix is to run stripComments on the source before the regex match — the same thing readTopLevelObjectEntries does for its input.


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 && prev !== '\\') quote = undefined;
continue;
Comment on lines +228 to +229
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.

P2 Consecutive-backslash escape check is incorrect in stripComments

The check prev !== '\\' uses only the immediately preceding character to decide whether a closing quote is escaped. This fails for double-backslash sequences: in the literal 'hello\\', when the parser reaches the closing ', prev is \, so it treats the quote as escaped and never closes the string. The string tracking then "leaks" into subsequent file content, potentially hiding real properties from the parser. A correct check counts the run of consecutive backslashes before the quote; if the run is even, the quote is not escaped.

}
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 && prev !== '\\') 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];
}
Comment on lines +291 to +294
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.

P2 readStringProperty uses [^'"]+ in the character class, which forbids BOTH single and double quotes from appearing inside the captured value. This means a value like use: "foo'adapter" would silently capture only foo (the regex closes on the embedded ' before the closing "), returning wrong data with no error. Matching only the delimiter that opened the string prevents this class of truncation.

Suggested change
function readStringProperty(source: string, key: string): string | undefined {
const match = new RegExp(`${escapeRegExp(key)}\\s*:\\s*['"]([^'"]+)['"]`).exec(source);
return match?.[1];
}
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 escapeRegExp(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
Loading