diff --git a/packages/cli/src/commands/ship.test.ts b/packages/cli/src/commands/ship.test.ts index 2278d868..4cd36d70 100644 --- a/packages/cli/src/commands/ship.test.ts +++ b/packages/cli/src/commands/ship.test.ts @@ -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"', () => { @@ -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 }, + ]); + }); }); diff --git a/packages/cli/src/commands/ship.ts b/packages/cli/src/commands/ship.ts index aab088ff..909bf82d 100644 --- a/packages/cli/src/commands/ship.ts +++ b/packages/cli/src/commands/ship.ts @@ -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'; @@ -10,6 +11,27 @@ async function loadManifest(): Promise { 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 .`); + } + 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 ', 'target ids to ship (default: all enabled)') @@ -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 ', '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 @@ -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); +} + +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]; +} + +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, '\\$&'); +} + +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; +}