gipity 1.0.407 → 1.0.409

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/banner.js +16 -1
  2. package/dist/commands/add.js +1 -1
  3. package/dist/commands/approval.js +1 -1
  4. package/dist/commands/claude.js +9 -9
  5. package/dist/commands/email.js +2 -1
  6. package/dist/commands/gmail.js +2 -1
  7. package/dist/commands/init.js +2 -1
  8. package/dist/commands/job.js +2 -1
  9. package/dist/commands/login.js +1 -0
  10. package/dist/commands/notify.js +2 -1
  11. package/dist/commands/page.js +1 -1
  12. package/dist/commands/payments.js +2 -1
  13. package/dist/commands/plan.js +1 -1
  14. package/dist/commands/remove.js +1 -1
  15. package/dist/commands/secrets.js +2 -1
  16. package/dist/commands/service.js +2 -1
  17. package/dist/commands/sync.js +2 -1
  18. package/dist/commands/test.js +6 -0
  19. package/dist/commands/text.js +2 -1
  20. package/dist/commands/token.js +2 -1
  21. package/dist/config.js +4 -4
  22. package/dist/helpers/output.js +42 -13
  23. package/dist/knowledge.js +2 -0
  24. package/dist/platform.js +53 -6
  25. package/dist/relay/daemon.js +8 -30
  26. package/dist/relay/installers.js +10 -2
  27. package/dist/setup.js +4 -4
  28. package/dist/updater/bootstrap.js +3 -4
  29. package/dist/updater/check.js +2 -3
  30. package/package.json +3 -4
  31. package/dist/coding-guidelines.js +0 -52
  32. package/dist/commands/browser.js +0 -84
  33. package/dist/commands/checkpoint.js +0 -68
  34. package/dist/commands/hook-capture.js +0 -215
  35. package/dist/commands/scaffold.js +0 -58
  36. package/dist/commands/skills.js +0 -45
  37. package/dist/commands/start-cc.js +0 -313
  38. package/dist/gip3dw-guide.js +0 -18
  39. package/dist/platform-overview.js +0 -52
  40. package/dist/relay/transcripts.js +0 -119
  41. package/hooks/post-write.sh +0 -17
  42. package/hooks/pre-turn.sh +0 -20
package/dist/banner.js CHANGED
@@ -1,6 +1,21 @@
1
1
  // ── Gipity CLI Startup Banner ──────────────────────────────────────────
2
2
  // Two-panel box showing all AI models, platform tools, and sandbox capabilities.
3
+ import { homedir } from 'os';
4
+ import { sep } from 'path';
3
5
  import { brand, bold, faint, muted, fg } from './colors.js';
6
+ /** Abbreviate a home-relative path to `~`, cross-platform. On Windows `HOME`
7
+ * is usually unset (it's `USERPROFILE`), and `str.replace('', '~')` prepends
8
+ * a stray `~`, so use os.homedir() and a real prefix check. */
9
+ function tildify(p) {
10
+ const home = homedir();
11
+ if (!home)
12
+ return p;
13
+ if (p === home)
14
+ return '~';
15
+ if (p.startsWith(home + sep))
16
+ return '~' + p.slice(home.length);
17
+ return p;
18
+ }
4
19
  // ── Static content ─────────────────────────────────────────────────────
5
20
  // ── Feature groups ────────────────────────────────────────────────────
6
21
  const AI_MODELS = [
@@ -174,7 +189,7 @@ function buildLeftPanel(opts, panelW) {
174
189
  leftLines.push(center(line, panelW));
175
190
  }
176
191
  if (opts.cwd) {
177
- const short = leadingEllipsify(opts.cwd.replace(process.env['HOME'] || '', '~'), panelW);
192
+ const short = leadingEllipsify(tildify(opts.cwd), panelW);
178
193
  leftLines.push('');
179
194
  leftLines.push(center(muted(short), panelW));
180
195
  leftLines.push('');
@@ -144,7 +144,7 @@ function buildLocalPayload(rootDir) {
144
144
  return { name: path.basename(rootDir), files };
145
145
  }
146
146
  export const addCommand = new Command('add')
147
- .description('Add a template (scaffold an app) or a kit (reusable building block) to the project. Pass ./path/to/dir to install a local template directly.')
147
+ .description('Add a template or kit')
148
148
  .argument('[name]', 'Template/kit key, OR a local directory path (./, ~/, or /abs). Omit for help; use --list for just the catalog.')
149
149
  .option('--title <title>', 'App title - templates only (defaults to project name)')
150
150
  .option('--description <desc>', 'App description for meta tags - templates only')
@@ -21,7 +21,7 @@ function keyToIndex(key) {
21
21
  return code >= 0 && code < 26 ? code : -1;
22
22
  }
23
23
  export const approvalCommand = new Command('approval')
24
- .description('List, create, answer, or cancel approvals');
24
+ .description('Manage approvals');
25
25
  approvalCommand
26
26
  .command('list')
27
27
  .description('List pending approvals')
@@ -1,10 +1,9 @@
1
1
  import { Command } from 'commander';
2
- import { join, dirname, resolve, basename } from 'path';
2
+ import { join, dirname, resolve, basename, sep } from 'path';
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, statSync } from 'fs';
4
- import { spawn } from 'child_process';
5
4
  import { homedir } from 'os';
6
5
  import { fileURLToPath } from 'url';
7
- import { resolveCommand } from '../platform.js';
6
+ import { resolveCommand, spawnCommand } from '../platform.js';
8
7
  import { getAuth, saveAuth, sessionExpired, accessTokenExpired, refreshTokenIfNeeded } from '../auth.js';
9
8
  import { get, post, publicPost, ApiError, getAccountSlug } from '../api.js';
10
9
  import { getConfig, saveConfigAt, clearConfigCache, getApiBaseOverride, DEFAULT_API_BASE, getConfigPath } from '../config.js';
@@ -234,7 +233,7 @@ export const claudeCommand = new Command('claude')
234
233
  // Forwarded to `claude` via the unknown-arg passthrough below (NOT in the
235
234
  // gipity strip lists). Declared here only so it shows up in --help — without
236
235
  // this, callers can't discover that the session model is selectable.
237
- .option('--model <model>', 'Model for the Claude session, forwarded to claude (e.g. sonnet, opus, or a full id like claude-sonnet-4-6)')
236
+ .option('--model <model>', 'Model for the Claude session, forwarded to claude (e.g. sonnet, opus, or a full id like claude-sonnet-5)')
238
237
  .allowUnknownOption(true)
239
238
  .allowExcessArguments(true)
240
239
  .action(async (opts) => {
@@ -822,8 +821,9 @@ export const claudeCommand = new Command('claude')
822
821
  allArgs.push('--verbose');
823
822
  }
824
823
  }
825
- // Resolve full path on Windows so we can avoid shell:true, which
826
- // passes args through cmd.exe and mangles quotes/special chars.
824
+ // Resolve the real path on Windows; spawnCommand then routes the `.cmd`
825
+ // shim through cmd.exe with verbatim args (a bare `spawn` of a `.cmd`
826
+ // throws EINVAL on Node >=18.20.2/20.12.2 - the CVE-2024-27980 fix).
827
827
  const claudeCmd = resolveCommand('claude');
828
828
  const childEnv = { ...process.env };
829
829
  // Gate hook-based capture: when the daemon is streaming via
@@ -844,7 +844,7 @@ export const claudeCommand = new Command('claude')
844
844
  // `-p` runs skip the dialog already, so this is only needed interactively.
845
845
  if (!nonInteractive)
846
846
  markFolderTrusted(process.cwd());
847
- const child = spawn(claudeCmd, allArgs, {
847
+ const child = spawnCommand(claudeCmd, allArgs, {
848
848
  stdio: 'inherit',
849
849
  cwd: process.cwd(),
850
850
  env: childEnv,
@@ -945,10 +945,10 @@ function formatNewProjectLabel(existingSlugs) {
945
945
  const slug = suggestProjectName(existingSlugs);
946
946
  const root = getProjectsRoot();
947
947
  const home = homedir();
948
- const display = root.startsWith(home + '/') || root === home
948
+ const display = root === home || root.startsWith(home + sep)
949
949
  ? '~' + root.slice(home.length)
950
950
  : root;
951
- return `${display}/${slug}`;
951
+ return `${display}${sep}${slug}`;
952
952
  }
953
953
  /** Run the size-tier scan, surface the right UX:
954
954
  * - easy → silent, return true.
@@ -7,7 +7,8 @@ function collect(value, prev) {
7
7
  return [...prev, value];
8
8
  }
9
9
  export const emailCommand = new Command('email')
10
- .description('Send email from the Gipity platform (gipity@gipity.ai). Omit --to to self-send.')
10
+ .description('Send email')
11
+ .addHelpText('after', '\nSends from gipity@gipity.ai. Omit --to to self-send.')
11
12
  .requiredOption('--subject <subject>', 'Email subject')
12
13
  .requiredOption('--body <body>', 'Email body (plain text)')
13
14
  .option('--to <email>', 'Recipient (repeatable; omit for self-send)', collect, [])
@@ -3,7 +3,8 @@ import { get, post } from '../api.js';
3
3
  import { run, printList, printResult } from '../helpers/index.js';
4
4
  import { muted } from '../colors.js';
5
5
  export const gmailCommand = new Command('gmail')
6
- .description('Send, reply, search, or read via your Gmail (user channel - separate from `gipity email`)');
6
+ .description('Use your Gmail')
7
+ .addHelpText('after', '\nSend, reply, search, or read via your own Gmail (separate from `gipity email`).');
7
8
  gmailCommand
8
9
  .command('search <query...>')
9
10
  .description('Search your Gmail (standard Gmail search syntax)')
@@ -22,7 +22,8 @@ function resolveTools(forFlag) {
22
22
  return SUPPORTED_TOOLS.filter(t => requested.includes(t.key) || (!t.optIn && requested.includes('all')));
23
23
  }
24
24
  export const initCommand = new Command('init')
25
- .description('Link this directory to a Gipity project (writes primer files so your AI coding tool understands Gipity)')
25
+ .description('Link this directory to a project')
26
+ .addHelpText('after', '\nWrites CLAUDE.md/AGENTS.md primer files so your AI coding tool understands Gipity.')
26
27
  .argument('[name]', 'Project name/slug (defaults to current directory name)')
27
28
  .option('--agent <guid>', 'Agent GUID to use')
28
29
  .option('--for <tools>', `Which AI tool primer files to write (comma-separated). Default: all except aider (opt-in - it also writes .aider.conf.yml). Choices: ${TOOL_KEYS.join(', ')}, all`)
@@ -4,7 +4,8 @@ import { requireConfig } from '../config.js';
4
4
  import { error as clrError, bold, muted, success, warning as warn } from '../colors.js';
5
5
  import { run, printList } from '../helpers/index.js';
6
6
  export const jobCommand = new Command('job')
7
- .description('Long-running jobs - CPU sandbox or GPU via Modal (gpu-small=L4, gpu-medium=A10G, gpu-large=A100, gpu-huge=H100). Declared in gipity.yaml jobs: phase; submit + poll via subcommands below.');
7
+ .description('Run long jobs (CPU/GPU)')
8
+ .addHelpText('after', '\nCPU sandbox or GPU (gpu-small=L4, gpu-medium=A10G, gpu-large=A100, gpu-huge=H100). Declared in the gipity.yaml jobs: phase; submit and poll via the subcommands below.');
8
9
  jobCommand
9
10
  .command('list')
10
11
  .description('List jobs')
@@ -34,6 +34,7 @@ export const loginCommand = new Command('login')
34
34
  process.exit(1);
35
35
  }
36
36
  await publicPost('/auth/login', { email });
37
+ console.log('');
37
38
  console.log('Check your email for a 6-digit code.');
38
39
  code = await prompt('Code: ');
39
40
  await verify(email, code);
@@ -4,7 +4,8 @@ import { resolveProjectContext } from '../config.js';
4
4
  import { bold, muted, success, warning } from '../colors.js';
5
5
  import { run } from '../helpers/index.js';
6
6
  export const notifyCommand = new Command('notify')
7
- .description('Gipity Notify (web push): send a test notification or inspect subscriptions');
7
+ .description('Web push notifications')
8
+ .addHelpText('after', '\nGipity Notify: send a test notification or inspect subscriptions.');
8
9
  notifyCommand
9
10
  .command('test')
10
11
  .description('Send a test push notification to yourself, a user, or everyone')
@@ -10,7 +10,7 @@ import { pageFetchCommand } from './page-fetch.js';
10
10
  // top-level surface lean and makes the siblings discoverable via `page --help`.
11
11
  // `inspect` is the rendered DOM (browser); `fetch` is the raw asset (plain HTTP).
12
12
  export const pageCommand = new Command('page')
13
- .description('Inspect, evaluate, screenshot, multi-client test, and verify raw files of web pages (page inspect | eval | screenshot | test | fetch)')
13
+ .description('Inspect web pages')
14
14
  .addCommand(pageInspectCommand)
15
15
  .addCommand(pageEvalCommand)
16
16
  .addCommand(pageScreenshotCommand)
@@ -19,7 +19,8 @@ function renderStatus(status) {
19
19
  }
20
20
  }
21
21
  export const paymentsCommand = new Command('payments')
22
- .description('Connect Stripe so your app can charge its users (one-time + subscriptions)');
22
+ .description('Charge your users via Stripe')
23
+ .addHelpText('after', '\nAccept one-time purchases and subscriptions. Set up with `gipity payments connect`.');
23
24
  paymentsCommand
24
25
  .command('connect')
25
26
  .description('Start (or resume) Stripe onboarding for this app — prints a link to finish in your browser')
@@ -31,7 +31,7 @@ function renderLimits(limits, indent = '') {
31
31
  }
32
32
  }
33
33
  export const planCommand = new Command('plan')
34
- .description('Show your current subscription plan and limits')
34
+ .description('Show your plan')
35
35
  .option('--json', 'Output as JSON')
36
36
  .action((opts) => run('Plan', async () => {
37
37
  const [limitsRes, plansRes] = await Promise.all([
@@ -7,7 +7,7 @@ import { run } from '../helpers/index.js';
7
7
  import { confirm } from '../utils.js';
8
8
  import { createProgressReporter, withSpinner } from '../progress.js';
9
9
  export const removeCommand = new Command('remove')
10
- .description('Remove an installed kit from the project (inverse of `gipity add <kit>`).')
10
+ .description('Remove a kit')
11
11
  .argument('<kit>', 'Kit key/directory under src/packages/ to remove')
12
12
  .option('-y, --yes', 'Skip the confirmation prompt')
13
13
  .option('--json', 'Output as JSON')
@@ -12,7 +12,8 @@ async function scopeQuery(opts) {
12
12
  return `scope=project&app_guid=${config.projectGuid}`;
13
13
  }
14
14
  export const secretsCommand = new Command('secrets')
15
- .description('Encrypted secrets (API keys, tokens) for your app — read in functions via secrets.get("NAME")');
15
+ .description('Manage app secrets')
16
+ .addHelpText('after', '\nEncrypted API keys and tokens for your app, read in functions via secrets.get("NAME").');
16
17
  secretsCommand
17
18
  .command('list')
18
19
  .alias('ls')
@@ -20,7 +20,8 @@ const SERVICES = [
20
20
  { name: 'location/geocode', method: 'POST', desc: 'Reverse geocode ({ lat, lon })' },
21
21
  ];
22
22
  export const serviceCommand = new Command('service')
23
- .description('Call a Gipity app service (LLM, image, music, TTS, ...)');
23
+ .description('Call an app service')
24
+ .addHelpText('after', '\nCall a Gipity app service: LLM, image, music, TTS, and more.');
24
25
  serviceCommand
25
26
  .command('list')
26
27
  .description('List callable app services')
@@ -3,7 +3,8 @@ import { sync } from '../sync.js';
3
3
  import { createProgressReporter } from '../progress.js';
4
4
  import { error as clrError, muted } from '../colors.js';
5
5
  export const syncCommand = new Command('sync')
6
- .description('Sync files (a .gipityignore at the project root excludes paths, gitignore-style)')
6
+ .description('Sync files')
7
+ .addHelpText('after', '\nAdd a .gipityignore at the project root to exclude paths (gitignore-style).')
7
8
  .option('--plan', 'Print the plan without applying any changes')
8
9
  .option('--force', 'Bypass the bulk-deletion guard')
9
10
  .option('--prune', 'Remove files that exist on Gipity but not locally (applies the bulk deletes the guard defers)')
@@ -168,6 +168,12 @@ export const testCommand = new Command('test')
168
168
  console.log(clrError(`No tests matched filter: ${filterPath}`));
169
169
  process.exit(1);
170
170
  }
171
+ if (!filterPath && data.total === 0 && data.results.length === 0) {
172
+ console.log(muted('No tests ran - this app has no tests/*.test.js files.'));
173
+ console.log(muted('gipity test runs server-function tests only; a frontend-only app (no functions/) has nothing here to run.'));
174
+ console.log(muted('Verify a frontend app by driving the live page: gipity page inspect <url> (or gipity page eval).'));
175
+ return;
176
+ }
171
177
  if (data.status === 'failed' && data.errorMessage) {
172
178
  console.log(clrError(`Run failed: ${data.errorMessage}`));
173
179
  console.log('');
@@ -140,7 +140,8 @@ const analyzeCommand = new Command('analyze')
140
140
  // Parent namespace. One capability today (analyze); namespaced for a lean
141
141
  // top-level surface and room for future text operations.
142
142
  export const textCommand = new Command('text')
143
- .description(`Deterministic text analysis - char/word counts, frequency, occurrences (${brand('text analyze')})`)
143
+ .description('Analyze text')
144
+ .addHelpText('after', `\nDeterministic char/word counts, frequency, and occurrences (${brand('text analyze')}). Local, no network.`)
144
145
  .addCommand(analyzeCommand);
145
146
  textCommand.action(() => {
146
147
  textCommand.help();
@@ -3,7 +3,8 @@ import { get, post, del } from '../api.js';
3
3
  import { bold, muted, success, warning } from '../colors.js';
4
4
  import { run, printList } from '../helpers/index.js';
5
5
  export const tokenCommand = new Command('token')
6
- .description('Manage agent API tokens (gip_at_*) for headless agents and CI');
6
+ .description('Manage API tokens')
7
+ .addHelpText('after', '\nLong-lived agent API tokens (gip_at_*) for headless agents and CI.');
7
8
  const fmtDate = (d) => (d ? new Date(d).toLocaleDateString() : 'never');
8
9
  tokenCommand
9
10
  .command('create')
package/dist/config.js CHANGED
@@ -134,8 +134,8 @@ export async function resolveProjectContext(opts) {
134
134
  }
135
135
  const agents = await get(`/projects/${match.short_guid}/agents`);
136
136
  const accountSlug = await getAccountSlug();
137
- console.error(dim(`→ One-off mode: targeting project "${match.slug}" (--project override).`));
138
- console.error(dim(`→ Files are not synced - outputs will also be downloaded to ./ for you.`));
137
+ console.error(dim(`→ (project: ${match.slug} · no file sync)`));
138
+ console.error('');
139
139
  return {
140
140
  config: {
141
141
  projectGuid: match.short_guid,
@@ -163,8 +163,8 @@ export async function resolveProjectContext(opts) {
163
163
  console.error('Could not resolve your Home project - please contact support.');
164
164
  process.exit(1);
165
165
  }
166
- console.error(dim(`→ One-off mode: no .gipity.json in cwd, using your Home project on the server.`));
167
- console.error(dim(`→ Files are not synced - outputs will also be downloaded to ./ for you.`));
166
+ console.error(dim(`→ (project: ${res.data.projectName} · no file sync)`));
167
+ console.error('');
168
168
  return {
169
169
  config: {
170
170
  projectGuid: res.data.projectGuid,
@@ -3,16 +3,38 @@
3
3
  * can close a frame even when a command ends via process.exit() and so skips
4
4
  * the postAction hook. */
5
5
  let frameOpen = false;
6
+ /** Count of newlines currently at the tail of everything written to stdout. Kept
7
+ * live by wrapping stdout.write so the frame can close to EXACTLY one trailing
8
+ * blank line instead of blindly appending one - the blind append doubled the
9
+ * blank whenever a command's own output already ended in a newline (e.g. an
10
+ * agent reply printed with a trailing '\n'). */
11
+ let trailingNewlines = 0;
12
+ function trackTrailing(chunk) {
13
+ const s = typeof chunk === 'string'
14
+ ? chunk
15
+ : (chunk && typeof chunk.toString === 'function' ? String(chunk) : '');
16
+ if (!s.length)
17
+ return;
18
+ const run = /\n*$/.exec(s)?.[0].length ?? 0;
19
+ // A chunk that is all newlines extends the current run; otherwise it resets it
20
+ // to just this chunk's own trailing run.
21
+ trailingNewlines = run === s.length ? trailingNewlines + run : run;
22
+ }
6
23
  /**
7
24
  * Bracket every human (non-JSON) command's stdout with exactly one leading and
8
25
  * one trailing blank line, so output is visually separated from the shell
9
26
  * prompt and consistent across commands. JSON output is left untouched so it
10
27
  * stays pipe/parse-clean.
11
28
  *
29
+ * The trailing blank is added by TOP-UP, not blind append: one blank line means
30
+ * the stream ends in two newlines (the content's own line terminator plus one
31
+ * empty line), so we only write the newlines still missing. That keeps the
32
+ * boundary at exactly one blank whether the command ended its output with no
33
+ * newline, one, or an accidental extra.
34
+ *
12
35
  * Centralizing the frame here is what lets individual commands stop printing
13
- * their own leading/trailing blank lines (and stops them doubling up at the
14
- * boundaries). Call once on the root program; Commander runs these lifecycle
15
- * hooks for the actual subcommand action too.
36
+ * their own leading/trailing blank lines. Call once on the root program;
37
+ * Commander runs these lifecycle hooks for the actual subcommand action too.
16
38
  */
17
39
  export function installOutputFrame(program) {
18
40
  // Only decorate a real terminal. When stdout is piped or redirected (a relay
@@ -20,6 +42,21 @@ export function installOutputFrame(program) {
20
42
  // colors.ts uses for ANSI. This keeps headless `claude -p` stdout empty.
21
43
  if (!process.stdout.isTTY)
22
44
  return;
45
+ // Wrap stdout.write to keep `trailingNewlines` current for every byte the
46
+ // command emits (console.log, spinner, colors all route through here).
47
+ const origWrite = process.stdout.write.bind(process.stdout);
48
+ process.stdout.write = (chunk, ...rest) => {
49
+ trackTrailing(chunk);
50
+ return origWrite(chunk, ...rest);
51
+ };
52
+ const closeFrame = () => {
53
+ if (!frameOpen)
54
+ return;
55
+ frameOpen = false;
56
+ const need = Math.max(0, 2 - trailingNewlines);
57
+ if (need > 0)
58
+ origWrite('\n'.repeat(need));
59
+ };
23
60
  program.hook('preAction', (_thisCommand, actionCommand) => {
24
61
  const opts = actionCommand.optsWithGlobals
25
62
  ? actionCommand.optsWithGlobals()
@@ -29,18 +66,10 @@ export function installOutputFrame(program) {
29
66
  process.stdout.write('\n');
30
67
  frameOpen = true;
31
68
  });
32
- program.hook('postAction', () => {
33
- if (frameOpen) {
34
- process.stdout.write('\n');
35
- frameOpen = false;
36
- }
37
- });
69
+ program.hook('postAction', closeFrame);
38
70
  // Commands that finish via process.exit() never reach postAction; close the
39
71
  // frame from the exit handler instead (sync write, fires on every exit path).
40
- process.on('exit', () => {
41
- if (frameOpen)
42
- process.stdout.write('\n');
43
- });
72
+ process.on('exit', closeFrame);
44
73
  }
45
74
  /**
46
75
  * Print data as JSON or formatted text.
package/dist/knowledge.js CHANGED
@@ -155,8 +155,10 @@ App services skills (load before calling \`/services/*\` endpoints):
155
155
 
156
156
  App development skills:
157
157
  - \`agent-deploy\` - headless auth via agent API tokens (GIPITY_TOKEN) for unattended deploys
158
+ - \`app-database\` - app Postgres database: migrations, the db helper, transactions, table permissions
158
159
  - \`app-debugging\` - debug a deployed app: page inspect/eval, screenshots, function logs
159
160
  - \`app-development\` - functions, database, and API
161
+ - \`app-testing\` - testing deployed app functions (ctx.fn.call/callAs, the isolated test DB)
160
162
  - \`deploy\` - the deploy pipeline & gipity.yaml manifest
161
163
  - \`jobs\` - long-running CPU + GPU compute jobs (Python / Node / bash)
162
164
  - \`realtime-scheduled-app\` - recipe: realtime presence/messages + DB function + scheduled poster, end-to-end
package/dist/platform.js CHANGED
@@ -1,12 +1,16 @@
1
- import { execSync } from 'child_process';
1
+ import { execSync, spawn, spawnSync } from 'child_process';
2
2
  /**
3
- * Resolve a command to something Node's spawn/spawnSync can launch directly.
3
+ * Resolve a command to a concrete path when possible.
4
4
  *
5
5
  * On Windows, npm-installed CLIs (npm, npx, gipity, claude) are `.cmd` batch
6
- * shims, not `.exe`s. Node's spawn without `shell: true` cannot launch a bare
7
- * `npm` - it fails with ENOENT and a null exit status. Resolving the real path
8
- * (preferring `.exe`, falling back to `.cmd`) lets us spawn without `shell: true`
9
- * (which would otherwise require escaping arguments).
6
+ * shims, not `.exe`s. A bare `npm` fails to spawn with ENOENT, so we resolve
7
+ * the real path (preferring a native `.exe`, falling back to the `.cmd` shim).
8
+ *
9
+ * NOTE: resolving to the `.cmd` path is NOT enough on its own. Since Node
10
+ * 18.20.2 / 20.12.2 (the CVE-2024-27980 fix), `spawn`/`spawnSync` REFUSE to
11
+ * launch a `.cmd`/`.bat` target unless `shell: true` - they throw `EINVAL`.
12
+ * So always launch a resolved command through `spawnCommand` /
13
+ * `spawnSyncCommand` below, which route batch shims through cmd.exe safely.
10
14
  */
11
15
  export function resolveCommand(cmd) {
12
16
  if (process.platform !== 'win32')
@@ -23,4 +27,47 @@ export function resolveCommand(cmd) {
23
27
  return `${cmd}.cmd`;
24
28
  }
25
29
  }
30
+ /** True for a Windows batch shim that Node cannot spawn without a shell. */
31
+ export function isBatchShim(cmd) {
32
+ return process.platform === 'win32' && /\.(cmd|bat)$/i.test(cmd);
33
+ }
34
+ /**
35
+ * Build a cmd.exe invocation that launches a `.cmd`/`.bat` shim with its args
36
+ * passed through verbatim - the correct way to run a batch file post-CVE
37
+ * without `shell: true` mangling argument quoting.
38
+ *
39
+ * `cmd.exe /d /s /c "<line>"`: `/d` skips AutoRun, `/s` + the wrapping outer
40
+ * quotes make cmd strip exactly the first and last quote of the whole line
41
+ * (leaving each already-quoted token intact). We quote every token ourselves
42
+ * and set `windowsVerbatimArguments` so Node hands our line to cmd untouched.
43
+ */
44
+ export function winBatchInvocation(cmd, args) {
45
+ const comspec = process.env.ComSpec || 'cmd.exe';
46
+ // Double-quote each token; escape any embedded double-quote as "" (cmd's
47
+ // in-quote escape). Our call sites pass flags/paths/guids with no cmd
48
+ // metacharacters, so this conservative quoting is sufficient and safe.
49
+ const quote = (t) => `"${String(t).replace(/"/g, '""')}"`;
50
+ const line = [cmd, ...args].map(quote).join(' ');
51
+ return { file: comspec, args: ['/d', '/s', '/c', `"${line}"`] };
52
+ }
53
+ /**
54
+ * Drop-in `spawn` that survives Windows batch shims. When `cmd` is a `.cmd`/
55
+ * `.bat` on win32, it is launched via cmd.exe with verbatim arguments;
56
+ * otherwise this is a plain `spawn`. Use for anything from `resolveCommand`.
57
+ */
58
+ export function spawnCommand(cmd, args = [], options = {}) {
59
+ if (isBatchShim(cmd)) {
60
+ const inv = winBatchInvocation(cmd, args);
61
+ return spawn(inv.file, inv.args, { ...options, windowsVerbatimArguments: true });
62
+ }
63
+ return spawn(cmd, [...args], options);
64
+ }
65
+ /** Drop-in `spawnSync` counterpart of {@link spawnCommand}. */
66
+ export function spawnSyncCommand(cmd, args = [], options = {}) {
67
+ if (isBatchShim(cmd)) {
68
+ const inv = winBatchInvocation(cmd, args);
69
+ return spawnSync(inv.file, inv.args, { ...options, windowsVerbatimArguments: true });
70
+ }
71
+ return spawnSync(cmd, [...args], options);
72
+ }
26
73
  //# sourceMappingURL=platform.js.map
@@ -1,27 +1,4 @@
1
- /**
2
- * Gipity relay daemon - the `gipity-relay` long-running helper that backs
3
- * `gipity relay run`. Runs two concurrent loops against the paired Gipity
4
- * account using the device's bearer token:
5
- *
6
- * 1. Heartbeat every 60s → POST /remote-devices/heartbeat. Drives the
7
- * web CLI's presence indicator.
8
- * 2. Long-poll → GET /remote-devices/next. On a 200 claim, look up the
9
- * dispatch's project in the local allowlist, spawn `gipity claude -p
10
- * "<msg>"` in that project's cwd, wait for it to exit, POST ack.
11
- *
12
- * The conversation stream (prompts, tool calls, assistant output) flows
13
- * back to the web CLI *automatically* via the capture hooks installed in
14
- * `.claude/settings.json` - the daemon itself doesn't forward content.
15
- *
16
- * Graceful exit:
17
- * - SIGINT / SIGTERM → stop both loops, wait for in-flight child, exit 0.
18
- * - 401 from heartbeat or /next → device was revoked; exit 0.
19
- * - Any other backend error → log and retry with exponential backoff.
20
- *
21
- * See docs/feature-backlog/gipity-relay-phases.md (Phase A Step 7).
22
- */
23
- import { spawn } from 'child_process';
24
- import { resolveCommand } from '../platform.js';
1
+ import { resolveCommand, spawnCommand } from '../platform.js';
25
2
  import { appendFileSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, closeSync, openSync, unlinkSync } from 'fs';
26
3
  import { stat, readFile } from 'fs/promises';
27
4
  import { createInterface } from 'readline';
@@ -528,14 +505,15 @@ function isSafeSessionId(s) {
528
505
  return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(s);
529
506
  }
530
507
  /** Resolve Claude Code's on-disk transcript path for measuring resume
531
- * size. Claude encodes the project cwd into a slug by replacing `/` with
532
- * `-`. We only read this file cosmetically (to show "resume 5 KB" in the
533
- * Invoking marker); actual capture is via stream-json. Returns null for
508
+ * size. Claude encodes the project cwd into a slug by replacing every
509
+ * non-alphanumeric char with `-` (so `/`, and on Windows `\` and `:`, all
510
+ * collapse). We only read this file cosmetically (to show "resume 5 KB" in
511
+ * the Invoking marker); actual capture is via stream-json. Returns null for
534
512
  * a sessionId that fails the safety check. */
535
513
  function transcriptPathFor(cwd, sessionId) {
536
514
  if (!isSafeSessionId(sessionId))
537
515
  return null;
538
- const slug = cwd.replace(/\//g, '-');
516
+ const slug = cwd.replace(/[^a-zA-Z0-9]/g, '-');
539
517
  return join(homedir(), '.claude', 'projects', slug, `${sessionId}.jsonl`);
540
518
  }
541
519
  /** `18.4s` when under a minute, `3:12.2` above. */
@@ -867,7 +845,7 @@ async function spawnSync(cwd, timeoutMs) {
867
845
  // verbatim (it may be a full path); only the default name is resolved.
868
846
  const cmd = process.env.GIPITY_RELAY_CLAUDE_CMD || resolveCommand('gipity');
869
847
  return new Promise((resolve, reject) => {
870
- const child = spawn(cmd, ['sync', '--json'], {
848
+ const child = spawnCommand(cmd, ['sync', '--json'], {
871
849
  cwd,
872
850
  env: process.env,
873
851
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -921,7 +899,7 @@ export async function spawnGipityClaude(args, cwd, d) {
921
899
  const fullArgs = [...args, '--output-format', 'stream-json', '--verbose'];
922
900
  const env = { ...process.env, GIPITY_CONVERSATION_GUID: d.conversation_guid };
923
901
  return new Promise((resolve, reject) => {
924
- const child = spawn(cmd, fullArgs, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
902
+ const child = spawnCommand(cmd, fullArgs, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
925
903
  // `exited` fires when the child fully unwinds (exit event). Callers
926
904
  // like `killRunningForConv` await this before spawning a replacement
927
905
  // so the outgoing child has a chance to post its cancelled marker
@@ -126,6 +126,14 @@ WantedBy=default.target
126
126
  function windowsTaskPlan(cliPath) {
127
127
  const taskName = 'GipityRelay';
128
128
  const path = join(homedir(), 'AppData', 'Local', 'Gipity', 'relay-task.xml');
129
+ // Launch through node.exe explicitly. Task Scheduler's <Command> runs the
130
+ // target via CreateProcess, and a `.js` cliPath (what resolveCliPath yields)
131
+ // would resolve to its file association - Windows Script Host - which chokes
132
+ // on the shebang with "Invalid character" (800A03F6). Passing the entry as a
133
+ // node argument runs it under Node like every other platform.
134
+ const runsViaNode = /\.[cm]?js$/i.test(cliPath);
135
+ const command = runsViaNode ? process.execPath : cliPath;
136
+ const argLine = runsViaNode ? `"${cliPath}" relay run` : 'relay run';
129
137
  const content = `<?xml version="1.0" encoding="UTF-16"?>
130
138
  <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
131
139
  <RegistrationInfo>
@@ -151,8 +159,8 @@ function windowsTaskPlan(cliPath) {
151
159
  </Settings>
152
160
  <Actions>
153
161
  <Exec>
154
- <Command>${cliPath}</Command>
155
- <Arguments>relay run</Arguments>
162
+ <Command>${command}</Command>
163
+ <Arguments>${argLine}</Arguments>
156
164
  </Exec>
157
165
  </Actions>
158
166
  </Task>
package/dist/setup.js CHANGED
@@ -5,7 +5,7 @@ import { resolve, join, dirname } from 'path';
5
5
  import { homedir } from 'os';
6
6
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
7
7
  import { spawnSync } from 'child_process';
8
- import { resolveCommand } from './platform.js';
8
+ import { resolveCommand, spawnSyncCommand } from './platform.js';
9
9
  import { SKILLS_CONTENT, BUILD_VS_NON_BUILD_RULE, DEFINITION_OF_DONE } from './knowledge.js';
10
10
  export { SKILLS_CONTENT };
11
11
  /** Canonical list of workstation artifacts that are NOT part of the project.
@@ -137,7 +137,7 @@ export const GIPITY_MARKETPLACE_REPO = 'GipityAI/claude-plugin';
137
137
  // an installed plugin when the marketplace advances - only an explicit
138
138
  // `plugin install`/`update` does - so this constant is how a CLI upgrade tells
139
139
  // ensureGipityPluginInstalled() to refresh a stale user-scope install.
140
- export const GIPITY_PLUGIN_VERSION = '0.4.0';
140
+ export const GIPITY_PLUGIN_VERSION = '0.4.1';
141
141
  /** True for hook commands the CLI itself wrote into settings.json in past
142
142
  * versions. Matched by signature so migration strips exactly our own
143
143
  * entries and never touches user-authored hooks. */
@@ -288,11 +288,11 @@ export function ensureGipityPluginInstalled() {
288
288
  // without an explicit path, so resolve it (otherwise the install silently
289
289
  // ENOENTs and the plugin's hooks never land at user scope).
290
290
  const claudeCmd = resolveCommand('claude');
291
- spawnSync(claudeCmd, ['plugin', 'marketplace', 'update', GIPITY_MARKETPLACE_NAME], {
291
+ spawnSyncCommand(claudeCmd, ['plugin', 'marketplace', 'update', GIPITY_MARKETPLACE_NAME], {
292
292
  stdio: 'ignore',
293
293
  timeout: 120_000,
294
294
  });
295
- spawnSync(claudeCmd, ['plugin', 'install', GIPITY_PLUGIN_ID, '--scope', 'user'], {
295
+ spawnSyncCommand(claudeCmd, ['plugin', 'install', GIPITY_PLUGIN_ID, '--scope', 'user'], {
296
296
  stdio: 'ignore',
297
297
  timeout: 120_000,
298
298
  });