Compare commits
5
Commits
e2b3f7cff1
...
c94ad2c05b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c94ad2c05b | ||
|
|
1b00d88a0d | ||
|
|
c1ad33f1b0 | ||
|
|
7bdeb9ab24 | ||
|
|
0412a2284a |
@@ -47,6 +47,11 @@ tools/ui/.serve.pid
|
||||
coverage.*
|
||||
/tmp/
|
||||
|
||||
# Alchemist golden-test failure artefacts (diff images written when a
|
||||
# pixel-compare fails locally; the committed fixtures live under
|
||||
# app/test/goldens/goldens/{ci,linux}/).
|
||||
app/test/goldens/failures/
|
||||
|
||||
# -- pql per-repo state (clide dogfoods against itself; pql's index lands
|
||||
# here when running queries locally). `pql.db` is also where the
|
||||
# `tools/scripts/plan` stopgap writes decisions + tickets (D-040).
|
||||
|
||||
@@ -18,6 +18,51 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- Josefin Sans bundled as `app/assets/fonts/josefin_sans/` as the
|
||||
application UI face — variable-font pair (upright + italic, weight
|
||||
range 100-700), OFL-licensed. Declared as the `JosefinSans` family
|
||||
in `app/pubspec.yaml`. `_AppRoot` installs it as the ambient
|
||||
`DefaultTextStyle` at weight `w300` (Light) per the project's
|
||||
aesthetic direction; callers can still pass an explicit
|
||||
`fontWeight` on `ClideText` to get bolder emphasis.
|
||||
|
||||
- JetBrains Mono bundled as `app/assets/fonts/jetbrains_mono/` —
|
||||
Regular / Italic / Bold / BoldItalic weights (OFL-licensed,
|
||||
license file checked in alongside). Declared as the `JetBrainsMono`
|
||||
family in `app/pubspec.yaml`. `app/lib/widgets/src/typography.dart`
|
||||
exposes `clideUiFamily` + `clideMonoFamily` plus platform-ordered
|
||||
fallback chains for both faces, for web builds and harnesses that
|
||||
don't load asset fonts.
|
||||
|
||||
- `app/assets/licenses.yaml` — canonical manifest of every bundled
|
||||
third-party artefact (fonts today; Dart packages + native tools as
|
||||
they land). Schema has name, kind, version, homepage, license,
|
||||
`license_file` pointer, and a one-line purpose. Bundled alongside
|
||||
the per-dep license texts. The About screen (Tier 6) will render
|
||||
this file verbatim. Accompanied by
|
||||
[`D-042`](decisions/tooling.md#d-042-bundled-dependencies-documented-in-licensesyaml):
|
||||
adding a dep is a two-step commit (artefact + `licenses.yaml`
|
||||
entry in the same changeset).
|
||||
|
||||
### Changed
|
||||
|
||||
- CLAUDE.md "Dependencies & supply chain" section gains the
|
||||
"document every bundled dependency" rule, pointing at
|
||||
`app/assets/licenses.yaml` and `D-042`.
|
||||
|
||||
- `ptyc/` — the C PTY-spawn helper, peer of `pql` per
|
||||
[`D-005`](decisions/architecture.md#d-005-dart-core-sidecar-dissolved-ptyc-as-pql-peer).
|
||||
One-shot, libc-only, ~400 LOC. Reads a JSON request on stdin
|
||||
(`argv`, optional `cwd`/`env`/`cols`/`rows`), does
|
||||
`posix_openpt` + `fork` + `execvp`, and hands the master fd back
|
||||
to the caller over a unix socket via `SCM_RIGHTS`. Socket fd
|
||||
defaults to 3; override via `PTYC_SOCK_FD` for language runtimes
|
||||
that shuffle pipe fds through the low numbers (Python's
|
||||
`subprocess` with `stdout=PIPE` does this). Exec-failure pipe
|
||||
(CLOEXEC) reports child-side errors to the parent without leaking
|
||||
zombies. Root Makefile gains `ptyc-test` target in addition to
|
||||
`ptyc-build` / `ptyc-clean`.
|
||||
|
||||
- Migrated the `docs/ADRs/` content into `decisions/` as D/R records:
|
||||
ADR 0001 → `D-001`, ADR 0002 → `R-002` (superseded by `D-005`), ADR
|
||||
0003 → `D-003`, ADR 0004 → `D-004`, ADR 0005 → `D-005`, ADR 0006 →
|
||||
|
||||
@@ -51,6 +51,7 @@ See `docs/initial-plan.md` for the full tier definitions and acceptance criteria
|
||||
## Dependencies & supply chain
|
||||
|
||||
- **Dart (core + app):** prefer-zero-deps. Flutter-SDK widgets first; third-party packages need justification. What stays is exact-pinned in `pubspec.yaml` (no caret ranges). Advisories reviewed before every bump; `pubspec.lock` committed.
|
||||
- **Document every bundled dependency.** Every third-party artefact that ships in the clide binary — Dart packages, fonts, bundled assets, native supporter tools — is listed in [`app/assets/licenses.yaml`](app/assets/licenses.yaml) with name, kind, version, homepage, license, relative path to the bundled license text, and a one-line purpose. Adding a dep is a two-step commit: add the artefact **and** the `licenses.yaml` entry in the same changeset. The About screen (Tier 6) renders the file verbatim; until then, the list being accurate is the contract. See [`D-042`](decisions/tooling.md#d-042-bundled-dependencies-documented-in-licensesyaml).
|
||||
- **`ptyc` and any future native supporter tool:** no dep graph by design (libc-only for `ptyc`). "Audit" is reading the source before each bump.
|
||||
- `pubspec.lock` is always committed.
|
||||
- `make security` runs the Dart advisory review; `ci/security.sh` is the CI entry.
|
||||
|
||||
@@ -132,19 +132,27 @@ endif
|
||||
# -- ptyc (C supporter tool) --------------------------------------------
|
||||
|
||||
# Compiled alongside the main binary. Tiny, no deps beyond libc.
|
||||
PTYX_PRESENT := $(shell test -f ptyc/Makefile && echo yes || echo no)
|
||||
PTYC_PRESENT := $(shell test -f ptyc/Makefile && echo yes || echo no)
|
||||
|
||||
.PHONY: ptyc-build
|
||||
ptyc-build: ## Build the ptyc PTY-spawn helper.
|
||||
ifeq ($(PTYX_PRESENT),yes)
|
||||
ifeq ($(PTYC_PRESENT),yes)
|
||||
$(MAKE) -C ptyc
|
||||
else
|
||||
@echo "(ptyc/ not scaffolded yet; skipping)"
|
||||
endif
|
||||
|
||||
.PHONY: ptyc-test
|
||||
ptyc-test: ## Run ptyc smoke tests (SCM_RIGHTS round-trip).
|
||||
ifeq ($(PTYC_PRESENT),yes)
|
||||
$(MAKE) -C ptyc test
|
||||
else
|
||||
@echo "(ptyc/ not scaffolded yet; skipping)"
|
||||
endif
|
||||
|
||||
.PHONY: ptyc-clean
|
||||
ptyc-clean: ## Clean ptyc build artefacts.
|
||||
ifeq ($(PTYX_PRESENT),yes)
|
||||
ifeq ($(PTYC_PRESENT),yes)
|
||||
$(MAKE) -C ptyc clean
|
||||
else
|
||||
@echo "(ptyc/ not scaffolded yet; skipping)"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Jeroen Schweitzer
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2010 The Josefin Sans Project Authors (https://github.com/ThomasJockin/JosefinSansFont-master), with Reserved Font Name "Josefin Sans".
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,123 @@
|
||||
# clide — licensing manifest.
|
||||
#
|
||||
# Three sections:
|
||||
# - `self:` — clide's own license (MIT). Rendered first in the About
|
||||
# screen so the user knows what they're running before they see
|
||||
# the list of things it depends on.
|
||||
# - `dependencies:` — third-party artefacts that SHIP in the clide
|
||||
# binary: fonts, runtime Dart packages, native supporter tools,
|
||||
# bundled data. The About screen renders these verbatim.
|
||||
# - `dev_dependencies:` — build-time-only tooling: test runners,
|
||||
# mocks, lints, golden-test harness. These never enter the shipped
|
||||
# binary, so the About screen does not render them, but they're
|
||||
# tracked here for audit completeness alongside the real list.
|
||||
#
|
||||
# Adding a new runtime dependency is a two-step commit:
|
||||
# 1. Add the dependency itself (font file, `pubspec.yaml` entry, etc.)
|
||||
# 2. Add the entry under `dependencies:` below in the same commit, so
|
||||
# the About screen stays accurate and the prefer-zero-deps
|
||||
# guardrail is enforced by the obvious visible consequence: an
|
||||
# extra row in the list.
|
||||
#
|
||||
# Adding a new dev-only dependency is the same two-step commit, just
|
||||
# under `dev_dependencies:` instead. The guardrail still applies —
|
||||
# dev deps are supply-chain risk too — but they don't pollute the
|
||||
# user-facing About screen.
|
||||
#
|
||||
# See CLAUDE.md "Dependencies & supply chain" for the full rule, and
|
||||
# `decisions/tooling.md` for the D-record that pins it.
|
||||
|
||||
schema_version: 1
|
||||
|
||||
# clide itself. Listed first so the About screen can render "You're
|
||||
# looking at clide vX, MIT-licensed" at the top of the panel before
|
||||
# the bundled-dependency list underneath. The root LICENSE is the
|
||||
# authoritative copy; `app/assets/LICENSE` is a mirror bundled so the
|
||||
# About screen can read it at runtime.
|
||||
self:
|
||||
name: clide
|
||||
version: "2.0.0-dev"
|
||||
homepage: https://git.schweitz.net/jpmschweitzer/clide
|
||||
license: MIT
|
||||
license_file: assets/LICENSE
|
||||
copyright: "© 2026 Jeroen Schweitzer"
|
||||
|
||||
# Runtime dependencies — ship in the clide binary.
|
||||
dependencies:
|
||||
- name: JetBrains Mono
|
||||
kind: font
|
||||
version: "2.304"
|
||||
homepage: https://github.com/JetBrains/JetBrainsMono
|
||||
license: OFL-1.1
|
||||
license_file: assets/fonts/jetbrains_mono/OFL.txt
|
||||
purpose: >-
|
||||
Monospace face for terminal panes, diff views, code editors, and
|
||||
any other monospace surface.
|
||||
weights_bundled: [Regular, Italic, Bold, BoldItalic]
|
||||
|
||||
- name: Josefin Sans
|
||||
kind: font
|
||||
version: "variable"
|
||||
homepage: https://fonts.google.com/specimen/Josefin+Sans
|
||||
license: OFL-1.1
|
||||
license_file: assets/fonts/josefin_sans/OFL.txt
|
||||
purpose: >-
|
||||
Application UI face. Default weight Light (300); full 100-700
|
||||
range available via the variable-font weight axis.
|
||||
weights_bundled: [VariableFont, Italic-VariableFont]
|
||||
|
||||
- name: yaml
|
||||
kind: dart-package
|
||||
version: "3.1.3"
|
||||
homepage: https://pub.dev/packages/yaml
|
||||
license: MIT
|
||||
purpose: >-
|
||||
YAML parser for theme files and extension manifests. The one
|
||||
justified exception to prefer-zero-deps; Dart-team maintained.
|
||||
|
||||
# Build-time-only dependencies — test runners, mocks, lints. Tracked
|
||||
# here for audit completeness; NOT rendered in the About screen.
|
||||
dev_dependencies:
|
||||
- name: mocktail
|
||||
kind: dart-package
|
||||
version: "1.0.4"
|
||||
homepage: https://pub.dev/packages/mocktail
|
||||
license: MIT
|
||||
purpose: >-
|
||||
Mocks at IO / IPC boundaries. ChangeNotifier facades use
|
||||
hand-rolled fakes instead of mocks (D-025).
|
||||
|
||||
- name: alchemist
|
||||
kind: dart-package
|
||||
version: "0.12.1"
|
||||
homepage: https://pub.dev/packages/alchemist
|
||||
license: MIT
|
||||
purpose: >-
|
||||
Golden-test framework; Ahem-font rendering for cross-platform
|
||||
pixel stability on widget primitives (D-024).
|
||||
|
||||
- name: flutter_lints
|
||||
kind: dart-package
|
||||
version: "6.0.0"
|
||||
homepage: https://pub.dev/packages/flutter_lints
|
||||
license: BSD-3-Clause
|
||||
purpose: Flutter-team-recommended analyzer lint set (app/).
|
||||
|
||||
- name: lints
|
||||
kind: dart-package
|
||||
version: "5.0.0"
|
||||
homepage: https://pub.dev/packages/lints
|
||||
license: BSD-3-Clause
|
||||
purpose: >-
|
||||
Dart-team-recommended analyzer lint set for the Flutter-free
|
||||
core package at the repo root.
|
||||
|
||||
- name: test
|
||||
kind: dart-package
|
||||
version: "1.25.8"
|
||||
homepage: https://pub.dev/packages/test
|
||||
license: BSD-3-Clause
|
||||
purpose: >-
|
||||
Dart test runner for the core package (Flutter-free; the app
|
||||
uses flutter_test from the Flutter SDK for widget + golden
|
||||
coverage).
|
||||
+3
-6
@@ -70,12 +70,9 @@ class _RootShellState extends State<_RootShell> {
|
||||
style: TextStyle(
|
||||
color: tokens.globalForeground,
|
||||
fontSize: 13,
|
||||
fontFamilyFallback: const [
|
||||
'Inter',
|
||||
'Helvetica',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
],
|
||||
fontWeight: clideUiDefaultWeight,
|
||||
fontFamily: clideUiFamily,
|
||||
fontFamilyFallback: clideUiFamilyFallback,
|
||||
),
|
||||
child: KeyboardListener(
|
||||
focusNode: _keyFocus,
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import 'package:clide_app/kernel/src/theme/controller.dart';
|
||||
import 'package:clide_app/widgets/src/typography.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Theme-aware Text. Defaults pull from the global foreground token.
|
||||
/// Theme-aware Text. Defaults pull from the global foreground token
|
||||
/// and [clideUiDefaultWeight].
|
||||
///
|
||||
/// The UI font family is deliberately **not** set on this widget — it
|
||||
/// inherits from the ambient `DefaultTextStyle` which `_AppRoot`
|
||||
/// provides ([clideUiFamily] in real runs). Goldens rely on Alchemist
|
||||
/// injecting Ahem for deterministic metrics; hard-coding a family here
|
||||
/// would override that and break pixel determinism per D-024.
|
||||
class ClideText extends StatelessWidget {
|
||||
const ClideText(
|
||||
this.data, {
|
||||
super.key,
|
||||
this.color,
|
||||
this.fontSize = 13,
|
||||
this.fontWeight = FontWeight.w400,
|
||||
this.fontWeight,
|
||||
this.muted = false,
|
||||
this.maxLines,
|
||||
this.overflow,
|
||||
@@ -18,7 +26,7 @@ class ClideText extends StatelessWidget {
|
||||
final String data;
|
||||
final Color? color;
|
||||
final double fontSize;
|
||||
final FontWeight fontWeight;
|
||||
final FontWeight? fontWeight;
|
||||
final bool muted;
|
||||
final int? maxLines;
|
||||
final TextOverflow? overflow;
|
||||
@@ -37,8 +45,11 @@ class ClideText extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: resolved,
|
||||
fontSize: fontSize,
|
||||
// Null means inherit from the ambient DefaultTextStyle —
|
||||
// _AppRoot installs clideUiDefaultWeight there; goldens get
|
||||
// whatever Alchemist injects. Passing an explicit weight (e.g.
|
||||
// FontWeight.bold) still wins.
|
||||
fontWeight: fontWeight,
|
||||
fontFamilyFallback: const ['Inter', 'Helvetica', 'Arial', 'sans-serif'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/// Typography constants shared across widgets.
|
||||
///
|
||||
/// Two bundled families:
|
||||
///
|
||||
/// - [clideUiFamily] — Josefin Sans, the application-wide UI face.
|
||||
/// Shipped as a variable font (weights 100-700) + italic companion;
|
||||
/// default weight is [clideUiDefaultWeight] (Light / `w300`).
|
||||
/// - [clideMonoFamily] — JetBrains Mono, for terminal panes, diff
|
||||
/// views, code editors, and any other monospace surface.
|
||||
///
|
||||
/// Fallback chains exist for web builds + harnesses where the bundled
|
||||
/// asset isn't picked up (rare, but possible during `flutter test` if
|
||||
/// asset fonts aren't declared in the harness).
|
||||
library;
|
||||
|
||||
import 'package:flutter/widgets.dart' show FontWeight;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI face — Josefin Sans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The bundled application UI family. Always resolved first.
|
||||
const String clideUiFamily = 'JosefinSans';
|
||||
|
||||
/// Default weight for UI text. Josefin Sans reads well at Light; the
|
||||
/// rest of the design adjusts contrast and size to stay legible.
|
||||
const FontWeight clideUiDefaultWeight = FontWeight.w300;
|
||||
|
||||
/// System fallback chain for the UI face. Sans-serif humanist faces
|
||||
/// that sit close to Josefin's proportions, ordered by platform.
|
||||
const List<String> clideUiFamilyFallback = [
|
||||
// User system install of Josefin, if any.
|
||||
'Josefin Sans',
|
||||
// Platform humanist sans defaults.
|
||||
'Inter',
|
||||
'Helvetica Neue',
|
||||
'Helvetica',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Monospace face — JetBrains Mono
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The bundled monospace family. Always resolved first.
|
||||
const String clideMonoFamily = 'JetBrainsMono';
|
||||
|
||||
/// System fallback chain. Ordered by platform prevalence + quality of
|
||||
/// programming-ligature / box-drawing coverage.
|
||||
const List<String> clideMonoFamilyFallback = [
|
||||
// macOS
|
||||
'SF Mono',
|
||||
'Menlo',
|
||||
'Monaco',
|
||||
// Linux — user system install under the canonical PostScript name
|
||||
'JetBrains Mono',
|
||||
'Fira Code',
|
||||
'Hack',
|
||||
'DejaVu Sans Mono',
|
||||
'Liberation Mono',
|
||||
// Windows
|
||||
'Cascadia Code',
|
||||
'Consolas',
|
||||
// Last resort
|
||||
'monospace',
|
||||
];
|
||||
@@ -13,6 +13,7 @@ export 'src/clide_surface.dart';
|
||||
export 'src/clide_tab_bar.dart';
|
||||
export 'src/clide_text.dart';
|
||||
export 'src/clide_tooltip.dart';
|
||||
export 'src/typography.dart';
|
||||
export 'src/icons/check.dart';
|
||||
export 'src/icons/chevron.dart';
|
||||
export 'src/icons/dot.dart';
|
||||
|
||||
@@ -53,3 +53,40 @@ flutter:
|
||||
assets:
|
||||
- lib/kernel/src/theme/themes/
|
||||
- lib/kernel/src/i18n/catalog/
|
||||
# Licensing — read by the About screen. `licenses.yaml` is the
|
||||
# manifest (self + runtime deps + dev deps); each `license_file`
|
||||
# pointer must also be a bundled asset so the About screen can
|
||||
# display each dependency's full license text at runtime.
|
||||
# Root LICENSE is mirrored into assets/LICENSE for the same reason
|
||||
# (root paths can't be referenced from a Flutter package's assets).
|
||||
# See CLAUDE.md "Dependencies & supply chain" and D-042.
|
||||
- assets/licenses.yaml
|
||||
- assets/LICENSE
|
||||
- assets/fonts/jetbrains_mono/OFL.txt
|
||||
- assets/fonts/josefin_sans/OFL.txt
|
||||
|
||||
# Bundled fonts. OFL-licensed; license files live alongside each.
|
||||
#
|
||||
# JosefinSans — UI default. Variable font covers weights 100-700
|
||||
# plus an italic axis companion; we default to Light (300) per the
|
||||
# project's aesthetic direction.
|
||||
#
|
||||
# JetBrainsMono — terminal / code surfaces. Static weights keep
|
||||
# rendering predictable across xterm.dart, goldens, and CI.
|
||||
fonts:
|
||||
- family: JosefinSans
|
||||
fonts:
|
||||
- asset: assets/fonts/josefin_sans/JosefinSans-VariableFont.ttf
|
||||
- asset: assets/fonts/josefin_sans/JosefinSans-Italic-VariableFont.ttf
|
||||
style: italic
|
||||
|
||||
- family: JetBrainsMono
|
||||
fonts:
|
||||
- asset: assets/fonts/jetbrains_mono/JetBrainsMono-Regular.ttf
|
||||
- asset: assets/fonts/jetbrains_mono/JetBrainsMono-Italic.ttf
|
||||
style: italic
|
||||
- asset: assets/fonts/jetbrains_mono/JetBrainsMono-Bold.ttf
|
||||
weight: 700
|
||||
- asset: assets/fonts/jetbrains_mono/JetBrainsMono-BoldItalic.ttf
|
||||
weight: 700
|
||||
style: italic
|
||||
|
||||
@@ -18,6 +18,13 @@ Toolchain, supply chain, CI, ignore strategy.
|
||||
- **Cost:** PRs don't run CI yet; `make push-check` is the gate until activation.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-042: Dependencies documented in `licenses.yaml`
|
||||
- **Date:** 2026-04-22
|
||||
- **Decision:** `app/assets/licenses.yaml` has three sections: `self:` (clide's MIT license, rendered first in the About screen so the user knows what they're running), `dependencies:` (third-party artefacts that **ship in the binary** — fonts, runtime Dart packages, native supporter tools, bundled data), and `dev_dependencies:` (build-time-only tooling — test runners, mocks, lints, golden harness — tracked for audit but **not rendered** in the About screen because they don't reach the user). Each entry has name, kind, version, homepage, license identifier, and a one-line purpose; runtime entries also carry a `license_file:` pointer to the bundled license text so the About screen can display it verbatim. Adding any dependency is a two-step commit: add the artefact **and** the corresponding `licenses.yaml` entry in the same changeset, under the correct section.
|
||||
- **Rationale:** Complements [D-031](#d-031-prefer-zero-deps-exact-pin). Prefer-zero-deps is a *budget*; `licenses.yaml` is the *visible consequence*. An extra row in the About screen is a review-time signal that the shipped-binary surface grew. Splitting dev deps out keeps the user-facing list small and honest — a test framework is not something the user needs to see in About — while still documenting every supply-chain input for audit completeness. The runtime entries discharge the redistribution obligations bundled licenses impose (OFL, MIT, BSD all require preserving the license text alongside the binary) without ad-hoc NOTICE files.
|
||||
- **Cost:** One extra edit per dep. Zero tolerance for drift — an un-listed dep is a contributor-visible bug. Until the About screen lands at Tier 6, `licenses.yaml` is accurate but not rendered; the discipline applies from now regardless so Tier 6 inherits a clean list.
|
||||
- **Raised by:** 2026-04-22 planning (user-directed best practice).
|
||||
|
||||
### D-033: Golden-output ignore pattern — `coverage.*` excludes output, not scripts
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** `.gitignore` excludes `coverage.*` (the lcov output files from `flutter test --coverage`). Coverage-related scripts are named `ci/test_coverage.sh` (not `ci/coverage.sh`) to stay outside the pattern.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# ptyc — PTY-spawn helper for clide.
|
||||
#
|
||||
# Single source file, libc only. No configure step. Builds everywhere a
|
||||
# POSIX-y C compiler + unix sockets exist.
|
||||
|
||||
CC ?= cc
|
||||
CFLAGS ?= -std=c11 -Wall -Wextra -Wpedantic -Werror -O2 -D_FORTIFY_SOURCE=2
|
||||
LDFLAGS ?=
|
||||
|
||||
BIN := bin/ptyc
|
||||
|
||||
.PHONY: all
|
||||
all: $(BIN)
|
||||
|
||||
$(BIN): ptyc.c | bin
|
||||
$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
|
||||
|
||||
bin:
|
||||
mkdir -p bin
|
||||
|
||||
.PHONY: test
|
||||
test: $(BIN)
|
||||
./test_ptyc.sh
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf bin *.o
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
# ptyc
|
||||
|
||||
Small POSIX helper that spawns a child process under a PTY and hands
|
||||
the master fd back to its caller. Language-agnostic; usable from any
|
||||
program that can fork a subprocess and receive a file descriptor over a
|
||||
unix socket.
|
||||
|
||||
Clide uses it for every PTY it owns (terminal panes, Claude sessions,
|
||||
tmux wrappers, LSP servers, debug adapters). See
|
||||
[`D-005`](../decisions/architecture.md#d-005-dart-core-sidecar-dissolved-ptyc-as-pql-peer)
|
||||
for the architectural rationale; ptyc is a peer of
|
||||
[`pql`](https://github.com/postmeridiem/pql), not a clide subsystem.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
make # produces bin/ptyc
|
||||
make test # runs test_ptyc.sh against the built binary
|
||||
make clean
|
||||
```
|
||||
|
||||
No third-party dependencies. `CC`, `CFLAGS`, and `LDFLAGS` are
|
||||
overrideable in the usual way.
|
||||
|
||||
## Wire contract
|
||||
|
||||
ptyc is a one-shot helper. The caller:
|
||||
|
||||
1. Creates a `socketpair(AF_UNIX, SOCK_STREAM, 0)`.
|
||||
2. Launches `ptyc` as a subprocess, passing one end of the socket to
|
||||
the child as **file descriptor 3** (the default) or whatever fd is
|
||||
given in the `PTYC_SOCK_FD` environment variable. The other end of
|
||||
the socket stays with the caller. The env-var override exists
|
||||
because some language runtimes (Python's `subprocess` with
|
||||
`stdout=PIPE`, for example) shuffle their own pipe fds through the
|
||||
low numbers and it's cheaper for the caller to pick a higher fd
|
||||
than to dup2 it down.
|
||||
3. Writes the request as **JSON on stdin** and closes stdin (EOF
|
||||
signals end of request).
|
||||
4. Receives the master PTY fd over the socket via **`SCM_RIGHTS`**
|
||||
ancillary data (with a single-byte `'x'` payload so the receiver
|
||||
knows when to `recvmsg`).
|
||||
5. Reads the success response from stdout (single line of JSON) and
|
||||
reaps the exited `ptyc` process.
|
||||
|
||||
### Request
|
||||
|
||||
JSON object on stdin. All fields optional except `argv`.
|
||||
|
||||
```json
|
||||
{
|
||||
"argv": ["bash", "-l"],
|
||||
"cwd": "/home/me/work",
|
||||
"env": {"TERM": "xterm-256color", "LANG": "en_US.UTF-8"},
|
||||
"cols": 80,
|
||||
"rows": 24
|
||||
}
|
||||
```
|
||||
|
||||
- `argv` — required, non-empty array of strings. `argv[0]` is resolved
|
||||
via `PATH`.
|
||||
- `cwd` — optional. If omitted, the child inherits ptyc's cwd.
|
||||
- `env` — optional object. If present, the child's environment is
|
||||
**replaced** with exactly the keys given (ptyc does `clearenv()` and
|
||||
then `putenv` per entry). If absent, the child inherits ptyc's
|
||||
environment. This is a deliberate choice: the daemon is expected to
|
||||
build the env it wants, not rely on a merge.
|
||||
- `cols`, `rows` — optional. Default `80` × `24`. Applied via
|
||||
`TIOCSWINSZ` before fork.
|
||||
|
||||
### Success response (stdout)
|
||||
|
||||
```json
|
||||
{"ok":true,"pid":12345}
|
||||
```
|
||||
|
||||
One line, trailing newline. The master PTY fd is already on the socket
|
||||
by the time stdout is written. `pid` is the spawned child's PID — the
|
||||
caller is responsible for `waitpid`'ing it when appropriate.
|
||||
|
||||
### Error response (stderr)
|
||||
|
||||
```json
|
||||
{"ok":false,"error":"exec: No such file or directory","errno":2}
|
||||
```
|
||||
|
||||
Written on stderr. No fd is sent. ptyc exits with a non-zero code.
|
||||
|
||||
### Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `0` | Success — fd sent, success response on stdout. |
|
||||
| `1` | Bad request — JSON parse error, missing `argv`, bad field values. |
|
||||
| `2` | Syscall failed — fork, exec, PTY open, `sendmsg`, etc. Check `errno` in the response. |
|
||||
|
||||
## Limits
|
||||
|
||||
Compile-time caps, deliberately small:
|
||||
|
||||
- `MAX_ARGV` = 64 entries
|
||||
- `MAX_ENV` = 256 entries
|
||||
- `MAX_INPUT` = 64 KiB request size
|
||||
|
||||
These are far above what any reasonable pane invocation needs; if you
|
||||
hit them you're holding ptyc wrong. Edit the `#define`s in `ptyc.c` and
|
||||
rebuild.
|
||||
|
||||
## Security notes
|
||||
|
||||
- The JSON parser is scoped to the shape above. It rejects anything
|
||||
else. Strings support the standard `\"` `\\` `\/` `\b` `\f` `\n`
|
||||
`\r` `\t` escapes and ASCII-range `\uXXXX`. Non-ASCII Unicode escapes
|
||||
(and surrogate pairs) are rejected — the daemon is expected to emit
|
||||
raw UTF-8 bytes.
|
||||
- Input is trusted (daemon is local, same user). ptyc does not sanitise
|
||||
argv or env beyond format-level checks — if the daemon asks ptyc to
|
||||
exec `rm`, ptyc execs `rm`.
|
||||
- ptyc does not `setuid` or `setgid`. It runs as the invoking user.
|
||||
|
||||
## Session persistence
|
||||
|
||||
ptyc is stateless and one-shot. Session persistence (survive app
|
||||
restart) is the **caller's** concern. Clide achieves it by spawning
|
||||
ptyc with `tmux new-session -A -s <name> -- <cmd>` for Claude panes;
|
||||
tmux handles the persistence layer and ptyc just spawns tmux. See
|
||||
`D-041` (Claude panes — one primary per repo, tmux-backed).
|
||||
+476
@@ -0,0 +1,476 @@
|
||||
/*
|
||||
* ptyc — spawn a child under a PTY, hand the master fd back.
|
||||
*
|
||||
* Wire contract (documented in README.md):
|
||||
* stdin : JSON request {"argv":[...],"cwd":"...","env":{...},"cols":N,"rows":N}
|
||||
* stdout : JSON response {"ok":true,"pid":N} on success
|
||||
* stderr : JSON diagnostic {"ok":false,"error":"...","errno":N} on failure
|
||||
* fd 3 : unix-domain socket; master fd is sent over SCM_RIGHTS on success
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 success
|
||||
* 1 bad request (JSON parse error, missing field, bad value)
|
||||
* 2 syscall failed (fork/exec/pty/socket)
|
||||
*
|
||||
* Dependencies: libc only. POSIX APIs where available (posix_openpt,
|
||||
* grantpt, unlockpt, ptsname). Single-threaded, one-shot, ~300 LOC.
|
||||
*/
|
||||
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#define _XOPEN_SOURCE 600
|
||||
|
||||
#include <ctype.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <termios.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* POSIX exposes `environ` but requires an explicit declaration. We
|
||||
* set it to NULL in the child to replace the inherited environment
|
||||
* when the caller supplied one — `clearenv()` is GNU-only, not POSIX. */
|
||||
extern char **environ;
|
||||
|
||||
/* Discard write() result without tripping warn_unused_result. We only
|
||||
* call this on the exec-failure pipe in the child immediately before
|
||||
* _exit(127); the parent either receives the bytes or notices EOF via
|
||||
* the CLOEXEC pipe. Nothing useful to do with the return value. */
|
||||
static void report_errno(int fd, int e) {
|
||||
ssize_t r = write(fd, &e, sizeof(e));
|
||||
(void)r;
|
||||
}
|
||||
|
||||
#define MAX_ARGV 64
|
||||
#define MAX_ENV 256
|
||||
#define MAX_INPUT (64 * 1024)
|
||||
|
||||
/* -------------------------------------------------------------------- */
|
||||
/* error reporting */
|
||||
/* -------------------------------------------------------------------- */
|
||||
|
||||
static void die_bad_request(const char *msg) {
|
||||
fprintf(stderr, "{\"ok\":false,\"error\":\"%s\",\"errno\":0}\n", msg);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
static void die_syscall(const char *msg) {
|
||||
int e = errno;
|
||||
/* Avoid quoting edge cases: strerror results don't contain quotes on
|
||||
* any platform we care about; if this ever bites us we'll escape. */
|
||||
fprintf(stderr, "{\"ok\":false,\"error\":\"%s: %s\",\"errno\":%d}\n", msg,
|
||||
strerror(e), e);
|
||||
exit(2);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- */
|
||||
/* minimal JSON parser */
|
||||
/* */
|
||||
/* Scoped to exactly the shape we accept. String escapes supported for */
|
||||
/* the subset we emit (\" \\ \n \r \t \b \f \/ and \uXXXX for ASCII). */
|
||||
/* Surrogate pairs, nested arrays, and non-string numbers-as-keys are */
|
||||
/* not supported — the daemon never emits them. */
|
||||
/* -------------------------------------------------------------------- */
|
||||
|
||||
typedef struct {
|
||||
const char *src;
|
||||
size_t len;
|
||||
size_t pos;
|
||||
} Parser;
|
||||
|
||||
static void p_skip_ws(Parser *p) {
|
||||
while (p->pos < p->len) {
|
||||
char c = p->src[p->pos];
|
||||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
|
||||
p->pos++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static int p_peek(Parser *p) {
|
||||
p_skip_ws(p);
|
||||
return p->pos < p->len ? (unsigned char)p->src[p->pos] : -1;
|
||||
}
|
||||
|
||||
static int p_expect(Parser *p, char c) {
|
||||
if (p_peek(p) != (unsigned char)c) return 0;
|
||||
p->pos++;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Parse a JSON string into a freshly-allocated NUL-terminated buffer. */
|
||||
static char *p_string(Parser *p) {
|
||||
if (p_peek(p) != '"') return NULL;
|
||||
p->pos++;
|
||||
size_t start = p->pos;
|
||||
/* First pass: find end and compute output length. */
|
||||
size_t out_len = 0;
|
||||
while (p->pos < p->len && p->src[p->pos] != '"') {
|
||||
if (p->src[p->pos] == '\\') {
|
||||
if (p->pos + 1 >= p->len) return NULL;
|
||||
char esc = p->src[p->pos + 1];
|
||||
if (esc == 'u') {
|
||||
if (p->pos + 5 >= p->len) return NULL;
|
||||
/* We only accept ASCII in \uXXXX. */
|
||||
for (int i = 2; i < 6; i++) {
|
||||
if (!isxdigit((unsigned char)p->src[p->pos + i])) return NULL;
|
||||
}
|
||||
p->pos += 6;
|
||||
} else {
|
||||
p->pos += 2;
|
||||
}
|
||||
out_len++;
|
||||
} else {
|
||||
p->pos++;
|
||||
out_len++;
|
||||
}
|
||||
}
|
||||
if (p->pos >= p->len || p->src[p->pos] != '"') return NULL;
|
||||
size_t end = p->pos;
|
||||
p->pos++; /* consume closing quote */
|
||||
|
||||
char *out = malloc(out_len + 1);
|
||||
if (!out) return NULL;
|
||||
size_t j = 0;
|
||||
for (size_t i = start; i < end;) {
|
||||
if (p->src[i] == '\\') {
|
||||
char esc = p->src[i + 1];
|
||||
switch (esc) {
|
||||
case '"': out[j++] = '"'; i += 2; break;
|
||||
case '\\': out[j++] = '\\'; i += 2; break;
|
||||
case '/': out[j++] = '/'; i += 2; break;
|
||||
case 'b': out[j++] = '\b'; i += 2; break;
|
||||
case 'f': out[j++] = '\f'; i += 2; break;
|
||||
case 'n': out[j++] = '\n'; i += 2; break;
|
||||
case 'r': out[j++] = '\r'; i += 2; break;
|
||||
case 't': out[j++] = '\t'; i += 2; break;
|
||||
case 'u': {
|
||||
unsigned int cp = 0;
|
||||
for (int k = 0; k < 4; k++) {
|
||||
char h = p->src[i + 2 + k];
|
||||
cp <<= 4;
|
||||
if (h >= '0' && h <= '9') cp |= (unsigned)(h - '0');
|
||||
else if (h >= 'a' && h <= 'f') cp |= (unsigned)(h - 'a' + 10);
|
||||
else if (h >= 'A' && h <= 'F') cp |= (unsigned)(h - 'A' + 10);
|
||||
}
|
||||
/* ASCII-range only. Anything else is a request-format bug. */
|
||||
if (cp > 0x7f) { free(out); return NULL; }
|
||||
out[j++] = (char)cp;
|
||||
i += 6;
|
||||
break;
|
||||
}
|
||||
default: free(out); return NULL;
|
||||
}
|
||||
} else {
|
||||
out[j++] = p->src[i++];
|
||||
}
|
||||
}
|
||||
out[j] = '\0';
|
||||
return out;
|
||||
}
|
||||
|
||||
static int p_int(Parser *p, long *out) {
|
||||
p_skip_ws(p);
|
||||
size_t start = p->pos;
|
||||
if (p->pos < p->len && (p->src[p->pos] == '-' || p->src[p->pos] == '+'))
|
||||
p->pos++;
|
||||
int digits = 0;
|
||||
while (p->pos < p->len && isdigit((unsigned char)p->src[p->pos])) {
|
||||
p->pos++;
|
||||
digits++;
|
||||
}
|
||||
if (!digits) {
|
||||
p->pos = start;
|
||||
return 0;
|
||||
}
|
||||
char buf[32];
|
||||
size_t n = p->pos - start;
|
||||
if (n >= sizeof(buf)) return 0;
|
||||
memcpy(buf, p->src + start, n);
|
||||
buf[n] = '\0';
|
||||
*out = strtol(buf, NULL, 10);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- */
|
||||
/* request */
|
||||
/* -------------------------------------------------------------------- */
|
||||
|
||||
typedef struct {
|
||||
char *argv[MAX_ARGV + 1]; /* NULL-terminated */
|
||||
int argc;
|
||||
char *cwd; /* optional, NULL means inherit */
|
||||
char *env[MAX_ENV + 1]; /* each "KEY=VAL" */
|
||||
int envc;
|
||||
int cols;
|
||||
int rows;
|
||||
} Request;
|
||||
|
||||
static void req_init(Request *r) {
|
||||
memset(r, 0, sizeof(*r));
|
||||
r->cols = 80;
|
||||
r->rows = 24;
|
||||
}
|
||||
|
||||
static void req_free(Request *r) {
|
||||
for (int i = 0; i < r->argc; i++) free(r->argv[i]);
|
||||
for (int i = 0; i < r->envc; i++) free(r->env[i]);
|
||||
free(r->cwd);
|
||||
}
|
||||
|
||||
static void parse_argv(Parser *p, Request *r) {
|
||||
if (!p_expect(p, '[')) die_bad_request("argv must be an array");
|
||||
if (p_peek(p) == ']') { p->pos++; return; }
|
||||
for (;;) {
|
||||
if (r->argc >= MAX_ARGV) die_bad_request("argv too long");
|
||||
char *s = p_string(p);
|
||||
if (!s) die_bad_request("argv element must be a string");
|
||||
r->argv[r->argc++] = s;
|
||||
if (p_expect(p, ',')) continue;
|
||||
if (p_expect(p, ']')) break;
|
||||
die_bad_request("malformed argv array");
|
||||
}
|
||||
}
|
||||
|
||||
static void parse_env(Parser *p, Request *r) {
|
||||
if (!p_expect(p, '{')) die_bad_request("env must be an object");
|
||||
if (p_peek(p) == '}') { p->pos++; return; }
|
||||
for (;;) {
|
||||
if (r->envc >= MAX_ENV) die_bad_request("env too large");
|
||||
char *k = p_string(p);
|
||||
if (!k) die_bad_request("env key must be a string");
|
||||
if (!p_expect(p, ':')) { free(k); die_bad_request("env missing ':'"); }
|
||||
char *v = p_string(p);
|
||||
if (!v) { free(k); die_bad_request("env value must be a string"); }
|
||||
size_t kl = strlen(k), vl = strlen(v);
|
||||
char *kv = malloc(kl + 1 + vl + 1);
|
||||
if (!kv) { free(k); free(v); die_syscall("malloc"); }
|
||||
memcpy(kv, k, kl);
|
||||
kv[kl] = '=';
|
||||
memcpy(kv + kl + 1, v, vl);
|
||||
kv[kl + 1 + vl] = '\0';
|
||||
free(k); free(v);
|
||||
r->env[r->envc++] = kv;
|
||||
if (p_expect(p, ',')) continue;
|
||||
if (p_expect(p, '}')) break;
|
||||
die_bad_request("malformed env object");
|
||||
}
|
||||
}
|
||||
|
||||
static void parse_request(const char *src, size_t len, Request *r) {
|
||||
Parser p = { .src = src, .len = len, .pos = 0 };
|
||||
if (!p_expect(&p, '{')) die_bad_request("top-level must be an object");
|
||||
if (p_peek(&p) == '}') { p.pos++; goto done; }
|
||||
for (;;) {
|
||||
char *key = p_string(&p);
|
||||
if (!key) die_bad_request("key must be a string");
|
||||
if (!p_expect(&p, ':')) { free(key); die_bad_request("missing ':'"); }
|
||||
if (strcmp(key, "argv") == 0) {
|
||||
parse_argv(&p, r);
|
||||
} else if (strcmp(key, "cwd") == 0) {
|
||||
r->cwd = p_string(&p);
|
||||
if (!r->cwd) { free(key); die_bad_request("cwd must be a string"); }
|
||||
} else if (strcmp(key, "env") == 0) {
|
||||
parse_env(&p, r);
|
||||
} else if (strcmp(key, "cols") == 0) {
|
||||
long v; if (!p_int(&p, &v)) { free(key); die_bad_request("cols must be an integer"); }
|
||||
if (v < 1 || v > 65535) { free(key); die_bad_request("cols out of range"); }
|
||||
r->cols = (int)v;
|
||||
} else if (strcmp(key, "rows") == 0) {
|
||||
long v; if (!p_int(&p, &v)) { free(key); die_bad_request("rows must be an integer"); }
|
||||
if (v < 1 || v > 65535) { free(key); die_bad_request("rows out of range"); }
|
||||
r->rows = (int)v;
|
||||
} else {
|
||||
free(key);
|
||||
die_bad_request("unknown key");
|
||||
}
|
||||
free(key);
|
||||
if (p_expect(&p, ',')) continue;
|
||||
if (p_expect(&p, '}')) break;
|
||||
die_bad_request("malformed object");
|
||||
}
|
||||
done:
|
||||
if (r->argc == 0) die_bad_request("argv is required and non-empty");
|
||||
r->argv[r->argc] = NULL;
|
||||
r->env[r->envc] = NULL;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- */
|
||||
/* read stdin into a bounded buffer */
|
||||
/* -------------------------------------------------------------------- */
|
||||
|
||||
static char *slurp_stdin(size_t *out_len) {
|
||||
char *buf = malloc(MAX_INPUT);
|
||||
if (!buf) die_syscall("malloc");
|
||||
size_t n = 0;
|
||||
while (n < MAX_INPUT) {
|
||||
ssize_t r = read(0, buf + n, MAX_INPUT - n);
|
||||
if (r == 0) break;
|
||||
if (r < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
die_syscall("read(stdin)");
|
||||
}
|
||||
n += (size_t)r;
|
||||
}
|
||||
if (n == MAX_INPUT) die_bad_request("request too large");
|
||||
*out_len = n;
|
||||
return buf;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- */
|
||||
/* PTY open + spawn */
|
||||
/* -------------------------------------------------------------------- */
|
||||
|
||||
static void send_fd(int sock, int fd) {
|
||||
/* sendmsg with SCM_RIGHTS; one byte payload so receiver knows to read. */
|
||||
char byte = 'x';
|
||||
struct iovec iov = { .iov_base = &byte, .iov_len = 1 };
|
||||
union {
|
||||
struct cmsghdr hdr;
|
||||
char buf[CMSG_SPACE(sizeof(int))];
|
||||
} cbuf;
|
||||
memset(&cbuf, 0, sizeof(cbuf));
|
||||
|
||||
struct msghdr msg = {0};
|
||||
msg.msg_iov = &iov;
|
||||
msg.msg_iovlen = 1;
|
||||
msg.msg_control = cbuf.buf;
|
||||
msg.msg_controllen = sizeof(cbuf.buf);
|
||||
|
||||
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
|
||||
cmsg->cmsg_level = SOL_SOCKET;
|
||||
cmsg->cmsg_type = SCM_RIGHTS;
|
||||
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
|
||||
memcpy(CMSG_DATA(cmsg), &fd, sizeof(int));
|
||||
|
||||
for (;;) {
|
||||
ssize_t r = sendmsg(sock, &msg, 0);
|
||||
if (r < 0 && errno == EINTR) continue;
|
||||
if (r < 0) die_syscall("sendmsg(fd)");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
Request req;
|
||||
req_init(&req);
|
||||
|
||||
size_t in_len = 0;
|
||||
char *in = slurp_stdin(&in_len);
|
||||
parse_request(in, in_len, &req);
|
||||
free(in);
|
||||
|
||||
/* 1. open master */
|
||||
int master = posix_openpt(O_RDWR | O_NOCTTY);
|
||||
if (master < 0) die_syscall("posix_openpt");
|
||||
if (grantpt(master) < 0) die_syscall("grantpt");
|
||||
if (unlockpt(master) < 0) die_syscall("unlockpt");
|
||||
|
||||
/* 2. open slave (ptsname is POSIX; we're single-threaded) */
|
||||
const char *slave_path = ptsname(master);
|
||||
if (!slave_path) die_syscall("ptsname");
|
||||
int slave = open(slave_path, O_RDWR | O_NOCTTY);
|
||||
if (slave < 0) die_syscall("open(slave)");
|
||||
|
||||
/* 3. apply window size */
|
||||
struct winsize ws = {0};
|
||||
ws.ws_col = (unsigned short)req.cols;
|
||||
ws.ws_row = (unsigned short)req.rows;
|
||||
if (ioctl(master, TIOCSWINSZ, &ws) < 0) die_syscall("ioctl(TIOCSWINSZ)");
|
||||
|
||||
/* 4. exec-failure-reporting pipe (CLOEXEC so it auto-closes on success) */
|
||||
int ef[2];
|
||||
if (pipe(ef) < 0) die_syscall("pipe");
|
||||
if (fcntl(ef[1], F_SETFD, FD_CLOEXEC) < 0) die_syscall("fcntl(FD_CLOEXEC)");
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) die_syscall("fork");
|
||||
|
||||
if (pid == 0) {
|
||||
/* ---- child ---- */
|
||||
close(master);
|
||||
close(ef[0]);
|
||||
|
||||
if (setsid() < 0) { report_errno(ef[1], errno); _exit(127); }
|
||||
#ifdef TIOCSCTTY
|
||||
if (ioctl(slave, TIOCSCTTY, 0) < 0) { report_errno(ef[1], errno); _exit(127); }
|
||||
#endif
|
||||
if (dup2(slave, 0) < 0 || dup2(slave, 1) < 0 || dup2(slave, 2) < 0) {
|
||||
report_errno(ef[1], errno);
|
||||
_exit(127);
|
||||
}
|
||||
if (slave > 2) close(slave);
|
||||
|
||||
if (req.cwd && chdir(req.cwd) < 0) { report_errno(ef[1], errno); _exit(127); }
|
||||
|
||||
/* Replace the environment if the caller supplied one; otherwise
|
||||
* inherit. Daemon is expected to build the env it wants — this is
|
||||
* not a "merge" API. */
|
||||
if (req.envc > 0) {
|
||||
environ = NULL;
|
||||
for (int i = 0; i < req.envc; i++) {
|
||||
if (putenv(req.env[i]) != 0) { report_errno(ef[1], errno); _exit(127); }
|
||||
}
|
||||
}
|
||||
|
||||
execvp(req.argv[0], req.argv);
|
||||
/* execvp returned → failure */
|
||||
report_errno(ef[1], errno);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
/* ---- parent ---- */
|
||||
close(slave);
|
||||
close(ef[1]);
|
||||
|
||||
int child_errno = 0;
|
||||
ssize_t rr;
|
||||
for (;;) {
|
||||
rr = read(ef[0], &child_errno, sizeof(child_errno));
|
||||
if (rr < 0 && errno == EINTR) continue;
|
||||
break;
|
||||
}
|
||||
close(ef[0]);
|
||||
|
||||
if (rr == (ssize_t)sizeof(child_errno)) {
|
||||
/* exec failed in child; reap it so we don't leak a zombie. */
|
||||
int st;
|
||||
(void)waitpid(pid, &st, 0);
|
||||
close(master);
|
||||
errno = child_errno;
|
||||
die_syscall("execvp");
|
||||
}
|
||||
/* rr == 0: pipe closed via CLOEXEC on successful exec. */
|
||||
|
||||
/* Hand the master fd back to the parent caller over a unix socket.
|
||||
* Default fd is 3; callers that can't reliably place the socket at
|
||||
* fd 3 (e.g. Python's subprocess with stdout=PIPE shifts pipe fds
|
||||
* around fd 3) can override via PTYC_SOCK_FD. */
|
||||
int sock_fd = 3;
|
||||
const char *sock_env = getenv("PTYC_SOCK_FD");
|
||||
if (sock_env && *sock_env) {
|
||||
char *endp = NULL;
|
||||
long v = strtol(sock_env, &endp, 10);
|
||||
if (!endp || *endp != '\0' || v < 0 || v > 65535)
|
||||
die_bad_request("PTYC_SOCK_FD must be a non-negative integer");
|
||||
sock_fd = (int)v;
|
||||
}
|
||||
send_fd(sock_fd, master);
|
||||
close(master);
|
||||
|
||||
/* Emit success response on stdout and exit. */
|
||||
printf("{\"ok\":true,\"pid\":%ld}\n", (long)pid);
|
||||
fflush(stdout);
|
||||
|
||||
req_free(&req);
|
||||
return 0;
|
||||
}
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke test for ptyc. Uses python3 for the SCM_RIGHTS fd receive dance.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
BIN="$HERE/bin/ptyc"
|
||||
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
echo "test: bin/ptyc not built — run 'make' first" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
python3 - "$BIN" <<'PY'
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
ptyc = sys.argv[1]
|
||||
|
||||
|
||||
def spawn(req):
|
||||
"""Launch ptyc, pipe the request in, receive fd + response."""
|
||||
sock_parent, sock_child = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
child_fd = sock_child.fileno()
|
||||
env = {**os.environ, "PTYC_SOCK_FD": str(child_fd)}
|
||||
try:
|
||||
p = subprocess.Popen(
|
||||
[ptyc],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
pass_fds=(child_fd,),
|
||||
env=env,
|
||||
)
|
||||
finally:
|
||||
sock_child.close()
|
||||
|
||||
p.stdin.write(json.dumps(req).encode())
|
||||
p.stdin.close()
|
||||
|
||||
fd = None
|
||||
try:
|
||||
msg, ancdata, _flags, _addr = sock_parent.recvmsg(1, socket.CMSG_SPACE(4))
|
||||
for cmsg_level, cmsg_type, cmsg_data in ancdata:
|
||||
if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
|
||||
fd = int.from_bytes(cmsg_data[:4], "little")
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
sock_parent.close()
|
||||
|
||||
stdout = p.stdout.read().decode()
|
||||
stderr = p.stderr.read().decode()
|
||||
code = p.wait()
|
||||
return code, stdout, stderr, fd
|
||||
|
||||
|
||||
def expect_ok(req, reads_substr=None):
|
||||
code, out, err, fd = spawn(req)
|
||||
assert code == 0, f"exit={code}, stderr={err!r}"
|
||||
j = json.loads(out)
|
||||
assert j["ok"] is True, out
|
||||
assert j["pid"] > 0, out
|
||||
assert fd is not None and fd >= 0, "no fd received"
|
||||
pid = j["pid"]
|
||||
try:
|
||||
if reads_substr is not None:
|
||||
chunks = []
|
||||
deadline = time.time() + 5.0
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
data = os.read(fd, 4096)
|
||||
except OSError as e:
|
||||
if e.errno in (errno.EIO,): # child exited, PTY EOF on Linux
|
||||
break
|
||||
raise
|
||||
if not data:
|
||||
break
|
||||
chunks.append(data.decode(errors="replace"))
|
||||
if reads_substr in "".join(chunks):
|
||||
break
|
||||
got = "".join(chunks)
|
||||
assert reads_substr in got, f"expected {reads_substr!r} in {got!r}"
|
||||
finally:
|
||||
os.close(fd)
|
||||
# Child should exit on its own after producing its output for an
|
||||
# `echo`; give it a moment, then reap.
|
||||
try:
|
||||
for _ in range(20):
|
||||
rpid, _ = os.waitpid(pid, os.WNOHANG)
|
||||
if rpid == pid:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
os.kill(pid, 9)
|
||||
os.waitpid(pid, 0)
|
||||
except ChildProcessError:
|
||||
pass
|
||||
|
||||
|
||||
def expect_err(req, match_fragment):
|
||||
code, out, err, fd = spawn(req)
|
||||
assert code != 0, f"expected failure, got ok: {out!r}"
|
||||
assert fd is None, "error path must not send an fd"
|
||||
j = json.loads(err)
|
||||
assert j["ok"] is False, err
|
||||
assert match_fragment in j["error"], f"{match_fragment!r} not in {j['error']!r}"
|
||||
|
||||
|
||||
# 1. Happy path: echo prints and exits cleanly.
|
||||
expect_ok({"argv": ["/bin/echo", "hello-ptyc"]}, reads_substr="hello-ptyc")
|
||||
|
||||
# 2. env replacement works — child sees exactly the keys we pass.
|
||||
expect_ok(
|
||||
{"argv": ["/usr/bin/env"], "env": {"FOO": "bar", "PATH": "/usr/bin:/bin"}},
|
||||
reads_substr="FOO=bar",
|
||||
)
|
||||
|
||||
# 3. cwd respected.
|
||||
expect_ok({"argv": ["/bin/sh", "-c", "pwd"], "cwd": "/tmp"}, reads_substr="/tmp")
|
||||
|
||||
# 4. window size propagates (stty reports it).
|
||||
expect_ok(
|
||||
{"argv": ["/bin/sh", "-c", "stty size"], "cols": 132, "rows": 42},
|
||||
reads_substr="42 132",
|
||||
)
|
||||
|
||||
# 5. Bad request: missing argv.
|
||||
expect_err({}, "argv is required")
|
||||
|
||||
# 6. Bad request: argv not an array.
|
||||
expect_err({"argv": "bash"}, "argv must be an array")
|
||||
|
||||
# 7. exec failure reported on error channel.
|
||||
expect_err({"argv": ["/does/not/exist/nope"]}, "execvp")
|
||||
|
||||
# 8. Unknown key rejected.
|
||||
expect_err({"argv": ["true"], "wat": 1}, "unknown key")
|
||||
|
||||
print("ptyc: all smoke tests passed")
|
||||
PY
|
||||
Reference in New Issue
Block a user